Authentication & Roles

The layered role model that gates the editor and harvester-admin services, and how it maps to Keycloak.

This page describes the role model that gates the editor and harvester-admin services, how the roles are wired up in Keycloak, and the per-deployment configuration each service needs.

The four-layer model

Access is governed by a layered set of realm roles. The hierarchy is encoded in Keycloak as composite roles, a higher role contains the lower one, so a user holding a higher role automatically holds all lower roles in their token. The application code never has to check role-A OR role-B; each protected route asks for exactly one role.

regelrecht-admin / \ editor-admin harvester-admin │ │ editor-writer harvester-writer │ │ editor-reader harvester-reader

A line means “the role above contains the role below” (configured as a Keycloak composite role).

There are two applications today:

  • editor: packages/editor-api + frontend/ (law and scenario editor)
  • harvester: packages/admin, a standalone harvester job-queue & corpus API. Its dashboard UI is served inside the editor as the “Corpusinwinning” section (frontend/src/harvester/), visible to any harvester-* role and reached through the editor-api /api/harvest-admin/* proxy (which forwards the session cookie so the harvester API enforces the harvester-* gates below). The API stays independently addressable for non-editor clients.

Role reference

RoleGrants
editor-readerEditor: read user-scoped data (favorites, settings) and harvest search.
editor-writerEditor: edit laws & scenarios, manage favorites/settings, enqueue harvests. Inherits editor-reader.
editor-adminEditor: corpus reload, feature-flag changes. Inherits editor-writer.
harvester-readerHarvester admin: read jobs, sources, law entries, platform info.
harvester-writerHarvester admin: enqueue harvest and enrich jobs. Inherits harvester-reader.
harvester-adminHarvester admin: delete jobs, reset exhausted entries, sync sources. Inherits harvester-writer.
regelrecht-adminEverything across both apps. Inherits editor-admin and harvester-admin.

Specific (orthogonal) rights

Some functions need their own gate, separate from the writer/reader ladder. Example: not every editor-writer should be able to publish a law. Model these as their own realm role (e.g. editor-publish) and add them to the relevant <app>-admin composite. Users who need the right without the full admin role get it granted explicitly on top of their writer role.

editor-admin / | \ editor-writer editor-publish (and any others) | editor-reader

To add a new specific right:

  1. Create a new realm role <app>-<verb> in Keycloak.
  2. Add it to the composite <app>-admin (so admin keeps inheriting everything).
  3. Optionally assign it to users who need it without the admin role.
  4. In the application code: route_layer(require_role("<app>-<verb>")) on the protected route.

No changes are needed to existing routes; the pattern is composable.

JWT shape

The application reads realm_access.roles from the ID token. With composite roles, the token contains the effective set of roles after expansion:

// regelrecht-admin user "realm_access": { "roles": [ "regelrecht-admin", "editor-admin", "editor-writer", "editor-reader", "harvester-admin", "harvester-writer", "harvester-reader" ] } // editor-writer user (no publish right) "realm_access": { "roles": ["editor-writer", "editor-reader"] }

Keycloak setup

  1. Create the roles in the realm: all seven plus any specific rights you need.
  2. Configure composite roles:
    • editor-writer → contains editor-reader.
    • editor-admin → contains editor-writer (+ every editor specific right).
    • harvester-writer → contains harvester-reader.
    • harvester-admin → contains harvester-writer (+ every harvester specific right).
    • regelrecht-admin → contains editor-admin and harvester-admin.
  3. Realm-roles mapper on the ID token: each OIDC client (editor, harvester-admin) needs a “Realm roles” mapper that injects realm_access into the ID token (not just the access token). Keycloak only adds it to the access token by default; without this mapper the service falls back to parsing the access token, which is noisier in the logs.
  4. Assign roles to users.

Per-deployment configuration

Each service is gated on a minimum role at login time, configured via OIDC_REQUIRED_ROLE. Per-route checks layer finer-grained roles on top.

ComponentOIDC_REQUIRED_ROLE
editoreditor-reader
harvester-adminharvester-reader

If OIDC_REQUIRED_ROLE is unset or empty, the service falls back to allowed-user and logs a warning on startup. This default keeps the pre-RBAC migration path working out of the box; always set the value explicitly in production so the login gate matches the per-app reader role (editor-reader / harvester-reader) once the migration completes.

Setting the env var on ZAD

The zad-actions/deploy@v4 GitHub action used in .github/workflows/deploy.yml takes only image and clone-from; env vars are set out-of-band per component via the ZAD CLI or dashboard. For preview deploys, clone-from: regelrecht carries the value from the production deployment automatically, you only need to set it once per environment.

# Production zad component edit editor --deployment regelrecht \ --env OIDC_REQUIRED_ROLE=editor-reader zad component edit harvester-admin --deployment regelrecht \ --env OIDC_REQUIRED_ROLE=harvester-reader

Pre-existing sessions at deploy time

Sessions created before this code shipped carry authenticated = true but no SESSION_KEY_ROLES key. The per-route role check distinguishes “key absent” (pre-RBAC session) from “key present but empty list” (a legitimately mis-configured Keycloak): the former returns 401, which triggers the OIDC re-login redirect, the callback then populates SESSION_KEY_ROLES from the JWT and the session self-heals. No session flush is required at deploy.

Migration from the legacy allowed-user role

Earlier deployments used a single allowed-user realm role checked at login, with no per-route gating. To migrate without locking anyone out:

  1. Keycloak (hard prerequisite): create the seven new roles, set up composites, attach the ID-token mapper, and grant every existing user an appropriate new role (most editor users → editor-writer). This must be fully rolled out before Step 2, any user without one of the new roles will get 403 on every API request once the new code is live, because the per-route middleware checks for editor-reader / harvester-reader etc., not allowed-user.
  2. Deploy the new code. If OIDC_REQUIRED_ROLE is unset on the existing deployment, the new code falls back to allowed-user and logs a warning, so the login redirect keeps working during the rolling deploy (provided step 1 is complete). Per-route checks gate on the new roles immediately, so users without one of the new roles will see 403 on every protected request until step 1 is rolled out for them. Setting the env var explicitly to allowed-user is still recommended for clarity. Keep the allowed-user role granted to all migrated users.
  3. Switch OIDC_REQUIRED_ROLE on each component to its new value (editor-reader / harvester-reader).
  4. Remove the allowed-user role from the realm.

Operational notes

Role-change propagation

The role set is read from the JWT at login and cached in the session (SESSION_KEY_ROLES) for the lifetime of that session. Per-request middleware reads this cached list rather than re-parsing the token, which means:

  • Role changes in Keycloak only take effect on the next login. Granting a user a new role (e.g. promoting editor-writer to editor-admin) requires the user to log out and back in before the new role is honoured by the application.
  • Role revocation has the same delay. Removing a role in Keycloak does not immediately revoke access; the live session continues to carry the expanded role list until it expires.

For emergency revocation (compromised account, immediate downgrade) the session store must also be cleared so the cached role list cannot be reused. Sessions live in the PostgreSQL tower_sessions.session table on each service’s database:

-- Revoke all live sessions for a specific user (by Keycloak sub): -- The session stores the Keycloak subject under the key `person_sub` -- (see SESSION_KEY_SUB in packages/auth/src/handlers.rs). DELETE FROM tower_sessions.session WHERE data::jsonb -> 'data' ->> 'person_sub' = '<keycloak-sub>'; -- Nuclear option — invalidate every active session on the service: TRUNCATE tower_sessions.session;

After deleting the session row(s), the affected user is forced through the OIDC login again, which re-reads roles from Keycloak.

Auth-disabled mode (dev/local only)

When the OIDC environment variables are not configured (OIDC_CLIENT_ID unset), each service starts with all per-route auth checks bypassed. Every tier (reader, writer, and admin) is reachable without a session. This mode exists for local development convenience (no Keycloak required) and emits a warn! line at startup:

OIDC authentication is DISABLED — editor is unprotected. All routes (editor-reader/writer/admin tiers) bypass auth checks. Do NOT run this configuration in production.

The same applies to the harvester-admin service. Never deploy a service without OIDC configured: the warning is the only safeguard, and the admin-tier routes (corpus reload, feature-flag toggles, job deletion, source sync) are fully open in this mode.

Local SSO login (dev/local, real Keycloak)

To test the real login + RBAC flow locally, instead of the auth-disabled bypass above, you can point the editor at a Keycloak realm and log in with a real account over http://localhost. No code change is required; the auth layer is driven entirely by environment variables.

How the local flow fits together

  • The editor frontend runs on Vite (started with --port 7300) and proxies /api, /auth, /health to the editor-api on :8000. The browser talks only to http://localhost:7300. (Vite’s config default is :3000; we use a host-mapped port, see Ports and DB host below.)
  • The redirect URI is built as ${BASE_URL}/auth/callback. With BASE_URL=http://localhost:7300 it becomes http://localhost:7300/auth/callback, which Vite proxies to the backend.
  • When OIDC is enabled the session uses a PostgreSQL store, so DATABASE_URL is required (the just dev Postgres works; from a dev container reach it via host.docker.internal).

Keycloak configuration (one-time, realm admin)

Configure the OIDC client the editor will use. Re-using the production client and adding a localhost redirect to it is a minor token-leakage footgun, so the cleaner choice is a single dedicated dev client, regelrecht-local, shared across all local apps (editor, harvester-admin, …), with a localhost redirect URI per app port:

  1. Client type / capability: Client authentication = On (a confidential client: the editor sends a client secret, and startup fails if OIDC_CLIENT_SECRET is empty). Enable Standard flow (Authorization Code). PKCE (S256) is sent by the app and accepted by Keycloak out of the box; you may optionally enforce it under Advanced → Proof Key for Code Exchange.
  2. Valid redirect URIs: add exactly http://localhost:7300/auth/callback for the editor. (The harvester-admin service is API-only; its UI is the editor’s Corpusinwinning section, reached through the editor-api proxy, so it needs no separate browser redirect URI in local dev.) Scheme + host + port + path must match the value the app sends.
  3. Web origins: add http://localhost:7300, or + to derive from the redirect URIs, so the browser’s CORS checks pass.
  4. Client scopes: ensure openid, email, and profile are granted; the app requests these three scopes.
  5. Realm-roles ID-token mapper: add a User Realm Role mapper on the client (or its dedicated scope) with Add to ID token = On. Keycloak only puts realm_access.roles in the access token by default; without this mapper the app falls back to parsing the access token (noisier, but still works).
  6. Roles on your account: grant your user at least editor-reader (the login gate set by OIDC_REQUIRED_ROLE). Without it you can authenticate but get 403 on every API call. See the role hierarchy above for higher tiers.
  7. Note the discovery URL (https://<host>/realms/<realm>/.well-known/openid-configuration), the client ID, and the client secret (Credentials tab) for the env file.

Run it

Copy .env.sso-local.example to .env.sso-local, fill in the Keycloak values, then:

# 1. Postgres only. (Don't use `just dev` here — it also starts the admin API # on :8000, which collides with editor-api below.) docker compose -f docker-compose.dev.yml -f dev/compose.native.yaml up -d postgres # 2. editor-api on :8000 with .env.sso-local loaded: just editor-sso # 3. in another shell — editor frontend on the host-mapped port 7300: cd frontend && npx vite --port 7300

Open http://localhost:7300 in any browser and log in. When BASE_URL is an http localhost origin the editor drops the session cookie’s Secure flag, so Safari (which, unlike Chrome and Firefox, refuses Secure cookies over http://localhost) completes the OIDC handshake too. Outside localhost (production always serves over an https:// BASE_URL) the cookie stays Secure.

On a successful start the editor-api log shows using OIDC_DISCOVERY_URL for issuer: … and session store ready (PostgreSQL-backed) (not the “OIDC is DISABLED” warning).

Ports and DB host. The values above assume a dev container backed by Docker Desktop (the common setup here):

  • DATABASE_URL uses host.docker.internal: published container ports live on the Docker host, not the container’s localhost (the same reason TESTCONTAINERS_HOST_OVERRIDE is needed for tests).
  • The frontend uses a host-mapped port in the 7100–7300 range (7300) because only mapped ports are reachable from the browser; 3000/8000 are not forwarded. The editor-api stays on :8000 (container-internal; only Vite needs to be reachable).

On a plain Linux host neither applies: use localhost for the DB and any free port (e.g. 3000) for BASE_URL and Vite, with a matching redirect URI in Keycloak.

Programmatic access (admin API key)

The harvester-admin service accepts a bearer API key on GET, POST and DELETE requests (ADMIN_API_KEY env var). This is an out-of-band trust path, the holder is treated as a regelrecht-admin-equivalent on those methods, so scripts and services can enqueue harvest/enrich jobs (POST /api/harvest-jobs, POST /api/enrich-jobs) without driving an interactive OIDC/SSO session. Because the trust is method-based (not route-based), the key also reaches the admin-tier POST routes (reset-exhausted, source sync). That is consistent with it already permitting the destructive DELETE /api/jobs. A user session with the matching role still works too. The editor service has no API key path.

Implementation pointers

  • Shared crate: packages/auth/, require_role(role) middleware factory.
  • Editor routes: packages/editor-api/src/main.rs, router split into public / reader / writer / admin groups.
  • Harvester-admin routes: packages/admin/src/main.rs, router split into reader / writer / admin groups; require_auth(role) in packages/admin/src/middleware.rs keeps the API-key bypass.
  • Roles are persisted in the session at login (SESSION_KEY_ROLES) so per-request checks don’t re-parse the JWT.

RegelRecht

An exploration by Bureau Architectuur of the Dutch Ministry of Economic Affairs and Climate Policy into the possibilities of transparent, executable legislation.

Links

GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research

Contact

regelrecht@minbzk.nl

Part of

Bureau Architectuur
Ministry of Economic Affairs and Climate Policy