fix(permissions): broadcast permission row-cache invalidations to peer regions

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 <noreply@anthropic.com>
This commit is contained in:
Juan Castro
2026-08-14 14:33:04 -04:00
co-authored by Claude Fable 5
parent e47ec447b0
commit d384a41c68
2 changed files with 39 additions and 2 deletions
@@ -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(
@@ -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<void> {
await this.publishCacheKeys({
keys: [this.#tokenCacheKey(tokenUid)],
broadcast: true,
});
}