feat(authz): add caching decorator for PermissionStore

This commit is contained in:
2026-08-04 16:47:50 +05:30
parent a01b7bc99e
commit 4362d49770
2 changed files with 136 additions and 0 deletions
+70
View File
@@ -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<string, { at: number; value: SubjectAssignments }>();
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,
};
}
+66
View File
@@ -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"]);
});
});