11 KiB
Permissions system design (@wrnexus/authz)
Date: 2026-08-04
Status: approved, not yet implemented
Supersedes: nothing — extends the existing @wrnexus/authz package
Problem
@wrnexus/authz today evaluates authorization but does not describe it. defineRbac()
takes a literal object of roles, policies are anonymous closures, and nothing records which
permissions exist. The consequences:
- No discoverability. Nothing can answer "what permissions does this system have?"
- No runtime assignment. Changing who is an admin requires a redeploy.
- No tenant awareness, despite
core/src/tenant.tsalready definingTenantMembership { tenantId, userId, roles[] }thatauthznever reads. - No audit trail.
- Typos in permission strings fail silently as
false.
The evaluation primitives are sound and stay: AuthorizationDecision, DecisionPolicy,
owner, anyDecision, allDecisions, filterAuthorized, and the guard middleware.
Approach
Separate declaration (what permissions, roles, policies and attributes exist — static, typed, in code) from assignment (who holds what — dynamic, in a store). The existing package becomes the evaluation layer beneath both.
Rejected alternatives:
- Extend
defineRbacin place. Half the work, but leaves discoverability, codegen, cross-app catalog and audit with nowhere to live. - Adapter for OpenFGA / Cedar / SpiceDB. Better at relationship-heavy authorization, but puts a network dependency and a sidecar in the request path of a zero-dependency framework.
Module layout
@wrnexus/authz
index.ts existing surface (unchanged exports)
advanced.ts existing decision primitives (unchanged exports)
registry.ts defineAuthz() — permissions, roles, policies, attributes
catalog.ts discovery, merge, conflict detection; frozen at boot
store.ts PermissionStore interface, memory adapter, cachedPermissionStore()
db.ts dbPermissionStore(getDb()) — subpath export @wrnexus/authz/db
engine.ts subject -> effective permissions -> Decision
guards.ts route middleware, extended for resources
audit.ts AuthzAuditSink
view.ts can() exposed to .wrn `{#if}` expressions
Declaration
Declarations live in app/authz/*.ts, discovered the same way app/schemas/*.ts already is
(packages/router/src/index.ts scans and populates router.schemas; this adds router.authz).
// app/authz/blog.ts
import { defineAuthz, owner } from "@wrnexus/authz";
export default defineAuthz({
permissions: {
"post:read": { title: "View posts", public: true },
"post:write": { title: "Create and edit posts" },
"post:delete": { title: "Delete posts", risk: "high" },
},
roles: {
editor: ["post:*"],
moderator: ["post:comment:*"],
admin: ["role:editor", "role:moderator"],
},
policies: {
ownsPost: owner("id", "authorId"),
},
attributes: {
department: { description: "Subject's department, from the identity provider" },
},
});
A permission declared public: true is granted to anonymous subjects. Every other permission
denies when there is no authenticated user.
Catalog and cross-app scope
Declarations are static code, so sharing them across workspace apps needs no runtime
distribution: the defineAuthz blocks live in the workspace's shared package
(packages/shared, already scaffolded by wrnexus workspace) and every app imports them.
They are identical by construction.
What is genuinely shared at runtime is assignments, and those live in the shared database
behind PermissionStore.
wrnexus authz list is therefore introspection, not distribution: it walks every app in the
workspace, merges catalogs, and reports the full permission/role/policy surface plus conflicts.
Merge rules:
- Two declarations of the same permission id with deep-equal metadata: no-op (lets shared packages re-declare freely).
- Two declarations of the same permission id whose metadata is not deep-equal: boot error naming both source files.
- The catalog is frozen after boot. Registration is not possible at request time.
Assignment store
interface AuthzScope {
tenantId?: string;
}
interface SubjectAssignments {
roles: string[];
grants: string[]; // explicit allows, bypassing roles
denies: string[]; // explicit denies, win over everything
}
interface PermissionStore {
assignmentsFor(subjectId: string, scope?: AuthzScope): Promise<SubjectAssignments>;
assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise<void>;
grant(
subjectId: string,
permission: string,
effect: "allow" | "deny",
scope?: AuthzScope,
): Promise<void>;
revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise<void>;
listSubjects(scope?: AuthzScope): Promise<string[]>;
}
scope.tenantId is how this meets the existing TenantMembership. An assignment with no
scope is global; a scoped assignment applies only within that tenant. Both are unioned for a
request whose ctx.tenant is set.
Adapters:
memoryPermissionStore()— tests and single-process development.dbPermissionStore(getDb())— default; tables below.cachedPermissionStore(inner, { ttlMs, max })— decorator exposinginvalidate(subjectId, scope). Role changes must invalidate explicitly rather than wait out a TTL.
Tables
wrn_authz_assignment
id, subject_id, scope, role, granted_by, created_at
unique(subject_id, scope, role)
wrn_authz_grant
id, subject_id, scope, permission, effect, granted_by, created_at
unique(subject_id, scope, permission)
scope stores the tenant id, or the empty string for global. Migrations are scaffolded by
wrnexus authz init, following the existing db/src/migrate.ts conventions.
Evaluation
How can() reaches a request
can is not added to the Context interface. @wrnexus/core must not depend on
@wrnexus/authz — the same constraint that keeps getDb() off Context rather than
introducing a core -> db cycle. Instead:
app.use(authzMiddleware({ store, catalog })); // stashes a resolver in ctx.locals
const allowed = await can(ctx, "post:delete", post); // imported from @wrnexus/authz
authzMiddleware puts the per-request resolver (with its memo table) into
ctx.locals._authz; can(ctx, ...) reads it and throws a clear setup error if the
middleware was not registered. Views get the bound form described under view integration.
Per request
- Subject is
ctx.user; scope isctx.tenant. store.assignmentsFor(subjectId, scope).- Registry expands roles into a permission set — wildcards at every depth
(
post:*andpost:comment:*both grantpost:comment:delete),role:inheritance, cycle-safe. can(ctx, permission, resource?)checks the set, then runs any policy bound to that permission with the resource.- Result is an
AuthorizationDecision; denials go to the audit sink.
Memoised per request. Precedence, highest first:
- Explicit deny (store
denies) — beats everything including*. - Policy denial.
- Explicit grant or role-derived permission.
- Default deny.
Failure behaviour
Every failure path denies.
| Condition | Behaviour |
|---|---|
| Permission not in the registry | Throws in development, denies and audits in production |
| Store throws | Deny, audit, log. Never fail open. |
| Policy throws | Treated as a denial, logged with the policy name |
| No authenticated user | Deny, unless the permission is declared public |
Audit
interface AuthzAuditEvent {
subjectId?: string;
scope?: AuthzScope;
permission: string;
allowed: boolean;
reason?: string;
policy?: string;
at: number;
}
interface AuthzAuditSink {
record(event: AuthzAuditEvent): void | Promise<void>;
}
Default sink is a no-op. Records denials only unless configured otherwise, to bound write volume on hot paths. Sink errors are logged and swallowed — auditing must never break a request.
Tooling
wrnexus authz list— merged catalog across the workspace, with conflicts.wrnexus authz generate— emitsapp/authz/permissions.gen.tsexportingtype Permission = "post:read" | "post:write" | ....can(),guardPermission(), anddecideFor()all take a barestringand nothing consumes this union automatically — it exists to type your own helpers/constants against the registered catalog. Runs automatically inbuild.ts, mirroringregenerateQueries.wrnexus authz init— scaffolds the migration and a seed helper for default roles.- Admin UI:
.wrncomponents for listing subjects and assigning roles, shipped in@wrnexus/uibehind the existing eject mechanism.
Inter-app seam
Reserved for the inter-app communication system, specified but not built here:
exportSubjectContext(ctx): string // signed, compact: subject id, scope, roles
importSubjectContext(token): Subject // verified on the receiving app
An app calling another on a user's behalf propagates identity and roles rather than re-querying the store. The signing key and transport are the comms system's concern.
Security fix folded into this work
authorizeDecision currently returns the internal reason and policy name in the 403 body,
disclosing policy structure to unauthenticated callers. This becomes opt-in via
authorizeDecision(evaluate, { exposeReason: true }), defaulting to a bare
{ ok: false, error: "Forbidden" }.
Testing
- Store conformance suite — one shared set of tests run against both the memory and DB adapters so they cannot drift.
- Unit — wildcard expansion at depth, deny precedence, role-cycle termination, catalog merge conflicts, public-permission handling.
- Integration — guards return 403 for API and redirect for pages;
{#if can(...)}omits markup server-side rather than hiding it with CSS. - Security regression — unregistered permission denies in production; 403 body does not leak policy names unless opted in; store failure denies rather than allows.
Build order
registry.ts,catalog.ts,store.ts(memory),engine.ts, extendedguards.ts.- Resource-level policies connected to
filterAuthorized;audit.ts. db.tsadapter, migrations,wrnexus authz init, codegen,wrnexus authz list..wrnview integration (can()inside{#if}).- Admin UI components.
Phase 4 is the only one whose shape is uncertain. The compiler supports {#if} at page level
and nested, but exposing can() into that scope touches codegen
(packages/compiler/src/codegen.ts). If it proves invasive, phases 1–3 ship on their own and
view integration returns as its own design.
Out of scope
- Relationship-based authorization ("can edit because they're in the team that owns the doc"). Role and policy checks cover the intended cases; revisit if hierarchical resources appear.
- Permission delegation and time-bounded grants.
- Cross-workspace federation.