feat(authz): add resolution engine with deny-wins precedence and fail-closed errors
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
import type { AuthorizationDecision } from "./advanced.ts";
|
||||||
|
import { safeRecord, type AuthzAuditSink } from "./audit.ts";
|
||||||
|
import type { PermissionStore } from "./store.ts";
|
||||||
|
import type { AuthzCatalog, AuthzScope } from "./types.ts";
|
||||||
|
|
||||||
|
export interface AuthzResolverOptions {
|
||||||
|
catalog: AuthzCatalog;
|
||||||
|
store: PermissionStore;
|
||||||
|
audit?: AuthzAuditSink;
|
||||||
|
/**
|
||||||
|
* Throw on an unregistered permission instead of denying. Defaults to true
|
||||||
|
* outside production, so typos surface during development.
|
||||||
|
*/
|
||||||
|
strict?: boolean;
|
||||||
|
/** Record allows as well as denies. Off by default to bound write volume. */
|
||||||
|
auditAllows?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecideInput {
|
||||||
|
subject: { id?: string; [key: string]: unknown } | null | undefined;
|
||||||
|
permission: string;
|
||||||
|
resource?: unknown;
|
||||||
|
scope?: AuthzScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthzResolver {
|
||||||
|
permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
|
||||||
|
decide(input: DecideInput): Promise<AuthorizationDecision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expand roles into their granted entries, following `role:` and stopping on cycles. */
|
||||||
|
export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const walk = (role: string) => {
|
||||||
|
if (seen.has(role)) return;
|
||||||
|
seen.add(role);
|
||||||
|
for (const entry of catalog.roles.get(role) ?? []) {
|
||||||
|
if (entry.startsWith("role:")) walk(entry.slice(5));
|
||||||
|
else out.add(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const role of roles) walk(role);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact match, root wildcard, or a namespace wildcard at any depth. */
|
||||||
|
export function permissionMatches(granted: Set<string>, permission: string): boolean {
|
||||||
|
if (granted.has("*") || granted.has(permission)) return true;
|
||||||
|
for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
|
||||||
|
if (granted.has(`${permission.slice(0, at)}:*`)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProduction(): boolean {
|
||||||
|
return (process.env.NODE_ENV ?? "development") === "production";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver {
|
||||||
|
const { catalog, store, audit } = options;
|
||||||
|
const strict = options.strict ?? !isProduction();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for "what does this subject hold?". Returns the
|
||||||
|
* raw assignments too, because `decide` needs `denies` and `permissionsFor`
|
||||||
|
* does not — do NOT duplicate this logic in either caller.
|
||||||
|
*/
|
||||||
|
const loadEffective = async (subjectId: string, scope?: AuthzScope) => {
|
||||||
|
const assignments = await store.assignmentsFor(subjectId, scope);
|
||||||
|
const granted = expandRoles(catalog, assignments.roles);
|
||||||
|
for (const grant of assignments.grants) granted.add(grant);
|
||||||
|
return { assignments, granted };
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> =>
|
||||||
|
(await loadEffective(subjectId, scope)).granted;
|
||||||
|
|
||||||
|
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
|
||||||
|
if (!result.allowed || options.auditAllows) {
|
||||||
|
safeRecord(audit, {
|
||||||
|
subjectId: input.subject?.id,
|
||||||
|
scope: input.scope,
|
||||||
|
permission: input.permission,
|
||||||
|
allowed: result.allowed,
|
||||||
|
reason: result.reason,
|
||||||
|
policy: result.policy,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissionsFor,
|
||||||
|
|
||||||
|
async decide(input) {
|
||||||
|
const { subject, permission, resource, scope } = input;
|
||||||
|
const meta = catalog.permissions.get(permission);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
if (strict) {
|
||||||
|
throw new Error(
|
||||||
|
`WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` +
|
||||||
|
`Declare it with defineAuthz() in app/authz/.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return finish(input, {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Permission '${permission}' is not registered`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectId = subject?.id;
|
||||||
|
if (!subjectId) {
|
||||||
|
return finish(
|
||||||
|
input,
|
||||||
|
meta.public
|
||||||
|
? { allowed: true, reason: "public permission" }
|
||||||
|
: { allowed: false, reason: "Authentication required" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let assignments;
|
||||||
|
let granted: Set<string>;
|
||||||
|
try {
|
||||||
|
({ assignments, granted } = await loadEffective(subjectId, scope));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus:authz] permission store failed; denying", error);
|
||||||
|
return finish(input, { allowed: false, reason: "Authorization store unavailable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Explicit deny wins over everything, including "*".
|
||||||
|
if (assignments.denies.includes(permission)) {
|
||||||
|
return finish(input, { allowed: false, reason: "explicit deny" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Must hold the permission at all.
|
||||||
|
if (!meta.public && !permissionMatches(granted, permission)) {
|
||||||
|
return finish(input, { allowed: false, reason: "Missing permission" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Every bound policy must pass.
|
||||||
|
for (const name of catalog.bindings.get(permission) ?? []) {
|
||||||
|
const policy = catalog.policies.get(name);
|
||||||
|
if (!policy) continue;
|
||||||
|
try {
|
||||||
|
const verdict = await (
|
||||||
|
policy as unknown as (
|
||||||
|
s: unknown,
|
||||||
|
r: unknown,
|
||||||
|
) => AuthorizationDecision | Promise<AuthorizationDecision>
|
||||||
|
)(subject, resource);
|
||||||
|
if (!verdict.allowed) {
|
||||||
|
return finish(input, { ...verdict, policy: verdict.policy ?? name });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
|
||||||
|
return finish(input, { allowed: false, reason: "Policy error", policy: name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finish(input, { allowed: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { defineAuthz } from "../src/registry.ts";
|
||||||
|
import { mergeCatalogs } from "../src/catalog.ts";
|
||||||
|
import { memoryPermissionStore } from "../src/store.ts";
|
||||||
|
import { memoryAuditSink } from "../src/audit.ts";
|
||||||
|
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";
|
||||||
|
|
||||||
|
const catalog = mergeCatalogs([
|
||||||
|
{
|
||||||
|
source: "test.ts",
|
||||||
|
module: defineAuthz({
|
||||||
|
permissions: {
|
||||||
|
"post:read": { public: true },
|
||||||
|
"post:write": {},
|
||||||
|
"post:delete": { risk: "high" },
|
||||||
|
"post:comment:delete": {},
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
editor: ["post:*"],
|
||||||
|
moderator: ["post:comment:*"],
|
||||||
|
admin: ["role:editor", "post:delete"],
|
||||||
|
cyclic: ["role:cyclic", "post:read"],
|
||||||
|
},
|
||||||
|
policies: {
|
||||||
|
ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
|
||||||
|
resource?.authorId === subject?.id
|
||||||
|
? { allowed: true }
|
||||||
|
: { allowed: false, reason: "not the author", policy: "ownsPost" },
|
||||||
|
explodes: async () => {
|
||||||
|
throw new Error("policy blew up");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindings: { "post:write": ["ownsPost"] },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
|
||||||
|
store,
|
||||||
|
audit,
|
||||||
|
resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expandRoles", () => {
|
||||||
|
test("expands wildcards and role inheritance", () => {
|
||||||
|
expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
|
||||||
|
});
|
||||||
|
test("terminates on cyclic inheritance", () => {
|
||||||
|
expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissionMatches", () => {
|
||||||
|
test("matches exact, root wildcard, and every namespace depth", () => {
|
||||||
|
expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
|
||||||
|
expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
|
||||||
|
expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
|
||||||
|
expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
|
||||||
|
expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAuthzResolver.decide", () => {
|
||||||
|
test("allows a public permission for an anonymous subject", async () => {
|
||||||
|
const { resolver } = make();
|
||||||
|
const result = await resolver.decide({ subject: null, permission: "post:read" });
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denies a non-public permission for an anonymous subject", async () => {
|
||||||
|
const { resolver } = make();
|
||||||
|
const result = await resolver.decide({ subject: null, permission: "post:delete" });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows via a role-derived wildcard", async () => {
|
||||||
|
const { store, resolver } = make();
|
||||||
|
await store.assignRole("u1", "moderator");
|
||||||
|
const result = await resolver.decide({
|
||||||
|
subject: { id: "u1" },
|
||||||
|
permission: "post:comment:delete",
|
||||||
|
});
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit deny beats a role and beats '*'", async () => {
|
||||||
|
const { store, resolver } = make();
|
||||||
|
await store.assignRole("u1", "admin");
|
||||||
|
await store.grant("u1", "post:delete", "deny");
|
||||||
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/explicit deny/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bound policy can deny a permission the role grants", async () => {
|
||||||
|
const { store, resolver } = make();
|
||||||
|
await store.assignRole("u1", "editor");
|
||||||
|
const denied = await resolver.decide({
|
||||||
|
subject: { id: "u1" },
|
||||||
|
permission: "post:write",
|
||||||
|
resource: { authorId: "someone-else" },
|
||||||
|
});
|
||||||
|
expect(denied.allowed).toBe(false);
|
||||||
|
expect(denied.policy).toBe("ownsPost");
|
||||||
|
|
||||||
|
const allowed = await resolver.decide({
|
||||||
|
subject: { id: "u1" },
|
||||||
|
permission: "post:write",
|
||||||
|
resource: { authorId: "u1" },
|
||||||
|
});
|
||||||
|
expect(allowed.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a throwing policy denies rather than escaping", async () => {
|
||||||
|
const throwing = mergeCatalogs([
|
||||||
|
{
|
||||||
|
source: "t.ts",
|
||||||
|
module: defineAuthz({
|
||||||
|
permissions: { "x:go": {} },
|
||||||
|
policies: {
|
||||||
|
explodes: async () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindings: { "x:go": ["explodes"] },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const store = memoryPermissionStore();
|
||||||
|
await store.grant("u1", "x:go", "allow");
|
||||||
|
const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
|
||||||
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a store failure denies and does not throw", async () => {
|
||||||
|
const broken = {
|
||||||
|
...memoryPermissionStore(),
|
||||||
|
assignmentsFor: async () => {
|
||||||
|
throw new Error("db down");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
|
||||||
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unregistered permission denies when strict is off", async () => {
|
||||||
|
const { resolver } = make();
|
||||||
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/not registered/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unregistered permission throws when strict is on", async () => {
|
||||||
|
const resolver = createAuthzResolver({
|
||||||
|
catalog,
|
||||||
|
store: memoryPermissionStore(),
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
|
||||||
|
).rejects.toThrow(/ghost:perm/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("denials are audited and allows are not, by default", async () => {
|
||||||
|
const { store, audit, resolver } = make();
|
||||||
|
await store.assignRole("u1", "moderator");
|
||||||
|
await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
|
||||||
|
await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
|
||||||
|
expect(audit.events).toHaveLength(1);
|
||||||
|
expect(audit.events[0]!.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auditAllows records both verdicts", async () => {
|
||||||
|
const store = memoryPermissionStore();
|
||||||
|
const audit = memoryAuditSink();
|
||||||
|
const resolver = createAuthzResolver({
|
||||||
|
catalog,
|
||||||
|
store,
|
||||||
|
audit,
|
||||||
|
strict: false,
|
||||||
|
auditAllows: true,
|
||||||
|
});
|
||||||
|
await resolver.decide({ subject: null, permission: "post:read" });
|
||||||
|
expect(audit.events).toHaveLength(1);
|
||||||
|
expect(audit.events[0]!.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tenant scope selects the right assignments", async () => {
|
||||||
|
const { store, resolver } = make();
|
||||||
|
await store.assignRole("u1", "editor", { tenantId: "t1" });
|
||||||
|
const inside = await resolver.decide({
|
||||||
|
subject: { id: "u1" },
|
||||||
|
permission: "post:write",
|
||||||
|
resource: { authorId: "u1" },
|
||||||
|
scope: { tenantId: "t1" },
|
||||||
|
});
|
||||||
|
const outside = await resolver.decide({
|
||||||
|
subject: { id: "u1" },
|
||||||
|
permission: "post:write",
|
||||||
|
resource: { authorId: "u1" },
|
||||||
|
scope: { tenantId: "t2" },
|
||||||
|
});
|
||||||
|
expect(inside.allowed).toBe(true);
|
||||||
|
expect(outside.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user