RBAC & super admin¶
Authorization is enforced in two layers: JWT role/permission at the HTTP edge, and RLS in the database. If one fails, the other holds.
Role model¶
Four ranked roles — 1 is the highest:
RoleSuperAdmin = 1 // manages admin users
RoleAdmin = 2 // all permissions
RoleManager = 3
RoleUser = 4 // default for new eID users
IsAdmin()istruefor both admin and super admin — a super admin inherits the admin JWT / RLS / permission paths.IsSuperAdmin()is true only for super admins and gates the/superadminsurface.- Role ID
0is the sentinel for legacy claim-less tokens; the RBAC middleware maps it down toRoleUser.
Dynamic permissions¶
Beyond the coarse ranking, the rbac usecase resolves a role's permission set
from the database (roles · permissions · role_permissions).
| Permission | Category | What it unlocks |
|---|---|---|
dashboard.view |
general | Dashboard |
settings.manage |
general | Settings, themes, AI prompts |
users.manage |
administration | User management |
roles.manage |
administration | Roles and permissions |
gateway.manage |
administration | API gateway, OAuth client registry |
registry.view |
administration | Viewing the service registry |
registry.manage |
administration | Editing and publishing the registry |
relay.view |
administration | SLA dashboard |
relay.manage |
administration | Relay routing |
gov.catalog |
administration | Managing the service catalogue |
gov.review |
management | Reviewing citizen applications (officer) |
manager.view |
management | Manager area |
personal.view |
personal | Personal area (/me/*) |
Default grants for system roles:
| Role | Permissions |
|---|---|
admin |
The entire catalogue — resolved automatically, no explicit rows |
manager |
dashboard.view · manager.view · users.manage |
user |
dashboard.view · personal.view |
Using it on routes¶
r.With(mw.RequirePermission(resolver, "registry.manage")).
Post("/services", h.CreateService)
r.With(mw.RequireAdmin()).Get("/audit", h.ListAudit)
r.With(mw.RequireSuperAdmin()).Route("/superadmin", …)
RequirePermissionlets admins through — they hold every permission.- If the resolver's database read fails it fails closed.
- The frontend builds its menu from the same permission map in
lib/navigation.ts, verified bynavigation.test.ts.
Role management API¶
| Endpoint | Permission | What |
|---|---|---|
GET /v1/rbac/me |
authenticated | My role and permissions |
GET /v1/rbac/roles |
roles.manage |
List roles |
GET /v1/rbac/permissions |
roles.manage |
Permission catalogue |
POST /v1/rbac/roles |
roles.manage |
Create a role |
PUT /v1/rbac/roles/{id} |
roles.manage |
Rename / describe |
PUT /v1/rbac/roles/{id}/permissions |
roles.manage |
Set the permission set |
DELETE /v1/rbac/roles/{id} |
roles.manage |
Delete a role |
System roles (is_system = true) cannot be deleted. Every mutation is audited.
User management¶
| Endpoint | Permission |
|---|---|
GET/POST /v1/admin/users |
users.manage |
PUT /v1/admin/users/{id}/role |
users.manage |
PUT /v1/admin/users/{id}/active |
users.manage |
DELETE /v1/admin/users/{id} |
users.manage (soft delete) |
Super admin¶
The super admin is the single role that manages admin users. Its account
lives in the separate superadmin_accounts table — so the same person can be an
eID admin and a super admin through a different method.
| Endpoint | What |
|---|---|
GET/POST /v1/superadmin/admins |
List / create admin users |
GET/POST /v1/superadmin/admins/by-register |
By national ID |
PUT /v1/superadmin/admins/{id}/grant |
Grant |
DELETE /v1/superadmin/admins/{id} |
Revoke |
GET/POST /v1/superadmin/invites |
Invite allow-list |
DELETE /v1/superadmin/invites/{email} |
Cancel an invite |
GET/PUT /v1/superadmin/access-mode |
Platform access mode |
Hardening:
- Every mutation is written to the audit log.
- MFA (TOTP) is mandatory at sign-in; recovery codes are stored hashed.
- The first super admin is bootstrapped only from
SUPERADMIN_EMAIL/ the database — it cannot be created through the API. - See eID sign-in for the onboarding flow.
Adding a permission
Insert the row into permissions in a migration, gate the route with
RequirePermission in the backend, and add the menu condition in
lib/navigation.ts. Miss any of the three and the UI shows a page whose API
returns 403.