docs: type-guard tenantId and add a no-transaction regression guard

Two gaps the Task 11 re-review left open.

scopeKey guarded the empty-string tenantId but not its type, so null, 0,
false or an object flowed through un-normalised and the adapters diverged -
the db rejects on NOT NULL while memory accepts an unreachable row. The whole
premise of the empty-string guard was a caller who controls the tenant id,
and that caller can just as easily hand over a null from a JSON body.

The vacuous concurrency test was removed for good reason, but that left
nothing failing if someone re-wraps grant() in db.tx and reintroduces the
shared-connection rollback. A spy over driver.transaction discriminates that
deterministically, with no timing dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 21:01:26 +05:30
co-authored by Claude Opus 5
parent 1cc0b97a72
commit 2fbf059c00
@@ -605,6 +605,15 @@ export function runStoreConformance(name: string, makeStore: () => Promise<Permi
await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
});
test("a non-string tenantId is refused", async () => {
// Same class as the empty-string case: the caller controls this value.
for (const bad of [null, 0, false, {}]) {
await expect(store.assignmentsFor("u1", { tenantId: bad as never })).rejects.toThrow(
/tenantId/,
);
}
});
test("concurrent identical assignRole calls all resolve", async () => {
// Check-then-act loses this race; the UNIQUE constraint then rejects
// every loser even though the desired end state was already reached.
@@ -699,9 +708,13 @@ export interface PermissionStore {
export function scopeKey(scope?: AuthzScope): string {
const tenantId = scope?.tenantId;
if (tenantId === undefined) return "";
if (tenantId === "") {
// 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 not be empty; omit the scope for a global assignment.",
"WRN-AUTHZ-SCOPE: tenantId must be a non-empty string; omit the scope for a global assignment.",
);
}
return tenantId;