diff --git a/packages/authz/src/store.ts b/packages/authz/src/store.ts index 3b4f1956..9ba7c5ee 100644 --- a/packages/authz/src/store.ts +++ b/packages/authz/src/store.ts @@ -88,3 +88,73 @@ export function memoryPermissionStore(): PermissionStore { }, }; } + +export interface CachedPermissionStore extends PermissionStore { + /** Drop one subject. Call after changing roles out of band. */ + invalidate(subjectId: string, scope?: AuthzScope): void; + invalidateAll(): void; + /** Cached entry count, for tests and diagnostics. */ + size(): number; +} + +export interface CacheOptions { + ttlMs?: number; + max?: number; +} + +/** + * Caches assignment reads. Writes through this decorator invalidate the + * affected subject immediately; changes made directly against the inner store + * need an explicit `invalidate()` call rather than waiting out the TTL. + */ +export function cachedPermissionStore( + inner: PermissionStore, + options: CacheOptions = {}, +): CachedPermissionStore { + const ttlMs = options.ttlMs ?? 5_000; + const max = options.max ?? 1_000; + const entries = new Map(); + + const cacheKey = (subjectId: string, scope?: AuthzScope) => `${scopeKey(scope)}�${subjectId}`; + const drop = (subjectId: string, scope?: AuthzScope) => { + entries.delete(cacheKey(subjectId, scope)); + // A global write changes what every tenant sees for that subject. + if (scopeKey(scope) === "") { + for (const key of [...entries.keys()]) { + if (key.endsWith(`�${subjectId}`)) entries.delete(key); + } + } + }; + + return { + async assignmentsFor(subjectId, scope) { + const key = cacheKey(subjectId, scope); + const hit = entries.get(key); + if (hit && Date.now() - hit.at < ttlMs) return hit.value; + const value = await inner.assignmentsFor(subjectId, scope); + if (entries.size >= max) entries.delete(entries.keys().next().value!); + entries.set(key, { at: Date.now(), value }); + return value; + }, + async assignRole(subjectId, role, scope) { + await inner.assignRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async revokeRole(subjectId, role, scope) { + await inner.revokeRole(subjectId, role, scope); + drop(subjectId, scope); + }, + async grant(subjectId, permission, effect, scope) { + await inner.grant(subjectId, permission, effect, scope); + drop(subjectId, scope); + }, + async revokeGrant(subjectId, permission, scope) { + await inner.revokeGrant(subjectId, permission, scope); + drop(subjectId, scope); + }, + listSubjects: (scope) => inner.listSubjects(scope), + invalidate: drop, + invalidateAll: () => entries.clear(), + size: () => entries.size, + }; +} diff --git a/packages/authz/test/store-cached.test.ts b/packages/authz/test/store-cached.test.ts new file mode 100644 index 00000000..7114a571 --- /dev/null +++ b/packages/authz/test/store-cached.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { cachedPermissionStore, memoryPermissionStore } from "../src/store.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// A cache must not change observable behaviour: writes invalidate internally. +runStoreConformance("cached(memory)", async () => cachedPermissionStore(memoryPermissionStore())); + +describe("cachedPermissionStore", () => { + test("serves a repeat read from cache", async () => { + const inner = memoryPermissionStore(); + let reads = 0; + const counting = { + ...inner, + assignmentsFor: (id: string, scope?: { tenantId?: string }) => { + reads++; + return inner.assignmentsFor(id, scope); + }, + }; + const store = cachedPermissionStore(counting, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignmentsFor("u1"); + expect(reads).toBe(1); + }); + + test("a write invalidates that subject", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await store.assignRole("u1", "editor"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("invalidate() drops a cached subject", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); // behind the cache's back + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + store.invalidate("u1"); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("entries expire after ttlMs", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 1 }); + await store.assignmentsFor("u1"); + await inner.assignRole("u1", "editor"); + await Bun.sleep(5); + expect((await store.assignmentsFor("u1")).roles).toEqual(["editor"]); + }); + + test("cache is bounded by max", async () => { + const store = cachedPermissionStore(memoryPermissionStore(), { ttlMs: 60_000, max: 2 }); + await store.assignmentsFor("a"); + await store.assignmentsFor("b"); + await store.assignmentsFor("c"); + expect(store.size()).toBeLessThanOrEqual(2); + }); + + test("scoped and global reads cache separately", async () => { + const inner = memoryPermissionStore(); + const store = cachedPermissionStore(inner, { ttlMs: 60_000 }); + await inner.assignRole("u1", "editor", { tenantId: "t1" }); + expect((await store.assignmentsFor("u1")).roles).toEqual([]); + expect((await store.assignmentsFor("u1", { tenantId: "t1" })).roles).toEqual(["editor"]); + }); +});