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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user