Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/backend/src/services/auth/AuthService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
));
});
});
77 changes: 14 additions & 63 deletions src/backend/src/services/auth/PermissionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -242,76 +239,30 @@ class PermissionService extends BaseService {
});

await setRedisCacheValue(cacheKey, JSON.stringify(reading), {
ttlSeconds: 20,
ttlSeconds: this._PERMISSION_SCAN_CACHE_TTL_SECONDS,
eventData: reading,
});

return reading;
}

/**
* 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<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.
* 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 _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 }) {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
20 changes: 5 additions & 15 deletions src/backend/src/services/auth/permissionUtils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', ...);
Expand Down Expand Up @@ -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,
Expand Down
Loading