# 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.ts` already defining `TenantMembership { tenantId, userId, roles[] }` that `authz` never 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 `defineRbac` in 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`). ```ts // 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 ```ts 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; assignRole(subjectId: string, role: string, scope?: AuthzScope): Promise; revokeRole(subjectId: string, role: string, scope?: AuthzScope): Promise; grant( subjectId: string, permission: string, effect: "allow" | "deny", scope?: AuthzScope, ): Promise; revokeGrant(subjectId: string, permission: string, scope?: AuthzScope): Promise; listSubjects(scope?: AuthzScope): Promise; } ``` `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 exposing `invalidate(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: ```ts 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 1. Subject is `ctx.user`; scope is `ctx.tenant`. 2. `store.assignmentsFor(subjectId, scope)`. 3. Registry expands roles into a permission set — wildcards at every depth (`post:*` and `post:comment:*` both grant `post:comment:delete`), `role:` inheritance, cycle-safe. 4. `can(ctx, permission, resource?)` checks the set, then runs any policy bound to that permission with the resource. 5. Result is an `AuthorizationDecision`; denials go to the audit sink. Memoised per request. Precedence, highest first: 1. Explicit deny (store `denies`) — beats everything including `*`. 2. Policy denial. 3. Explicit grant or role-derived permission. 4. 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 ```ts interface AuthzAuditEvent { subjectId?: string; scope?: AuthzScope; permission: string; allowed: boolean; reason?: string; policy?: string; at: number; } interface AuthzAuditSink { record(event: AuthzAuditEvent): void | Promise; } ``` 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` — emits `app/authz/permissions.gen.ts` exporting `type Permission = "post:read" | "post:write" | ...`, so `can()` is checked at compile time. Runs automatically in `build.ts`, mirroring `regenerateQueries`. - `wrnexus authz init` — scaffolds the migration and a seed helper for default roles. - Admin UI: `.wrn` components for listing subjects and assigning roles, shipped in `@wrnexus/ui` behind the existing eject mechanism. ## Inter-app seam Reserved for the inter-app communication system, specified but not built here: ```ts 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 1. `registry.ts`, `catalog.ts`, `store.ts` (memory), `engine.ts`, extended `guards.ts`. 2. Resource-level policies wired to `filterAuthorized`; `audit.ts`. 3. `db.ts` adapter, migrations, `wrnexus authz init`, codegen, `wrnexus authz list`. 4. `.wrn` view integration (`can()` inside `{#if}`). 5. 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.