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>
117 lines
5.0 KiB
TypeScript
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))];
|
|
},
|
|
};
|
|
}
|