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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<boolean | undefined> {
const key = this.buildEvaluationKey(
userEntityRef,
tokenHash,
action,
resourcePath,
environment,
Expand All @@ -186,6 +210,7 @@ export class AuthzProfileCache {

async setEvaluation(
userEntityRef: string,
tokenHash: string,
action: string,
resourcePath: string,
environment: string | undefined,
Expand All @@ -194,6 +219,7 @@ export class AuthzProfileCache {
): Promise<void> {
const key = this.buildEvaluationKey(
userEntityRef,
tokenHash,
action,
resourcePath,
environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Comment on lines +347 to +355
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the evaluate() cache-key doc to include tokenHash.

The method comment still states caching is per (userEntityRef, action, resourcePath, environment), but the implementation now keys by token session too. Please align the docstring.

📝 Suggested doc fix
- * per `(userEntityRef, action, resourcePath, environment)` for the lifetime
+ * per `(userEntityRef, tokenHash, action, resourcePath, environment)` for the lifetime
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugins/permission-backend-module-openchoreo-policy/src/services/AuthzProfileService.ts`
around lines 347 - 355, The evaluate() method's docstring is out of date: update
the comment on AuthzProfileService.evaluate (and any related doc for
AuthzProfileCache) to include tokenHash (derived via
AuthzProfileCache.hashToken(userToken)) as part of the cache key in addition to
(userEntityRef, action, resourcePath, environment) so the documentation matches
the implementation that includes the token/session component in cache key
generation.

// 1. Cache lookup for every input. Track which need a backend call.
const results: (boolean | undefined)[] = new Array(inputs.length);
const missingIdx: number[] = [];
Expand All @@ -352,6 +361,7 @@ export class AuthzProfileService {
const { action, resourcePath } = inputs[i];
results[i] = await this.cache.getEvaluation(
userEntityRef,
tokenHash,
action,
resourcePath,
encodedEnvs[i],
Expand Down Expand Up @@ -450,6 +460,7 @@ export class AuthzProfileService {
// backend (and stay isolated per namespace).
await this.cache.setEvaluation(
userEntityRef,
tokenHash,
action,
resourcePath,
encodedEnvs[i],
Expand Down
Loading