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:
@@ -18,16 +18,23 @@ export interface PermissionStore {
|
||||
|
||||
/**
|
||||
* Global assignments are stored under the empty-string scope key. An OMITTED
|
||||
* scope means global; an explicitly EMPTY tenantId is refused, because it is
|
||||
* indistinguishable from global and would let a caller who controls the tenant
|
||||
* id read and write global assignments.
|
||||
* scope means global; an explicitly EMPTY or non-string tenantId is refused,
|
||||
* because an empty string is indistinguishable from global (and would let a
|
||||
* caller who controls the tenant id read and write global assignments), and a
|
||||
* non-string value (e.g. `null` from a JSON body or a nullable column) would
|
||||
* otherwise flow through un-normalised and leave the adapters disagreeing
|
||||
* about what happened.
|
||||
*/
|
||||
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