From 56166894a0a74b43d5a525533094c09114fe7bf3 Mon Sep 17 00:00:00 2001 From: Kavith Lokuhewage Date: Fri, 15 May 2026 16:52:37 +0530 Subject: [PATCH] fix(authz): bind eval cache key to user token so re-login forces re-evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache key was `(userEntityRef, action, resourcePath, environment)` — stable across sign-outs. A stale ABAC decision from before a binding change survived re-login for the full JWT TTL (up to 24h), and the only recovery was restarting the backend or waiting it out. Fold a token-derived hash into the key (mirroring AuthzProfileCache's existing buildKey pattern) so a new sign-in produces a new key and re-evaluates against the current binding state. Signed-off-by: Kavith Lokuhewage --- .../src/services/AuthzProfileCache.ts | 26 +++++++++++ .../src/services/AuthzProfileService.test.ts | 43 ++++++++++++++++++- .../src/services/AuthzProfileService.ts | 11 +++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileCache.ts b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileCache.ts index 24ad0d236..31ef04b90 100644 --- a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileCache.ts +++ b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileCache.ts @@ -152,9 +152,17 @@ export class AuthzProfileCache { * Keys are scoped per-user so we never leak one user's decision to another, * and per-(action, resourcePath, environment) so that the same UI rendering * the same button repeatedly hits the cache instead of /authz/evaluates. + * + * The token hash is included so that signing out + back in produces a new + * key and forces a re-evaluation. Otherwise a stale `false` decision from + * before a binding change would survive re-login for the full JWT TTL + * (up to 24h), and operators would have to wait or restart the backend + * to recover. Callers pass `'no-token'` when no token is available (rare + * fail-closed path) so the cache still functions. */ private buildEvaluationKey( userEntityRef: string, + tokenHash: string, action: string, resourcePath: string, environment: string | undefined, @@ -163,20 +171,36 @@ export class AuthzProfileCache { // other character) cannot collide with another distinct tuple. return `openchoreo:authz-eval:${JSON.stringify([ userEntityRef, + tokenHash, action, resourcePath, environment ?? null, ])}`; } + /** + * Hashes a user token into a short, non-reversible cache-key fragment. + * Mirrors the helper used by `buildKey()` for the capabilities cache so + * both caches partition the same way per token issuance. + */ + static hashToken(userToken: string): string { + return crypto + .createHash('sha256') + .update(userToken) + .digest('hex') + .substring(0, 16); + } + async getEvaluation( userEntityRef: string, + tokenHash: string, action: string, resourcePath: string, environment: string | undefined, ): Promise { const key = this.buildEvaluationKey( userEntityRef, + tokenHash, action, resourcePath, environment, @@ -186,6 +210,7 @@ export class AuthzProfileCache { async setEvaluation( userEntityRef: string, + tokenHash: string, action: string, resourcePath: string, environment: string | undefined, @@ -194,6 +219,7 @@ export class AuthzProfileCache { ): Promise { const key = this.buildEvaluationKey( userEntityRef, + tokenHash, action, resourcePath, environment, diff --git a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.test.ts b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.test.ts index 0bcca3c6a..25c0acaf7 100644 --- a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.test.ts +++ b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.test.ts @@ -391,9 +391,12 @@ describe('AuthzProfileService', () => { resource: { environment: 'team-shop/production' }, }); // Cache write must use the encoded form so two namespaces sharing - // the same env name do not collide. + // the same env name do not collide. The second positional argument is + // a token-derived hash so the cache key invalidates when the user + // signs out and back in (see token-scoped test below). expect(cache.setEvaluation).toHaveBeenCalledWith( 'user:default/alice', + expect.any(String), 'releasebinding:update', 'ns/team-shop/project/team-shop/component/snip-api-service', 'team-shop/production', @@ -402,6 +405,44 @@ describe('AuthzProfileService', () => { ); }); + it('binds the cache key to the user token so re-login forces re-evaluation', async () => { + const cache = createMockCache(); + cache.getByUser.mockResolvedValue(subjectProfile); + cache.getEvaluation.mockResolvedValue(undefined); + mockPOST.mockResolvedValue(createOkResponse([{ decision: false }])); + + const service = createService(cache); + const exp = Math.floor(Date.now() / 1000) + 3600; + const firstToken = buildJwt(exp); + // Different payload (e.g. fresh `iat` after re-login) → different JWT + // string → different hash. We only need the hash to differ. + const secondToken = `${firstToken}-second-session`; + + const input = { + action: 'releasebinding:view', + resourcePath: + 'ns/team-shop/project/team-shop/component/snip-api-service', + environment: 'development', + }; + + await service.evaluate(firstToken, 'user:default/alice', [input]); + await service.evaluate(secondToken, 'user:default/alice', [input]); + + // Both calls should have looked up the cache with the *same* + // userEntityRef / action / resourcePath / encoded-env tuple, but with + // *different* token-hash components. That difference is what allows the + // second sign-in to bypass a stale `false` from the first session. + expect(cache.getEvaluation).toHaveBeenCalledTimes(2); + const firstHash = cache.getEvaluation.mock.calls[0][1]; + const secondHash = cache.getEvaluation.mock.calls[1][1]; + expect(firstHash).toEqual(expect.any(String)); + expect(secondHash).toEqual(expect.any(String)); + expect(firstHash).not.toEqual(secondHash); + // And the backend was hit twice — the second call did not piggy-back + // on the first session's cached decision. + expect(mockPOST).toHaveBeenCalledTimes(2); + }); + it('passes env through as bare `{name}` when no namespace is in the resource path', async () => { const cache = createMockCache(); cache.getByUser.mockResolvedValue(subjectProfile); diff --git a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.ts b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.ts index 9c349fd50..bf6ad3cde 100644 --- a/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.ts +++ b/plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.ts @@ -344,6 +344,15 @@ export class AuthzProfileService { return encoded || undefined; }); + // Include a token-derived component in the cache key so that signing out + // and back in produces a new key and forces a re-evaluation. Without this, + // a stale `false` from before an authz binding change would survive + // re-login for the full JWT TTL (up to 24h on our setup), making the only + // recovery paths "wait" or "restart the backend". + const tokenHash = userToken + ? AuthzProfileCache.hashToken(userToken) + : 'no-token'; + // 1. Cache lookup for every input. Track which need a backend call. const results: (boolean | undefined)[] = new Array(inputs.length); const missingIdx: number[] = []; @@ -352,6 +361,7 @@ export class AuthzProfileService { const { action, resourcePath } = inputs[i]; results[i] = await this.cache.getEvaluation( userEntityRef, + tokenHash, action, resourcePath, encodedEnvs[i], @@ -450,6 +460,7 @@ export class AuthzProfileService { // backend (and stay isolated per namespace). await this.cache.setEvaluation( userEntityRef, + tokenHash, action, resourcePath, encodedEnvs[i],