Authorization
RBAC, auto-seeded claims & the godmin
Where authentication answers "who are you?", authorization answers "what may you do?". Nucleus implements role-based access control built on fine-grained claims — and, crucially, it can generate those claims for you directly from your entity definitions.
A claim is a verb-on-resource permission like get.product or post.order. Roles bundle claims; users hold roles. On startup Nucleus walks every entity and seeds a claim for each method (and optionally each field and relation), so a brand-new table is access-controlled the moment it exists.
Claims can travel inside the JWT (simple) or be resolved from a shared Redis cache (keeps tokens small at scale). A bootstrap super-admin — the godmin — is created automatically so you're never locked out.
Enabling RBAC#
Two switches turn the system on and decide whether claims are generated automatically. With auto-seeding, you rarely write a claim by hand.
enabledbooleanOptionalMaster switch. When true, the authorization middleware guards entity and auth routes, checking the caller's claims against the claim required by the matched route.
falseautoSeedClaimsbooleanOptionalGenerate claims from your entities on startup. For each table Nucleus emits method claims (get.product, post.product, …), optional field-level claims (get.product.price), relation claims, and bulk-operation claims for entities that expose bulk routes. Idempotent — existing claims are skipped.
trueenforceFieldWriteClaimsbooleanOptionalWhen on (default), write routes (POST/PUT/PATCH + bulk) filter the payload to only the columns the caller holds a field WRITE claim for (put.entity.field), on top of the mass-assignment deny-list — so a caller without a full-entity write claim cannot set fields it lacks a claim for. A full-entity-claim holder still writes every non-deny-listed column; godmin bypasses. Set false for pre-H4 entity-claim-only writes.
truerequireClaimSpecificitybooleanOptionalWhen true, a bare single-segment claim (e.g. `get`) no longer acts as an all-entity wildcard — at least `method.entity` (2 segments) is required to grant entity access. Godmin roles always bypass.
falseClaim delivery: embed vs resolve#
How a service learns a caller's claims. The right choice depends on how many claims your roles accumulate and how many services must verify them.
jwtClaimsMode'embed' | 'resolve'Optionalembed writes the full claim list directly into the JWT — dead simple, but the token grows with claim count. resolve puts only role names + a claimsVersion in the JWT and caches role→claims in shared Redis; consumer services resolve claims by role, keeping tokens tiny even with hundreds of claims.
embed— Claims live in the JWT payload. Simple; token grows with claims.resolve— Roles + version in the JWT; claims resolved from Redis. Best at scale.
'embed'claimsCachePrefixstringOptionalRedis key prefix for the role→claims cache used in resolve mode. A version key is bumped on every change so stale caches are detected and refreshed.
"nucleus:claims"The godmin#
A bootstrap super-administrator provisioned at startup so a fresh database is never unmanageable. The godmin bypasses claim checks entirely.
godminEmailstringOptionalEmail of the super-admin to create/ensure on boot. In multi-tenant mode this seeds the godmin for the main schema; each tenant schema gets its own godmin from that tenant's configured godAdminEmail (not this value).
godminPasswordstringOptionalInitial password for the godmin. Provide via an env var and rotate after first login — treat it as a break-glass credential.
Tuning what gets protected#
Sensible defaults keep noise out of the claims table and let infrastructure routes stay open. Override these to fit your domain.
skipTablesstring[]OptionalEntities excluded from claim generation entirely.
["audit_logs"]skipColumnsstring[]OptionalColumns excluded from field-level claim generation — system columns and secrets you never expose as toggles.
["id","created_at","updated_at","is_active","password","version"]excludedPathsstring[]OptionalDEPRECATED and NOT ENFORCED — this key was never wired into the request pipeline, so paths listed here are still authenticated and authorized normally. It does NOT make anything public. Kept only for config back-compat; use publicPaths to open a path.
[]publicPathsstring[]OptionalPaths reachable without a claim check. A bare entry (e.g. /products) opens GET + OPTIONS only — use the method-qualified form ("POST /products") to expose a write. EXCEPTION: a bare entry whose path starts with "/auth/" is exposed for ALL HTTP methods (GET/POST/PUT/PATCH/DELETE/OPTIONS), so an unqualified /auth/login or /auth/register POST works — every other bare path stays GET + OPTIONS only. (The declared default is inert: the raw config value is read without merging defaults, so auth/entity routes stay public via their own flags, not this key.)
["/auth/login","/auth/register"]externalEntitiesNucleusTable[]OptionalFor an IDP (full mode): seed claims for entities that physically live in other consumer services. Only table_name is required; columns are optional for field-level claims. This lets one IDP own the permission model for an entire fleet.
Example: [{ "table_name": "blog_posts" }]
Project seed data#
Beyond entity-derived claims, you can declare project-specific roles, extra claims and role→claim assignments. This runs after auto-seeding and godmin setup, and is fully idempotent — only missing items are created.
1{2 "authorization": {3 "enabled": true,4 "autoSeedClaims": true,5 "godminEmail": "GODMIN_EMAIL",6 "seed": {7 "roles": [{ "name": "editor" }],8 "roleClaimAssignments": [9 {10 "role": "editor",11 "claims": ["get.post", "put.post"],12 "scope": "user_id=self:id"13 }14 ]15 }16 }17}seedobjectOptionalDeclarative starter data for your access model.
roles{ name; description? }[]OptionalRoles to ensure exist (e.g. editor, viewer, billing-admin).
claims{ action; path; method; description? }[]OptionalCustom claims beyond the entity-derived ones — useful for bespoke routes.
roleClaimAssignments{ role; claims[]; scope? }[]OptionalWire claims onto roles. The optional scope is a URLSearchParams string (e.g. "user_id=self:id") that adds row-level filtering — self:<field> resolves to the caller's own value, restricting the role to rows it owns.
Under the hood — the claim check#
On every guarded request checkAuthorization loads the caller's roles and their claims, then decides access against a claim pattern. The same matching logic runs in the browser (usePermission), which is why the UI and API always agree.
claim patternmethod.entity[.field | .with.relation]OptionalThe required permission is built from the request: get.product for the entity, get.product.price for a field, get.product.with.author for a relation. The HTTP method is lower-cased and joined with dots.
hierarchical matchingclaimMatchesOptionalA held claim authorises anything more specific: get.product matches get.product.price and get.product.with.author (a prefix match), but a field-only claim never widens to the whole entity. A godmin role short-circuits the whole check.
field & relation authorizationallowedFields / allowedRelationsOptionalWithout a full-entity claim, the request is still allowed if the caller holds claims for the specific fields/relations asked for — and the response is then trimmed by filterResponseFields (id is always kept) and filterResponseRelations, so a user cannot read a column they lack a claim for. A full/table-level read claim reveals every column EXCEPT columns declared sensitive: true (see below), which stay claim-gated.
sensitive columns (sensitive: true)stripUnclaimedSensitive / sensitiveFieldQueryableOptionalA column marked sensitive: true in config.entities is claim-gated ON TOP OF the ordinary model, inverting the blanket table-read rule: a caller holding only the full table-level read claim (allowedFields undefined/empty) does NOT see it — stripUnclaimedSensitive scrubs it from every list/detail response AND from /auth/me — and sensitiveFieldQueryable blocks it in filter/search/sort/select/distinct. It is revealed and queryable ONLY for a caller holding that specific per-column read claim (a defined allowedFields containing the column); godmin bypasses. Registered at boot from config.entities (registerSensitiveColumns) and unioned into the logger's redactKeys, so the value is also redacted from log context AND audit diffs. Distinct from the absolute password/auth-secret block (SENSITIVE_OUTPUT_KEYS), which NO claim opens. Declared on the entity/column (see the entities doc) but enforced here on the authorization field-read path.
scope & self: referencesscopeFiltersOptionalA role-claim may carry a scope (a URLSearchParams string like userId=self:id). self:<field> is resolved from the caller's own user row — lazily, only when a matched claim actually needs it, so unscoped requests do no extra query. On reads and by-id/bulk writes the resolved values become WHERE filters (applyScopeFilters); on CREATE (POST/form/bulk-POST) there is no row to filter, so they are forced onto the new record — overriding any client-supplied owned value, and failing closed (403) if the scope names a column the entity lacks — so a scoped claim can never create a row owned by someone else. This is true row-level security, the runtime behind the $self seed assignment.
godmin-only generic writes to sensitive tableshard guardOptionalIndependent of the claim model, generic-entity writes (POST/PUT/PATCH/DELETE + bulk) to the framework's own sensitive tables require godmin: the RBAC tables (roles/claims/user_roles/role_claims — so a claim can't self-assign godmin) and the money / tenant-routing / auth-secret system tables (payment_*, domain_*, verifications, api_keys, magic_link_tokens, user_sessions, trusted_devices). Their columns are mass-assignable (domain_hostnames.tenant_id is the live routing key; payment_sub_merchants.iban a payout destination), so legitimate owner writes must go through the dedicated routes (/invoices, /sub-merchants, /domains/hostnames, /subscriptions, /cards). Generic reads stay on the claim model.
Under the hood — multi-service modes#
Authorization works across a fleet, not just one service. An IDP owns the model; resource servers verify access in one of three ways depending on how much they trust the caller's token.
full (DB-backed)checkAuthorizationOptionalThe IDP / monolith path: roles and claims (with scope and self:) are read from the database for the complete, authoritative check described above.
consumer — from JWTcheckAuthorizationFromJWTOptionalA stateless resource server reads x-user-roles / x-user-claims (and x-user-claim-scopes) injected by a trusted gateway and matches them with no database at all. In embed mode the JWT now carries per-action ownership scope (claimScopes, resolved self: → concrete values at mint), so checkAuthorizationFromJWT merges each granting claim's scope into scopeFilters at both the entity-level and field/relation-level paths (mergeGrantingScopes) — enforcing the SAME row-level filtering as the DB path, not just field/relation matching. A legacy token minted without claimScopes simply runs unscoped. (This closes the pre-0.9.734 embed-mode IDOR where scoped claims were silently dropped on the JWT path.) Resolve mode carries no inline claims/scope on the token — use 'consumer — via IDP' or the ClaimsCache when you need scope from a resolve-mode token.
consumer — via IDPcheckAuthorizationViaIDPOptionalWhen a consumer needs full scope/self semantics it POSTs to {IDP_URL}/auth/check with the access token and trusts the IDP's verdict. The response envelope is unwrapped defensively (legacy flat shapes still accepted; anything unrecognised fails closed).
ClaimsCache (resolve mode)Redis-versionedOptionalWhen jwtClaimsMode is resolve, buildCache() writes {prefix}:role:{name} → claim arrays plus a {prefix}:version integer that bumps on every role-claim change. Tokens carry only roles + the version; services resolveClaimsForRoles() from Redis and rebuild when the version moves — keeping tokens tiny at any claim count.
ClaimGuards — capping usage & spend#
The generic primitive that replaced the old numeric "quota". A ClaimGuard caps or gates a capability by SUPPRESSING claims when a comparable shared key satisfies a condition — spend limits, usage caps, feature flags, maintenance windows: anything expressible as "when this key op this value, take these claims away". Suppression runs through the same claim-gate as everything else, so once the claims vanish the ordinary authorization denies the capability (reason claim_guard). Nucleus knows no metric, endpoint or window — those are all data.
1{2 "authorization": {3 "endpointDiscovery": { "enabled": true, "services": [] },4 "claimGuards": [5 { "name": "token_spent_today:{userId}", "value": "int" },6 { "name": "dolar_spent_today:{userId}", "value": "int" },7 { "name": "maintenance_mode", "value": "boolean" }8 ]9 }10}claimGuards{ name; value }[]OptionalA CATALOG of comparable shared keys. Each entry seeds exactly one claim with method GUARD (action = the name up to the first {, path = the name, policy = { kind:'guard', keyTemplate, valueType }). It carries no condition, endpoint or metric — only a key name and its type. Seeded idempotently and reconciled: removing an entry deactivates its GUARD claim (never deletes → assignments survive), re-adding reactivates it.
namestringOptionalThe shared state-store key, optionally templated with {userId} / {tenant} (e.g. token_spent_today:{userId}). The claim action is the head up to the first { (token_spent_today).
value'int' | 'boolean' | 'string'OptionalThe key's value type. Determines which comparison operators an assignment may use: int → gt/gte/lt/lte/eq/ne; boolean/string → eq/ne.
int— Numeric key — gt/gte/lt/lte/eq/ne.boolean— Boolean flag — eq/ne.string— String key — eq/ne.
assignment conditionrole_claims.scope = { op; value; invalidates[] }OptionalThe dynamic part — nothing is hardcoded. Assign a GUARD claim to a role and store a JSON condition in that role_claims.scope (via the portal GuardConditionEditor or PATCH /roleClaims/:id). op is one of the operators allowed by the value type; value is the compared number/boolean/string; invalidates lists claim ACTIONS to suppress while the condition holds. The SAME GUARD claim caps different roles differently (role A gt 1000000, role B gt 50000).
invalidates matchingprefix-awareOptionalSuppressed claims are subtracted prefix-aware (dot-bounded): ["llm"] removes every llm.* claim, ["llm.prediction.…predict_stream…"] removes only that leaf. Discovered claims are LEAF rows, so use the shortest prefix that covers the capability you are capping.
GET /authorization/guardsendpoint (alias /quotas)OptionalThe full EvaluatedGuard[] roster — each { action, key, valueType, op, threshold, current, invalidates, holds } — every guard the caller has with its live key value and whether it currently holds. Powers the profile Usage-limits panel ("token_spent_today = 42 000, gt 1 000 000 → ok"). Self via x-user-id; godmin reads anyone via ?userId=. /quotas is a migration alias.
GET /authorization/suppressed-claimsendpointOptional{ suppressedClaims, firedGuards } — the actions the caller has LOST right now plus the guards that fired (each names its key so a denial can be explained). This is what the proxy's authorize layer GETs to enforce guards. Self via x-user-id; godmin via ?userId=.
Endpoint discovery — protecting external services#
A nucleus IDP seeds claims for its own Drizzle-backed routes, but a real platform also runs non-nucleus back-ends (e.g. Python FastAPI) with hundreds of endpoints that must obey the SAME role/claim model. endpointDiscovery fetches each service's OpenAPI, seeds one claim per operation, and enforces them at the nucleus-core-ts/proxy reverse proxy — with zero changes to the downstream service or gateway. Full (IDP) mode only.
1{2 "authorization": {3 "endpointDiscovery": {4 "enabled": true,5 "runOnBoot": true,6 "services": [7 {8 "id": "agent",9 "baseUrl": "http://vorion-agent:8000",10 "openapi": "/openapi.json",11 "exclude": ["/health", "/docs", "/openapi.json", "/api/v1/internal/*"]12 },13 { "id": "llm", "baseUrl": "http://vorion-llm:8000" }14 ],15 "token": "SHARED_SECRET"16 }17 }18}enabledbooleanOptionalMaster switch. When true (with a DB and non-consumer mode) the discovery + manifest routes mount and boot discovery can run. Also gates the ClaimGuard endpoints (see the ClaimGuards coupling note).
falserunOnBootbooleanOptionalFetch + parse + reconcile every configured service's OpenAPI during boot seeding. Discovery is idempotent, so re-running each boot is safe; a per-service fetch/parse failure is logged and skipped (never fails boot).
true (when enabled)services{ id; baseUrl; openapi?; exclude? }[]OptionalThe external services to discover. Each OpenAPI operation becomes a claim action {id}.{tag|resource}.{operationId | method_path}, hierarchical so a role can be granted {id}.*, {id}.resource.*, or a single op via prefix match. Vanished endpoints are DEACTIVATED (isActive:false), never deleted, so role assignments survive.
idstringOptionalService namespace, prefixed onto every discovered action ({id}.…). Must NOT be an HTTP-method word (get/post/put/patch/delete/toggle/bulk) so discovered claims can't collide with entity claims like get.users.
baseUrlstringOptionalWhere to fetch the OpenAPI and (via the proxy) where requests are forwarded, e.g. http://vorion-agent:8000.
openapistringOptionalPath to the OpenAPI document relative to baseUrl.
"/openapi.json"excludestring[]OptionalPaths to skip when seeding claims (health checks, docs, internal routes), e.g. ['/health', '/docs', '/api/v1/internal/*'].
trigger{ enabled; basePath }OptionalDECLARED BUT NEVER READ — inert. No code reads trigger.enabled or trigger.basePath; POST /authorization/discover is a hardcoded path gated solely on endpointDiscovery.enabled. Setting trigger changes nothing.
tokenstringOptionalShared secret (or env NUCLEUS_DISCOVERY_TOKEN) that lets the proxy — which holds no user session — fetch the route/role-claims manifests. Presented as the x-discovery-token header; the manifest handlers accept godmin OR a valid token.
POST /authorization/discoverendpointOptionalRe-run discovery + reconcile on demand; returns the per-service diff ({ added, reactivated, deactivated, unchanged, total }). godmin only (session-protected). The route is only mounted when endpointDiscovery.enabled is true (and a DB is configured, non-consumer mode) — a call 404s otherwise; when it IS mounted, a non-godmin caller gets 403.
GET /authorization/route-manifestendpoint (?service={id})OptionalThe templated route → required-action table the proxy consumes. Registered as a PUBLIC route so the sessionless proxy can reach it; the handler enforces godmin OR a valid x-discovery-token. A companion GET /authorization/role-claims-manifest returns the role → claim-actions map the proxy uses in resolve mode.
Related sections