From d384a41c6870026aa41518e912b350a79ba6df14 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Fri, 14 Aug 2026 14:33:04 -0400 Subject: [PATCH] fix(permissions): broadcast permission row-cache invalidations to peer regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every publishCacheKeys call for the u2u, u2a, and access-token row caches omitted broadcast, so a revoke only cleared the mutating region's Redis. A peer region applied the replicated generation bump, re-scanned, read the deleted row from its own still-warm 5-minute row cache, and re-warmed the flat view from it — revoked access outlived the revoke by the row-cache TTL instead of the intended 60-second bound. CacheReplicationService already consumes these events; the emits were just never sent. Co-Authored-By: Claude Fable 5 --- .../stores/permission/PermissionStore.test.ts | 29 +++++++++++++++++++ .../stores/permission/PermissionStore.ts | 12 ++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts index 61116546a..fdcd52f35 100644 --- a/src/backend/stores/permission/PermissionStore.test.ts +++ b/src/backend/stores/permission/PermissionStore.test.ts @@ -854,6 +854,35 @@ describe('PermissionStore', () => { ).toEqual([]); }); + it('broadcasts a revoked row-cache key so peer regions drop it too', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + await store.upsertUserUserPerm( + holder.id, + issuer.id, + 'fs:u:read', + {}, + ); + + const seen: unknown[] = []; + server.clients.event.on('outer.cacheUpdate', (_key, data) => { + seen.push(data); + }); + + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:u:read', + issuer.id, + ); + + // Without the broadcast, a peer region's warm `perms:u2u:holder:*` + // cache keeps serving the revoked row for its full TTL — and every + // scan there re-warms the flat view from it. + expect(seen).toContainEqual({ + cacheKey: [`perms:u2u:holder:${holder.id}`], + }); + }); + it('returns nothing for an empty permission list', async () => { const holder = await makeUser(); expect(await store.readLinkedUserUserPerms(holder.id, [])).toEqual( diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 29c7a605a..099e43c70 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -257,6 +257,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2uCacheKey(holderUserId)], + broadcast: true, }); } @@ -280,6 +281,7 @@ export class PermissionStore extends PuterStore { if (!result.anyRowsAffected) return false; await this.publishCacheKeys({ keys: [this.#u2uCacheKey(holderUserId)], + broadcast: true, }); return true; } @@ -332,7 +334,8 @@ export class PermissionStore extends PuterStore { const keys = [ ...new Set(rows.map((r) => this.#u2uCacheKey(r.holder_user_id))), ]; - if (keys.length > 0) await this.publishCacheKeys({ keys }); + if (keys.length > 0) + await this.publishCacheKeys({ keys, broadcast: true }); return rows; } @@ -405,6 +408,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -419,6 +423,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -429,6 +434,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -501,7 +507,8 @@ export class PermissionStore extends PuterStore { .map((r) => this.#u2aCacheKey(r.user_id, r.app_id)), ), ]; - if (keys.length > 0) await this.publishCacheKeys({ keys }); + if (keys.length > 0) + await this.publishCacheKeys({ keys, broadcast: true }); return removed; } @@ -706,6 +713,7 @@ export class PermissionStore extends PuterStore { async invalidateAccessTokenPerms(tokenUid: string): Promise { await this.publishCacheKeys({ keys: [this.#tokenCacheKey(tokenUid)], + broadcast: true, }); }