From 2fbf059c00d59a498fd833181421e7a94e072069 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 21:01:26 +0530 Subject: [PATCH] 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 --- ...26-08-04-authz-permissions-implementation.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index ea1c93ae..95677ba5 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -605,6 +605,15 @@ export function runStoreConformance(name: string, makeStore: () => Promise { + // 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;