Files
WRNexusJS/packages/authz/src/engine.ts
T
ClintchizandClaude Opus 5 3867e7c183 fix(authz): audit getResource denials; fail closed on a malformed denies shape
guardPermission's getResource catch returned 403 directly, never reaching
decideFor -> decide -> finish, so the audit sink never saw it — an attacker
probing ids that make the resource loader throw got a clean 403 stream
invisible to the audit trail. The audit sink is now stashed on the
per-request RequestAuthz object (authzMiddleware already receives it via
AuthzResolverOptions), and the catch records an "allowed: false" event with
an opaque reason before returning the 403.

Also: the explicit-deny check sat outside decide()'s try/catch, and
deniedBy() guarded on denies.length rather than Array.isArray(denies). A
store returning denies as a bare string let new Set(denies) iterate
characters instead of the permission, so the deny matched nothing and was
silently discarded; a store omitting denies entirely threw straight out of
decide(). Both are now validated and handled inside the try, denying via the
same "Authorization store unavailable" path as any other store failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:11 +05:30

250 lines
9.7 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 {
/**
* Effective permissions with denied entries removed — for coarse gating such
* as hiding a menu section.
*
* NOT authoritative. A set of strings cannot express "everything under
* `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
* not representable here: the set still contains `post:*` while `decide()`
* correctly refuses `post:delete`. Gate individual actions with `decide()`
* (or `can()` / `filterCan()`), never by matching against this set.
*/
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.
*
* Do NOT gate access by matching against `permissionsFor()`'s result — that set
* cannot represent a narrow deny beneath a broad grant, so the composition
* returns true where `decide()` refuses. Use `decide()` / `can()` instead.
*/
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 {
// A non-conforming store (e.g. denies: "post:write" instead of an array)
// must not silently discard an explicit deny: new Set("post:write") would
// iterate the string's characters instead of throwing, so the deny would
// match nothing and fail open. Array.isArray guards the SHAPE, not just
// the length, so a truthy-but-non-array denies value denies by falling
// through to the caller's catch instead of matching nothing here.
if (!Array.isArray(denies)) return false;
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;
// Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
// allocations on a per-request path whose input size an operator controls.
const denySet = new Set(assignments.denies);
const effective = new Set<string>();
for (const entry of granted) {
if (!permissionMatches(denySet, entry)) effective.add(entry);
}
return effective;
};
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
if (!result.allowed || options.auditAllows) {
const rawId = input.subject?.id;
safeRecord(audit, {
subjectId: typeof rawId === "string" && rawId !== "" ? rawId : 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));
// A store returning a non-array `denies` (e.g. a single string, or
// omitting the field entirely) violates the PermissionStore contract.
// Treat that exactly like assignmentsFor() itself throwing — fail
// closed — rather than letting a malformed shape flow into
// deniedBy(): a string denies would otherwise iterate as
// CHARACTERS (new Set("post:write") is a set of letters, not the
// permission), so an explicit deny would silently match nothing and
// be discarded, and an omitted `denies` would throw past this
// function entirely if it weren't caught here.
if (!Array.isArray(assignments.denies)) {
throw new TypeError(
"WRN-AUTHZ-STORE: assignmentsFor() must return an array for `denies`",
);
}
// 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" });
}
} catch (error) {
console.error("[wrnexus:authz] permission store failed; denying", error);
return finish(input, { allowed: false, reason: "Authorization store unavailable" });
}
// 3. Every bound policy must pass.
const denied = await runPolicies(input, permission);
return finish(input, denied ?? { allowed: true });
},
};
}