Ga naar inhoud

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 shared client_secret. Migration to private_key_jwt is 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/usersService 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_secretstore 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/usersService 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.pyseed_permissions()). Use existing keys from ROUTE_PERMISSION_RULES; do not invent new ones.