Files
WRNexusJS/packages/authz/src/db.ts
T
ClintchizandClaude Opus 5 91e5c6e0c5 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>
2026-08-04 21:06:11 +05:30

117 lines
5.0 KiB
TypeScript

import type { Db, Dialect } from "@wrnexus/db";
import { authzMigrationSql } from "./migrations.ts";
import { scopeKey, type PermissionStore } from "./store.ts";
import type { AuthzScope, SubjectAssignments } from "./types.ts";
// Re-exported so `@wrnexus/authz/db` is the single entry point for everything
// database-related, including the DDL the CLI scaffolds.
export { authzMigrationSql } from "./migrations.ts";
/**
* Create the tables if absent. Production apps should use a real migration.
*
* The UNIQUE constraints in this DDL are load-bearing beyond deduplication:
* `grant`/`assignRole` below use ON CONFLICT / ON DUPLICATE KEY, which infers
* its conflict target from them. A hand-rolled migration that recreates these
* tables without `_wrn_authz_grant_unique` (or the assignment equivalent)
* will make those methods reject outright, where the old delete-then-insert
* approach would have silently worked without the constraint.
*/
export async function ensureAuthzTables(
db: Db,
dialect: Dialect = db.driver.dialect,
): Promise<void> {
for (const statement of authzMigrationSql(dialect).up) await db.exec(statement);
}
/** Positional placeholder for the dialect: postgres numbers them, others use "?". */
function ph(dialect: Dialect, index: number): string {
return dialect === "postgres" ? `$${index}` : "?";
}
export function dbPermissionStore(db: Db): PermissionStore {
const dialect = db.driver.dialect;
const p = (n: number) => ph(dialect, n);
// Single-statement upserts. A transaction here would be worse than useless:
// the drivers run BEGIN on one shared connection, so an open transaction
// swallows any concurrent write from another method and discards it on
// rollback - a revoke would resolve successfully while the role survived.
const onConflict = (columns: string, update: string) =>
dialect === "mysql"
? ` ON DUPLICATE KEY UPDATE ${update}`
: ` ON CONFLICT (${columns}) DO UPDATE SET ${update}`;
const onConflictIgnore = (columns: string) =>
dialect === "mysql"
? " ON DUPLICATE KEY UPDATE id = id"
: ` ON CONFLICT (${columns}) DO NOTHING`;
return {
async assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments> {
const key = scopeKey(scope);
// A request inside a tenant sees global rows plus that tenant's rows.
const roleRows = await db.all<{ role: string }>(
`SELECT role FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
[subjectId, key],
);
const grantRows = await db.all<{ permission: string; effect: string }>(
`SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND (scope = '' OR scope = ${p(2)})`,
[subjectId, key],
);
return {
roles: roleRows.map((row) => row.role),
grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission),
// Anything that is not literally "allow" counts as a deny, so a
// corrupted or mis-cased effect fails closed rather than vanishing.
denies: grantRows.filter((r) => r.effect !== "allow").map((r) => r.permission),
};
},
async assignRole(subjectId, role, scope) {
await db.exec(
`INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (${p(1)}, ${p(2)}, ${p(3)})` +
onConflictIgnore("subject_id, scope, role"),
[subjectId, scopeKey(scope), role],
);
},
async revokeRole(subjectId, role, scope) {
await db.exec(
`DELETE FROM _wrn_authz_assignment WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND role = ${p(3)}`,
[subjectId, scopeKey(scope), role],
);
},
async grant(subjectId, permission, effect, scope) {
// `VALUES(effect)` is deprecated as of MySQL 8.0.20 in favour of the
// row-alias form (`... VALUES (...) AS new ON DUPLICATE KEY UPDATE
// effect = new.effect`). Noted here rather than migrated because there
// is no MySQL server in CI to catch its eventual removal.
await db.exec(
`INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)})` +
onConflict(
"subject_id, scope, permission",
"effect = " + (dialect === "mysql" ? "VALUES(effect)" : "excluded.effect"),
),
[subjectId, scopeKey(scope), permission, effect],
);
},
async revokeGrant(subjectId, permission, scope) {
await db.exec(
`DELETE FROM _wrn_authz_grant WHERE subject_id = ${p(1)} AND scope = ${p(2)} AND permission = ${p(3)}`,
[subjectId, scopeKey(scope), permission],
);
},
async listSubjects(scope) {
const key = scopeKey(scope);
const rows = await db.all<{ subject_id: string }>(
`SELECT subject_id FROM _wrn_authz_assignment WHERE scope = ${p(1)} ` +
`UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ${p(2)}`,
[key, key],
);
return [...new Set(rows.map((row) => row.subject_id))];
},
};
}