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:
@@ -605,6 +605,15 @@ export function runStoreConformance(name: string, makeStore: () => Promise<Permi
|
|||||||
await expect(store.assignRole("g1", "admin", { tenantId: "" })).rejects.toThrow(/tenantId/);
|
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 () => {
|
test("concurrent identical assignRole calls all resolve", async () => {
|
||||||
// Check-then-act loses this race; the UNIQUE constraint then rejects
|
// Check-then-act loses this race; the UNIQUE constraint then rejects
|
||||||
// every loser even though the desired end state was already reached.
|
// every loser even though the desired end state was already reached.
|
||||||
@@ -699,9 +708,13 @@ export interface PermissionStore {
|
|||||||
export function scopeKey(scope?: AuthzScope): string {
|
export function scopeKey(scope?: AuthzScope): string {
|
||||||
const tenantId = scope?.tenantId;
|
const tenantId = scope?.tenantId;
|
||||||
if (tenantId === undefined) return "";
|
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(
|
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;
|
return tenantId;
|
||||||
|
|||||||
Reference in New Issue
Block a user