289 lines
11 KiB
Markdown
289 lines
11 KiB
Markdown
# 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.
|