Move the "don't gate on permissionsFor() with permissionMatches" warning onto permissionMatches itself so it's visible via autocomplete, not just on AuthzResolver.permissionsFor. Round out exports.test.ts to cover scopeKey, safeRecord, and AUTHZ_LOCALS_KEY, closing the gap where dropping either export from index.ts would not fail the test.
228 lines
8.4 KiB
TypeScript
228 lines
8.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 {
|
|
/**
|
|
* 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 {
|
|
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));
|
|
} 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 });
|
|
},
|
|
};
|
|
}
|