Files
WRNexusJS/docs/plans/2026-08-04-authz-permissions-design.md
T
ClintchizandClaude Opus 5 b209936f86 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>
2026-08-04 15:57:22 +05:30

11 KiB
Raw Blame History

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).

// 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 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:

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

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:

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.