Service Accounts¶
Service accounts are machine-to-machine principals used by external integrations (e.g. a UBL inbound invoice processor). They authenticate with OAuth2 client-credentials and are subject to the same RBAC and organisation-scoping as human users.
Concepts¶
| Concept | Description |
|---|---|
| Keycloak client | A confidential OAuth2 client in the erp-nl realm with serviceAccountsEnabled: true |
| ERP service account | A User row with principal_type = "service" and auth_provider = "keycloak_client" linked to the Keycloak client via keycloak_client_id |
| Role assignment | One or more starter roles from rbac_roles assigned per organisation via rbac_user_role_assignments — the same table used for human users |
| Organisation scope | The organisation is passed as organization_short_code in the request body or query string; the account's role assignments for that organisation are checked |
Authorization uses the same get_effective_access(db, user_id, org_id) resolver as human users.
There is no separate flat-permissions field; all access is expressed through RBAC roles.
Authentication flow¶
Integration → POST /realms/erp-nl/protocol/openid-connect/token
grant_type=client_credentials
client_id=<keycloak_client_id>
client_secret=<secret>
Keycloak → JWT access token (azp=<keycloak_client_id>, no preferred_username)
FastAPI → _decode_keycloak_token(): detects machine token (azp present, preferred_username starts with "service-account-")
→ _lookup_user(): finds User by keycloak_client_id, checks principal_type="service"
→ _principal_from_user(): builds {principal_type: "service", ...}
→ require_service_principal():
rejects non-service tokens → 403
→ _setup_org_context():
resolves organization_short_code → Organization.id
verifies membership in user_organizations → 403 if absent
set_system_rls_context() → RLS active for the session
get_effective_access() → 403 if required permission absent
→ route handler
Token requests use Authorization: Bearer <token> on subsequent API calls. Tokens expire after
300 seconds (realm default); integrations must re-request on expiry.
No refresh tokens are issued. Per the NL GOV OAuth 2.0 Profile v1.1.0 §3.1.9, clients using
the client_credentials grant MUST NOT receive refresh tokens. This is enforced at the client
level:
- Client attribute: client_credentials.use_refresh_token: false on every service-account
client (set automatically when the client is registered via the ERP service account API)
Phase 2 note: The NL GOV OAuth profile §3.1.3 requires M2M clients to authenticate using
private_key_jwt(signed client assertions) rather than a sharedclient_secret. Migration toprivate_key_jwtis tracked in the Phase 2 backlog.
Assigning permissions to a service account¶
Service accounts are granted permissions by assigning starter roles per organisation.
The permission key required by require_service_permissions() must belong to at least one
of the assigned roles. Use existing keys from ROUTE_PERMISSION_RULES in
backend/rbac/catalog.py; do not invent new ones.
Managing service accounts¶
All management endpoints require the RBAC setup permissions for platform user/service-account
management. Service accounts can also be managed through the ERP admin UI at
/setup/platform/users → Service accounts tab.
List all service accounts¶
GET /api/service-accounts
Authorization: Bearer <admin-token>
Response includes a role_assignments array:
[
{
"id": "<uuid>",
"username": "srv-ubl-inbound",
"full_name": "UBL Inbound Processor",
"keycloak_client_id": "<auto-generated-uuid>",
"active": true,
"role_assignments": [
{
"organization_id": "<org-uuid>",
"roles": [
{ "id": "<role-uuid>", "code": "payables_clerk", "name": "Crediteurenadministratie" }
]
}
]
}
]
Create a service account¶
POST /api/service-accounts
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"username": "srv-<integration-name>",
"full_name": "<Human readable name>",
"role_assignments": [
{ "organization_id": "<org-uuid>", "role_codes": ["<starter-role-code>"] }
],
"active": true
}
The username must start with srv-. The backend auto-generates a UUID keycloak_client_id
and, if Keycloak is reachable, registers a confidential client automatically. The 201 response
includes a one-time client_secret — store it immediately; it cannot be retrieved again.
role_codes must reference active roles for the given organisation.
Update a service account¶
PUT /api/service-accounts/{service_account_id}
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"full_name": "UBL Inbound Processor",
"role_assignments": [
{ "organization_id": "<org-uuid>", "role_codes": ["payables_clerk"] }
],
"active": true
}
The role_assignments array fully replaces all existing assignments for the account.
Deactivate a service account¶
DELETE /api/service-accounts/{service_account_id}
Authorization: Bearer <admin-token>
Sets active = false. The Keycloak client must be disabled separately in Keycloak if the
integration should be fully blocked.
Adding a new integration¶
Step 1 — Create the service account¶
Via the ERP admin UI at /setup/platform/users → Service accounts tab → Service account
toevoegen, or via the API:
POST /api/service-accounts
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"username": "srv-<integration-name>",
"full_name": "<Human readable name>",
"role_assignments": [
{ "organization_id": "<org-uuid>", "role_codes": ["<starter-role-code>"] }
],
"active": true
}
The 201 response includes a one-time client_secret. Copy and store it immediately — it
is not stored and cannot be retrieved again. Store it in a Kubernetes Secret or the secrets
manager used by the integration service.
Choose role_codes that include the RBAC permission key required by the endpoint.
See backend/rbac/catalog.py for which permissions belong to which roles.
Step 2 — Configure the integration service¶
Set the following in the integration's environment:
| Variable | Value |
|---|---|
CLIENT_ID |
keycloak_client_id from the step-1 response (a UUID) |
CLIENT_SECRET |
The one-time secret from step 1 |
TOKEN_URL |
<KEYCLOAK_URL>/realms/erp-nl/protocol/openid-connect/token |
API_BASE_URL |
ERP API base URL |
Step 3 — Protect the endpoint¶
from backend.auth import require_service_principal
from backend.db import get_bare_db, set_system_rls_context
from backend.public_api.v1_payment_requests import _setup_org_context
@router.post("/invoices")
def ingest_invoice(
body: InvoiceBody,
db: Session = Depends(get_bare_db),
current_principal: dict = Depends(require_service_principal()),
):
active_org_id = _setup_org_context(
db, current_principal, body.organization_short_code, "payables.invoices.create"
)
...
The permission key must already exist in rbac_permissions (seeded via
backend/rbac/catalog.py → seed_permissions()). Use existing keys from
ROUTE_PERMISSION_RULES; do not invent new ones.