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.
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.
A line means “the role above contains the role below” (configured as a Keycloak composite role).
There are two applications today:
packages/editor-api + frontend/ (law and scenario editor)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 | Grants |
|---|---|
editor-reader | Editor: read user-scoped data (favorites, settings) and harvest search. |
editor-writer | Editor: edit laws & scenarios, manage favorites/settings, enqueue harvests. Inherits editor-reader. |
editor-admin | Editor: corpus reload, feature-flag changes. Inherits editor-writer. |
harvester-reader | Harvester admin: read jobs, sources, law entries, platform info. |
harvester-writer | Harvester admin: enqueue harvest and enrich jobs. Inherits harvester-reader. |
harvester-admin | Harvester admin: delete jobs, reset exhausted entries, sync sources. Inherits harvester-writer. |
regelrecht-admin | Everything across both apps. Inherits editor-admin and harvester-admin. |
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.
To add a new specific right:
<app>-<verb> in Keycloak.<app>-admin (so admin keeps inheriting everything).route_layer(require_role("<app>-<verb>")) on the
protected route.No changes are needed to existing routes; the pattern is composable.
The application reads realm_access.roles from the ID token. With composite
roles, the token contains the effective set of roles after expansion:
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.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.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.
| Component | OIDC_REQUIRED_ROLE |
|---|---|
editor | editor-reader |
harvester-admin | harvester-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.
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.
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.
allowed-user roleEarlier deployments used a single allowed-user realm role checked at login,
with no per-route gating. To migrate without locking anyone out:
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.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.OIDC_REQUIRED_ROLE on each component to its new value
(editor-reader / harvester-reader).allowed-user role from the realm.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:
editor-writer to editor-admin) requires
the user to log out and back in before the new role is honoured by the
application.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:
After deleting the session row(s), the affected user is forced through the OIDC login again, which re-reads roles from Keycloak.
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:
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.
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
--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.)${BASE_URL}/auth/callback. With
BASE_URL=http://localhost:7300 it becomes
http://localhost:7300/auth/callback, which Vite proxies to the backend.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:
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.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.http://localhost:7300, or + to derive from the
redirect URIs, so the browser’s CORS checks pass.openid, email, and profile are granted; the
app requests these three scopes.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).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.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:
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).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.
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.
packages/auth/, require_role(role) middleware factory.packages/editor-api/src/main.rs, router split into
public / reader / writer / admin groups.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.SESSION_KEY_ROLES) so
per-request checks don’t re-parse the JWT.An exploration by Bureau Architectuur of the Dutch Ministry of Economic Affairs and Climate Policy into the possibilities of transparent, executable legislation.
GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research
Bureau Architectuur
Ministry of Economic Affairs and Climate Policy