Provisioning
The Keycloak authorization configuration is provisioned by the MigrationService - a background service that runs on application startup. It uses the Keycloak Admin REST API to create and update the realm, clients, roles, and authorization settings.
The MigrationService is the single source of truth for all Keycloak configuration. There is no manual setup required in the Keycloak admin console.
Migration Workflow
The KeycloakMigrator orchestrates seven steps:
| Step | Action |
|---|---|
| 1. Health check | Wait for Keycloak to be ready |
| 2. Realm | Ensure the voltimax realm exists |
| 3. Clients | Create or update OIDC clients |
| 4. Client UUID | Resolve the API client's internal UUID |
| 5. Roles | Create client roles on the API client |
| 6. Authorization | Import resources, scopes, policies, permissions |
| 7. Dev user | Create a dev user with admin role (development only) |
Steps are idempotent - running the migration multiple times produces the same result. This makes it safe to run on every deployment.
Realm Configuration
All configuration is defined in KeycloakRealmConfiguration, a static class that serves as the central definition for:
- Clients: OIDC client registrations (
iot-platform-api,iot-platform-scalar,iot-frontend,iot-platform-mcp) - Roles: Client roles on the API client (
platform-admin,platform-operator,platform-viewer,gateway-device) - Authorization settings: Resources, scopes, policies, and permissions as a
ResourceServerRepresentation
Clients
Four OIDC clients are registered on the voltimax realm: a confidential resource server for the Platform API (with Authorization Services enabled), and public PKCE clients for the Scalar docs UI, Vue frontend, and MCP server. Client definitions are in KeycloakRealmConfiguration.GetClients().
Protocol mappers
Public clients include two protocol mappers:
- realm-roles: Maps realm roles into the
rolesclaim in access and ID tokens - audience: Adds the API client as the
aud(audience) claim for token validation
Authorization Import
The most critical provisioning step is the authorization import (Step 6). This uses the KeycloakAuthorizationManager to call Keycloak's bulk import endpoint:
POST /admin/realms/{realm}/clients/{uuid}/authz/resource-server/importThe request body is a ResourceServerRepresentation containing the full authorization configuration: resources, scopes, policies, and permissions. This is built by KeycloakRealmConfiguration.GetAuthorizationSettings().
Why Bulk Import?
The import endpoint was chosen over individual CRUD operations for several reasons:
- Atomicity: All resources, scopes, policies, and permissions are created in a single operation. No partial states.
- Idempotency: Running the import again replaces the existing configuration rather than creating duplicates.
- Cross-references: Permissions reference both resources and policies by name. The import endpoint resolves these references automatically.
- Data model alignment: The
ResourceServerRepresentationmatches the Keycloak export format, making it easy to verify the configuration in the admin console.
KeycloakAuthorizationManager
The manager is a thin wrapper around the Kiota-generated Admin API client. It takes the realm name and the internal UUID of the API client (not the clientId string, which is resolved in Step 4) and calls the import endpoint.
Resource Definitions
Resources are created using the CreateResource helper:
CreateResource("gateways", "urn:voltimax:gateways", ["read", "write", "delete"])Each resource specifies:
- Name: Matches what the API uses in
RequireProtectedResource(name, scope) - Type: A URN-style identifier for categorization
- Scopes: The actions available on this resource
Policy Definitions
Role Policies
Role policies are type "role" and reference a client role:
CreateRolePolicy(
"platform-admin-policy",
"Grants access to users with platform-admin role",
"platform-admin")The role reference is stored as a JSON array in the policy's config:
[{ "id": "iot-platform-api/platform-admin", "required": true }]Scope Permissions
Scope permissions are type "scope" and bind a resource + scope to policies:
CreateScopePermission(
"gateways-read", // permission name
"gateways", // resource
"read", // scope
["platform-admin-policy", "platform-operator-policy",
"platform-viewer-policy", "gateway-device-policy"])The decision strategy for scope permissions is AFFIRMATIVE - if any of the listed policies grants access, the permission is satisfied.
Aspire Integration
The .NET Aspire AppHost wires Keycloak connection settings to both the MigrationService and Platform API via environment variables. The MigrationService connects to the master realm with admin credentials to perform provisioning. The Platform API connects to the voltimax realm as the iot-platform-api client for authorization evaluation.
In local development, Keycloak runs as a container with a dedicated PostgreSQL instance. The client secret is auto-generated by Aspire and shared between the MigrationService (which sets it on the client) and the Platform API (which uses it for authorization requests).
Adding a New Resource
To add a new protected resource to the authorization model:
Add the resource in
GetAuthorizationResources():csharpCreateResource("my-resource", "urn:voltimax:my-resource", ["read", "write"])Add scope permissions in
GetAuthorizationPolicies():csharpCreateScopePermission("my-resource-read", "my-resource", "read", ["platform-admin-policy", "platform-operator-policy", "platform-viewer-policy"]), CreateScopePermission("my-resource-write", "my-resource", "write", ["platform-admin-policy", "platform-operator-policy"])Deploy - the MigrationService will import the updated configuration on next startup
Match in API - add corresponding
Permissionconstants and apply to endpoints. See API Protection.