fix: remove redis cache scan in favour of direct key invalidation (#2589)

This commit is contained in:
Daniel Salazar
2026-03-03 19:55:41 -08:00
committed by GitHub
parent 80cabca7ad
commit dbdead9ad1
4 changed files with 58 additions and 81 deletions
@@ -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);
}
/**
@@ -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,
));
});
});
@@ -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.
* 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<void>}
*/
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.
*
* @param {string} user_uuid - The user's UUID.
* @param {string} app_uid - The app UID.
* @returns {Promise<void>}
*/
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);
}
/**
@@ -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,