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>
This commit is contained in:
2026-08-04 21:06:11 +05:30
co-authored by Claude Opus 5
parent 2fbf059c00
commit 91e5c6e0c5
4 changed files with 120 additions and 6 deletions
+9
View File
@@ -136,6 +136,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.