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:
@@ -516,6 +516,11 @@
|
||||
"requireRole",
|
||||
"safeRecord",
|
||||
"scopeKey"
|
||||
],
|
||||
"./db": [
|
||||
"authzMigrationSql",
|
||||
"dbPermissionStore",
|
||||
"ensureAuthzTables"
|
||||
]
|
||||
},
|
||||
"@wrnexus/benchmark": {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./db": "./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<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))];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user