# 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" | ...`. `can()`, `guardPermission()`,
  and `decideFor()` all take a bare `string` and nothing consumes this union
  automatically — it exists to type your own helpers/constants against the
  registered catalog. 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 connected 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.
