Files
WRNexusJS/packages/authz/src/store.ts
T
ClintchizandClaude Opus 5 91e5c6e0c5 fix(authz): guard scopeKey's tenantId type, add deterministic C1/C2 guard
N1: scopeKey guarded the empty-string VALUE but not the TYPE. A
non-string tenantId (null, 0, false, an object) flowed through
un-normalised, and the adapters disagreed about the result - db
rejects null on NOT NULL, memory accepts it as an unreachable row; 0
and false stringify differently and could collide. Now
`typeof tenantId !== "string" || tenantId === ""` is refused with the
same WRN-AUTHZ-SCOPE error. Added a conformance case covering
null/0/false/{}.

N2: nothing failed if grant() were re-wrapped in db.tx, reintroducing
the shared-connection rollback from C1/C2 - timing-based tests can't
reliably prove a transaction is never opened. Added
db-no-transaction.test.ts: a fake Db with a spied driver.transaction
and statement-recording all/exec, driving every PermissionStore method
and asserting zero transaction calls and no "BEGIN" in any recorded
statement. Verified it fails when grant() is temporarily re-wrapped in
db.tx, then restored.

Also documents two things in db.ts as comments only: the UNIQUE
constraints are now load-bearing for ON CONFLICT/ON DUPLICATE KEY
target inference, and MySQL's VALUES(effect) upsert syntax is
deprecated since 8.0.20 (no MySQL server in CI to catch its removal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:06:11 +05:30

207 lines
7.5 KiB
TypeScript

import type { AuthzScope, SubjectAssignments } from "./types.ts";
export type GrantEffect = "allow" | "deny";
export interface PermissionStore {
assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments>;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
grant(
subjectId: string,
permission: string,
effect: GrantEffect,
scope?: AuthzScope,
): Promise<void>;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
listSubjects(scope?: AuthzScope): Promise<string[]>;
}
/**
* Global assignments are stored under the empty-string scope key. An OMITTED
* scope means global; an explicitly EMPTY or non-string tenantId is refused,
* because an empty string is indistinguishable from global (and would let a
* caller who controls the tenant id read and write global assignments), and a
* non-string value (e.g. `null` from a JSON body or a nullable column) would
* otherwise flow through un-normalised and leave the adapters disagreeing
* about what happened.
*/
export function scopeKey(scope?: AuthzScope): string {
const tenantId = scope?.tenantId;
if (tenantId === undefined) return "";
// Guard the TYPE as well as the value: a null from a JSON body or a nullable
// column would otherwise flow through un-normalised and the adapters would
// disagree about what happened - the db rejects on NOT NULL, memory accepts
// an unreachable row.
if (typeof tenantId !== "string" || tenantId === "") {
throw new Error(
"WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.",
);
}
return tenantId;
}
interface Row {
subjectId: string;
scope: string;
}
interface RoleRow extends Row {
role: string;
}
interface GrantRow extends Row {
permission: string;
effect: GrantEffect;
}
export function memoryPermissionStore(): PermissionStore {
const roles: RoleRow[] = [];
const grants: GrantRow[] = [];
// A request inside tenant t sees global assignments plus t's own.
const visible = (row: Row, key: string) => row.scope === "" || row.scope === key;
return {
async assignmentsFor(subjectId, scope) {
const key = scopeKey(scope);
const mine = (row: Row) => row.subjectId === subjectId && visible(row, key);
const matched = grants.filter(mine);
return {
roles: roles.filter(mine).map((row) => row.role),
grants: matched.filter((row) => row.effect === "allow").map((row) => row.permission),
denies: matched.filter((row) => row.effect === "deny").map((row) => row.permission),
};
},
async assignRole(subjectId, role, scope) {
const key = scopeKey(scope);
if (roles.some((r) => r.subjectId === subjectId && r.scope === key && r.role === role))
return;
roles.push({ subjectId, scope: key, role });
},
async revokeRole(subjectId, role, scope) {
const key = scopeKey(scope);
const at = roles.findIndex(
(r) => r.subjectId === subjectId && r.scope === key && r.role === role,
);
if (at !== -1) roles.splice(at, 1);
},
async grant(subjectId, permission, effect, scope) {
// The db adapter enforces this via a CHECK constraint; the memory
// adapter must agree, or a bad effect would silently vanish from both
// the grant and deny buckets on read instead of being refused up front.
if (effect !== "allow" && effect !== "deny") {
throw new TypeError(
`WRN-AUTHZ-EFFECT: effect must be "allow" or "deny", received ${JSON.stringify(effect)}`,
);
}
const key = scopeKey(scope);
const at = grants.findIndex(
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
);
if (at !== -1) grants.splice(at, 1);
grants.push({ subjectId, scope: key, permission, effect });
},
async revokeGrant(subjectId, permission, scope) {
const key = scopeKey(scope);
const at = grants.findIndex(
(g) => g.subjectId === subjectId && g.scope === key && g.permission === permission,
);
if (at !== -1) grants.splice(at, 1);
},
async listSubjects(scope) {
const key = scopeKey(scope);
const ids = new Set<string>();
for (const row of roles) if (row.scope === key) ids.add(row.subjectId);
for (const row of grants) if (row.scope === key) ids.add(row.subjectId);
return [...ids];
},
};
}
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; subjectId: string }>();
const bySubject = new Map<string, Set<string>>();
const cacheKey = (subjectId: string, scope?: AuthzScope) =>
JSON.stringify([scopeKey(scope), subjectId]);
const drop = (subjectId: string, scope?: AuthzScope) => {
// A global write changes what every tenant sees for that subject.
if (scopeKey(scope) === "") {
const keys = bySubject.get(subjectId);
if (keys) for (const key of keys) entries.delete(key);
bySubject.delete(subjectId);
return;
}
const key = cacheKey(subjectId, scope);
entries.delete(key);
bySubject.get(subjectId)?.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) {
const oldestKey = entries.keys().next().value!;
const oldest = entries.get(oldestKey);
entries.delete(oldestKey);
if (oldest) bySubject.get(oldest.subjectId)?.delete(oldestKey);
}
entries.set(key, { at: Date.now(), value, subjectId });
let keys = bySubject.get(subjectId);
if (!keys) {
keys = new Set();
bySubject.set(subjectId, keys);
}
keys.add(key);
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();
bySubject.clear();
},
size: () => entries.size,
};
}