Files
WRNexusJS/packages/authz/src/engine.ts
T
ClintchizandClaude Opus 5 ae37c9b57a fix(authz): close fail-open engine gaps found in review
Coordinator review of Task 6's resolution engine (plan amendment
86b3dc1e) found two critical and four important defects, all inherited
from the brief's original engine snippet:

- C1: anonymous callers on a public permission returned allow before
  running bound policies, so the least-trusted caller got the weakest
  evaluation. Policies now run for anonymous subjects too.
- C2: the policy verdict check was a truthiness test (`!verdict.allowed`),
  so a policy returning `{allowed: "yes"}` granted access. Now requires
  `verdict?.allowed === true` exactly, and no longer spreads the raw
  verdict into the decision (which leaked arbitrary policy fields).
- I1: a binding naming a policy the catalog doesn't have was silently
  `continue`d, granting whatever the policy was meant to guard. Now
  denies with "Policy unavailable".
- I3: denies were checked by exact string equality, so a wildcard deny
  (e.g. "post:*") was accepted and silently did nothing. Denies now go
  through the same depth-aware wildcard matching as grants, via the new
  exported `deniedBy()`.
- I2: `permissionsFor` now subtracts denied entries so it agrees with
  `decide()` — needed for Task 7's UI gating to compose correctly.
- I4: non-string/empty `subject.id` (0, "", 123, {}) no longer silently
  falls back to anonymous; it denies with "Invalid subject". `subject:
  null` (no subject at all) remains genuinely anonymous.

Added six regression tests, each verified by reverting its fix and
confirming the test fails against the old code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:58:28 +05:30

208 lines
7.4 KiB
TypeScript

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;
}
/**
* True if any entry in the deny list covers `permission`. Denies honour the
* same depth-aware wildcards as grants, so denying "post:*" blocks
* post:comment:delete rather than being accepted and silently doing nothing.
*/
export function deniedBy(denies: readonly string[], permission: string): boolean {
return denies.length ? permissionMatches(new Set(denies), permission) : 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>> => {
const { assignments, granted } = await loadEffective(subjectId, scope);
if (!assignments.denies.length) return granted;
const effective = new Set<string>();
for (const entry of granted) {
if (!deniedBy(assignments.denies, entry)) effective.add(entry);
}
return effective;
};
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
if (!result.allowed || options.auditAllows) {
safeRecord(audit, {
subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
scope: input.scope,
permission: input.permission,
allowed: result.allowed,
reason: result.reason,
policy: result.policy,
at: Date.now(),
});
}
return result;
};
/**
* Run every policy bound to `permission`. Returns the denial verdict of the
* first failing/missing/throwing policy, or `null` if all bound policies
* passed (including "no policies bound" — an implicit allow).
*/
const runPolicies = async (
input: DecideInput,
permission: string,
): Promise<AuthorizationDecision | null> => {
const { subject, resource } = input;
for (const name of catalog.bindings.get(permission) ?? []) {
const policy = catalog.policies.get(name);
if (!policy) {
console.error(`[wrnexus:authz] policy '${name}' is not registered; denying`);
return { allowed: false, reason: "Policy unavailable", policy: name };
}
try {
const verdict = await (
policy as unknown as (
s: unknown,
r: unknown,
) => AuthorizationDecision | Promise<AuthorizationDecision>
)(subject, resource);
if (verdict?.allowed !== true) {
return {
allowed: false,
reason: verdict?.reason ?? "Policy denied access",
policy: verdict?.policy ?? name,
};
}
} catch (error) {
console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error);
return { allowed: false, reason: "Policy error", policy: name };
}
}
return null;
};
return {
permissionsFor,
async decide(input) {
const { permission, 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 rawId: unknown = input.subject?.id;
const subjectId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
if (rawId !== undefined && rawId !== null && subjectId === undefined) {
console.error("[wrnexus:authz] subject.id must be a non-empty string; denying");
return finish(input, { allowed: false, reason: "Invalid subject" });
}
if (!subjectId) {
if (!meta.public) {
return finish(input, { allowed: false, reason: "Authentication required" });
}
const denied = await runPolicies(input, permission);
return finish(input, denied ?? { allowed: true, reason: "public permission" });
}
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 "*", honouring wildcards.
if (deniedBy(assignments.denies, 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.
const denied = await runPolicies(input, permission);
return finish(input, denied ?? { allowed: true });
},
};
}