feat(authz): add resolution engine with deny-wins precedence and fail-closed errors

This commit is contained in:
2026-08-04 17:40:19 +05:30
parent d7509421c7
commit 6d8b6daba9
2 changed files with 374 additions and 0 deletions
+166
View File
@@ -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 });
},
};
}