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;