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.
trueClaim 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 a godmin is seeded into every tenant schema.
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[]OptionalPaths the authorization middleware ignores completely.
["/health","/swagger"]publicPathsstring[]OptionalPaths reachable without any claim check.
["/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": { "userId": "$self" }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 adds row-level filtering — e.g. scope { "userId": "$self" } restricts a role to rows the caller 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 literally cannot read a column they lack a claim for.
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. The resolved values become WHERE filters (applyScopeFilters), giving true row-level security; this is the runtime behind the $self seed assignment.
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 (injected by a trusted gateway) and matches them with no database at all. Fast, but JWT claims carry no scope, so row-level filtering isn't available on this path — field/relation matching still is.
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.
Related sections