diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index ec2ae586ea..134f026fff 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -811,9 +811,6 @@ class AuthService extends BaseService { 'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?', [token_uid], ); - - const svc_permission = this.services.get('permission'); - svc_permission.invalidate_permission_scan_cache_for_access_token(token_uid); } /** diff --git a/src/backend/src/services/auth/PermissionScanRedisCacheSpace.test.js b/src/backend/src/services/auth/PermissionScanRedisCacheSpace.test.js new file mode 100644 index 0000000000..0946571875 --- /dev/null +++ b/src/backend/src/services/auth/PermissionScanRedisCacheSpace.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { PermissionScanRedisCacheSpace } from './PermissionScanRedisCacheSpace.js'; +import { PermissionUtil } from './permissionUtils.mjs'; + +describe('PermissionScanRedisCacheSpace', () => { + it('builds cache keys for actor and permission options', () => { + const actorUid = 'app-under-user:user-123:app-456'; + const permissionOptions = ['fs:node-1:read']; + const key = PermissionScanRedisCacheSpace.key({ + actorUid, + permissionOptions, + joinPermissionParts: PermissionUtil.join, + }); + + expect(key).toBe(PermissionUtil.join( + 'permission-scan', + actorUid, + 'options-list', + ...permissionOptions, + )); + }); + + it('builds stable exact keys for app-under-user + one permission', () => { + const actorUid = 'app-under-user:user-123:app-456'; + const permissionOptions = ['flag:app-is-authenticated']; + const key = PermissionScanRedisCacheSpace.key({ + actorUid, + permissionOptions, + joinPermissionParts: PermissionUtil.join, + }); + + expect(key).toBe(PermissionUtil.join( + 'permission-scan', + actorUid, + 'options-list', + ...permissionOptions, + )); + }); +}); diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js index 9398f3ca17..363f0c678f 100644 --- a/src/backend/src/services/auth/PermissionService.js +++ b/src/backend/src/services/auth/PermissionService.js @@ -56,10 +56,7 @@ class PermissionService extends BaseService { this._permission_rewriters = []; this._permission_implicators = []; this._permission_exploders = []; - - // Debounce constants and state - this._INVALIDATE_DEBOUNCE_MS = 200; - this._invalidate_app_under_user_debounce = {}; + this._PERMISSION_SCAN_CACHE_TTL_SECONDS = 20; } /** @@ -242,7 +239,7 @@ class PermissionService extends BaseService { }); await setRedisCacheValue(cacheKey, JSON.stringify(reading), { - ttlSeconds: 20, + ttlSeconds: this._PERMISSION_SCAN_CACHE_TTL_SECONDS, eventData: reading, }); @@ -250,68 +247,22 @@ class PermissionService extends BaseService { } /** - * Removes permission-scan cache entries for an access token. - * Used when revoking an access token so stale scan results are not served. - * Only keys for this token are removed (see PermissionUtil.permission_scan_cache_pattern_for_access_token). - * - * @param {string} token_uid - The access token UUID. - */ - invalidate_permission_scan_cache_for_access_token (token_uid) { - const kv = this.modules.memKVMap; - if ( ! kv?.keys ) return; - const pattern = PermissionUtil.permission_scan_cache_pattern_for_access_token(token_uid); - const keys = kv.keys(pattern); - if ( ! Array.isArray(keys) ) return; - for ( const key of keys ) { - kv.del(key); - } - } - - /** - * Removes permission-scan cache entries for a single app-under-user actor. - * Used after grant_user_app_permission so the next check sees the new permission - * without waiting for cache TTL. Only keys for this (user, app) are removed. - * - * @param {string} user_uuid - The user's UUID. - * @param {string} app_uid - The app UID. - * @returns {Promise} - */ - async invalidate_permission_scan_cache_for_app_under_user (user_uuid, app_uid) { - const prefix = PermissionUtil.permission_scan_cache_prefix_for_app_under_user(user_uuid, app_uid); - const pattern = `${prefix}*`; - let cursor = '0'; - const toDelete = []; - do { - const [next_cursor, keys] = await redisClient.scan(cursor, 'MATCH', pattern, 'COUNT', 100); - cursor = next_cursor; - if ( keys?.length ) toDelete.push(...keys); - } while ( cursor !== '0' ); - if ( toDelete.length ) await deleteRedisKeys(toDelete); - } - - /** - * Invalidates permission-scan cache for (user_uuid, app_uid). - * Runs immediately, or skips in a debounce period for this key. + * Removes a specific permission-scan cache entry for a single app-under-user actor. + * This targets only the exact key for (user_uuid, app_uid, permission). * * @param {string} user_uuid - The user's UUID. * @param {string} app_uid - The app UID. + * @param {string} permission - The permission string used in scan. * @returns {Promise} */ - async _schedule_invalidate_permission_scan_cache_for_app_under_user (user_uuid, app_uid) { - const key = `${user_uuid}:${app_uid}`; - if ( this._invalidate_app_under_user_debounce[key] ) return; - this._invalidate_app_under_user_debounce[key] = setTimeout(() => { - delete this._invalidate_app_under_user_debounce[key]; - }, this._INVALIDATE_DEBOUNCE_MS); - try { - await this.invalidate_permission_scan_cache_for_app_under_user(user_uuid, app_uid); - } catch ( e ) { - this.log.error('failed to invalidate permission scan cache', { - actor_uid: user_uuid, - app_uid, - error: e, - }); - } + async invalidate_permission_scan_cache_for_app_under_user (user_uuid, app_uid, permission) { + const actorUid = `app-under-user:${user_uuid}:${app_uid}`; + const cacheKey = PermissionScanRedisCacheSpace.key({ + actorUid, + permissionOptions: [permission], + joinPermissionParts: PermissionUtil.join, + }); + await deleteRedisKeys(cacheKey); } async validateUserPerms ({ actor, permissions }) { @@ -518,7 +469,7 @@ class PermissionService extends BaseService { ); // Invalidate permission-scan cache for this app-under-user so the next check sees the grant. - this._schedule_invalidate_permission_scan_cache_for_app_under_user(actor.type.user.uuid, app.uid); + this.invalidate_permission_scan_cache_for_app_under_user(actor.type.user.uuid, app_uid, permission); } /** diff --git a/src/backend/src/services/auth/permissionUtils.mjs b/src/backend/src/services/auth/permissionUtils.mjs index 25d93e6f1a..737b53dc7e 100644 --- a/src/backend/src/services/auth/permissionUtils.mjs +++ b/src/backend/src/services/auth/permissionUtils.mjs @@ -84,19 +84,6 @@ export const PermissionUtil = { ; }, - /** - * Glob pattern for permission-scan cache keys belonging to a given access token. - * Cache keys are built as join('permission-scan', actor.uid, 'options-list', ...); - * for access tokens, actor.uid ends with ':' + token_uid (token_uid is not escaped). - * Use with kv.keys() to list only entries for that token when invalidating. - * - * @param {string} token_uid - The access token UUID. - * @returns {string} A glob pattern matching only that token's permission-scan cache keys. - */ - permission_scan_cache_pattern_for_access_token (token_uid) { - return `permission-scan:*${token_uid}:options-list:*`; - }, - /** * Exact key prefix for permission-scan cache entries belonging to a given app-under-user actor. * Cache keys are built as join('permission-scan', actor.uid, 'options-list', ...); @@ -124,9 +111,12 @@ export const PermissionUtil = { */ reading_to_options ( // actual arguments - reading, parameters = {}, + reading, + parameters = {}, // recursion state - options = [], extras = [], path = [], + options = [], + extras = [], + path = [], ) { const to_path_item = finding => ({ key: finding.key,