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 { 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 { 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))]; }, }; }