diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index 439acde7..8294afaf 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -14,7 +14,7 @@ - Zero runtime npm dependencies. Use only Bun/WebCrypto/node: builtins. - `@wrnexus/core` MUST NOT import `@wrnexus/authz`. `can()` stays off `Context`; the resolver lives in `ctx.locals._authz`. - `@wrnexus/authz` may import **types only** from `@wrnexus/core` (`import type { Context, Middleware }`). -- Existing exports of `@wrnexus/authz` must keep working unchanged. This is additive. +- Existing exports of `@wrnexus/authz` keep working unchanged, with ONE approved exception: Task 8 changes the default 403 body of `authorizeDecision` to stop disclosing policy internals. That break is intentional and ruled on; everything else is additive. - Framework-owned tables use the `_wrn_` prefix (matching `_wrn_tenant`, `_wrn_cursor`). The spec wrote `wrn_authz_assignment`; use `_wrn_authz_assignment` and `_wrn_authz_grant`. - `requirePermission` is already exported with signature `(rbac: Rbac, permission: string)`. Do not change it. The new resource-aware guard is named `guardPermission`. - Every failure path denies. Never fail open. @@ -1344,13 +1344,21 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve const { catalog, store, audit } = options; const strict = options.strict ?? !isProduction(); - const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { + /** + * 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 granted; + return { assignments, granted }; }; + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => + (await loadEffective(subjectId, scope)).granted; + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { if (!result.allowed || options.auditAllows) { safeRecord(audit, { @@ -1399,9 +1407,7 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve let assignments; let granted: Set; try { - assignments = await store.assignmentsFor(subjectId, scope); - granted = expandRoles(catalog, assignments.roles); - for (const grant of assignments.grants) granted.add(grant); + ({ 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" });