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/);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user