docs: design for the authz permissions system

Separates declaration (what permissions, roles, policies and attributes exist)
from assignment (who holds what), building on the decision primitives already
in advanced.ts rather than replacing them.

Covers the registry and app/authz discovery, the PermissionStore interface
with memory and db adapters, tenant-scoped assignments meeting the existing
TenantMembership, deny-wins precedence, fail-closed behaviour, the audit sink,
codegen and CLI introspection, and the seam for propagating subject context to
the inter-app communication system.

Records two decisions worth keeping: cross-app sharing needs no runtime
catalog distribution (declarations are static code in the shared package;
only assignments are shared, via the database), and can() stays off Context
to avoid a core -> authz dependency cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:57:22 +05:30
co-authored by Claude Opus 5
parent c64434a131
commit b209936f86
@@ -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<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 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<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` — 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 13 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.