diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 342c4e99..f29e0a5d 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -516,6 +516,11 @@ "requireRole", "safeRecord", "scopeKey" + ], + "./db": [ + "authzMigrationSql", + "dbPermissionStore", + "ensureAuthzTables" ] }, "@wrnexus/benchmark": { diff --git a/packages/authz/package.json b/packages/authz/package.json index a1dcf4ec..4fd24545 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./db": "./src/db.ts" } } diff --git a/packages/authz/src/db.ts b/packages/authz/src/db.ts new file mode 100644 index 00000000..8e2aa24c --- /dev/null +++ b/packages/authz/src/db.ts @@ -0,0 +1,91 @@ +import type { Db, Dialect } from "@wrnexus/db"; +import { authzMigrationSql } from "./migrations.ts"; +import { scopeKey, type GrantEffect, 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. */ +export async function ensureAuthzTables(db: Db, dialect: Dialect = "sqlite"): Promise { + for (const statement of authzMigrationSql(dialect).up.split(";\n\n")) { + const sql = statement.trim(); + if (sql) await db.exec(sql.endsWith(";") ? sql : `${sql};`); + } +} + +export function dbPermissionStore(db: Db): PermissionStore { + 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 = ? AND (scope = '' OR scope = ?)", + [subjectId, key], + ); + const grantRows = await db.all<{ permission: string; effect: GrantEffect }>( + "SELECT permission, effect FROM _wrn_authz_grant WHERE subject_id = ? AND (scope = '' OR scope = ?)", + [subjectId, key], + ); + return { + roles: roleRows.map((row) => row.role), + grants: grantRows.filter((r) => r.effect === "allow").map((r) => r.permission), + denies: grantRows.filter((r) => r.effect === "deny").map((r) => r.permission), + }; + }, + + async assignRole(subjectId, role, scope) { + const key = scopeKey(scope); + const existing = await db.all<{ id: number }>( + "SELECT id FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + [subjectId, key, role], + ); + if (existing.length) return; + await db.exec( + "INSERT INTO _wrn_authz_assignment (subject_id, scope, role) VALUES (?, ?, ?)", + [subjectId, key, role], + ); + }, + + async revokeRole(subjectId, role, scope) { + await db.exec( + "DELETE FROM _wrn_authz_assignment WHERE subject_id = ? AND scope = ? AND role = ?", + [subjectId, scopeKey(scope), role], + ); + }, + + async grant(subjectId, permission, effect, scope) { + const key = scopeKey(scope); + // Re-granting replaces the effect. Do the delete+insert inside a + // transaction so a failed insert can't leave the row missing. + await db.tx(async (tx) => { + await tx.exec( + "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + [subjectId, key, permission], + ); + await tx.exec( + "INSERT INTO _wrn_authz_grant (subject_id, scope, permission, effect) VALUES (?, ?, ?, ?)", + [subjectId, key, permission, effect], + ); + }); + }, + + async revokeGrant(subjectId, permission, scope) { + await db.exec( + "DELETE FROM _wrn_authz_grant WHERE subject_id = ? AND scope = ? AND permission = ?", + [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 = ? " + + "UNION SELECT subject_id FROM _wrn_authz_grant WHERE scope = ?", + [key, key], + ); + return [...new Set(rows.map((row) => row.subject_id))]; + }, + }; +} diff --git a/packages/authz/src/migrations.ts b/packages/authz/src/migrations.ts new file mode 100644 index 00000000..19117af0 --- /dev/null +++ b/packages/authz/src/migrations.ts @@ -0,0 +1,43 @@ +import type { Dialect } from "@wrnexus/db"; + +/** + * DDL for the two assignment tables. `scope` holds a tenant id, or the empty + * string for a global assignment, so the unique constraints work on every + * dialect (NULL is not comparable in a UNIQUE index). + */ +export function authzMigrationSql(dialect: Dialect): { up: string; down: string } { + const id = + dialect === "postgres" + ? "SERIAL PRIMARY KEY" + : dialect === "mysql" + ? "INT AUTO_INCREMENT PRIMARY KEY" + : "INTEGER PRIMARY KEY AUTOINCREMENT"; + const timestamp = dialect === "sqlite" ? "TEXT" : "TIMESTAMP"; + const now = "CURRENT_TIMESTAMP"; + + const up = `CREATE TABLE IF NOT EXISTS _wrn_authz_assignment ( + id ${id}, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + role VARCHAR(255) NOT NULL, + granted_by VARCHAR(255), + created_at ${timestamp} NOT NULL DEFAULT ${now}, + CONSTRAINT _wrn_authz_assignment_unique UNIQUE (subject_id, scope, role) +); + +CREATE TABLE IF NOT EXISTS _wrn_authz_grant ( + id ${id}, + subject_id VARCHAR(255) NOT NULL, + scope VARCHAR(255) NOT NULL DEFAULT '', + permission VARCHAR(255) NOT NULL, + effect VARCHAR(16) NOT NULL, + granted_by VARCHAR(255), + created_at ${timestamp} NOT NULL DEFAULT ${now}, + CONSTRAINT _wrn_authz_grant_unique UNIQUE (subject_id, scope, permission) +);`; + + const down = `DROP TABLE IF EXISTS _wrn_authz_grant; +DROP TABLE IF EXISTS _wrn_authz_assignment;`; + + return { up, down }; +} diff --git a/packages/authz/test/store-db.test.ts b/packages/authz/test/store-db.test.ts new file mode 100644 index 00000000..ebdb50d1 --- /dev/null +++ b/packages/authz/test/store-db.test.ts @@ -0,0 +1,11 @@ +import { createDb } from "@wrnexus/db"; +import { sqlite } from "@wrnexus/db/sqlite"; +import { dbPermissionStore, ensureAuthzTables } from "../src/db.ts"; +import { runStoreConformance } from "./store-conformance.ts"; + +// The db adapter must satisfy exactly the same contract as the memory one. +runStoreConformance("sqlite", async () => { + const db = createDb(sqlite(":memory:")); + await ensureAuthzTables(db, "sqlite"); + return dbPermissionStore(db); +}); diff --git a/tsconfig.json b/tsconfig.json index b6e26c2a..5f00913d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "@wrnexus/jwt": ["./packages/jwt/src/index.ts"], "@wrnexus/oauth": ["./packages/oauth/src/index.ts"], "@wrnexus/authz": ["./packages/authz/src/index.ts"], + "@wrnexus/authz/db": ["./packages/authz/src/db.ts"], "@wrnexus/helpers": ["./packages/helpers/src/index.ts"], "@wrnexus/encryption": ["./packages/encryption/src/index.ts"], "@wrnexus/pubsub": ["./packages/pubsub/src/index.ts"],