diff --git a/docs/plans/2026-08-04-authz-permissions-design.md b/docs/plans/2026-08-04-authz-permissions-design.md new file mode 100644 index 00000000..14a72a16 --- /dev/null +++ b/docs/plans/2026-08-04-authz-permissions-design.md @@ -0,0 +1,286 @@ +# 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.