feat(authz): add database-backed PermissionStore

Adds dbPermissionStore/ensureAuthzTables/authzMigrationSql, backed by
_wrn_authz_assignment and _wrn_authz_grant tables, plus a ./db subpath
export. Passes the identical 19-test store-conformance suite the memory
adapter passes, including tenant-scope isolation.
This commit is contained in:
2026-08-04 20:22:31 +05:30
parent 218f5e2dd6
commit 3fa3fce5df
6 changed files with 153 additions and 1 deletions
+91
View File
@@ -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<void> {
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<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 = ? 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))];
},
};
}