ADR-025: Keycloak client-role distinction and boot-time sync
Date: 2026-04-22 Authors: Jean-Francois Meyers Scope:
Granit.Identity,Granit.Identity.Federated.Keycloak
Context
Section titled “Context”Phase 1 introduced RoleMetadata.ClientId (nullable, indexed with
(Name, TenantId, ClientId) unique under NULLS NOT DISTINCT) reserved
for client-scope roles from federated providers. No provider populated the
field until now — the store treated every role as realm-scope.
Keycloak supports two native role families:
- Realm roles (global inside the realm) — already surfaced by Granit’s
IIdentityRoleManagerviaGET /admin/realms/{realm}/roles. - Client roles (scoped to a specific OIDC client inside the realm) — this ADR adds them.
The abstraction introduced here is the template for the Entra ID app roles (#1099) and Cognito app-client-bound groups (#1100) follow-ups.
Decision
Section titled “Decision”IIdentityClientRoleManager — a separate capability interface
Section titled “IIdentityClientRoleManager — a separate capability interface”Rather than extending the 5-method IIdentityRoleManager (inherited by
IIdentityProvider and implemented by 4 other providers today), a new
capability interface IIdentityClientRoleManager is added alongside it.
Only providers that support the concept natively implement it; AspNet Identity
— which has no analog — does not, and stays untouched. The interface is not
inherited by IIdentityProvider.
Shape:
Task<IReadOnlyList<string>> GetClientsAsync(CancellationToken);Task<IReadOnlyList<IdentityRole>> GetClientRolesAsync(string clientId, CancellationToken);Task<IReadOnlyList<IdentityRole>> GetUserClientRolesAsync(string userId, string clientId, CancellationToken);IIdentityProviderCapabilities.SupportsClientRoles surfaces the capability as
a DIM (default false). Keycloak overrides to true. Consumers can guard their
logic without reflecting on service registrations.
Additive ClientId on IdentityRole
Section titled “Additive ClientId on IdentityRole”IdentityRole gains a string? ClientId { get; init; } non-positional
property. Positional constructor stays (Id, Name, Description) so
existing callers and positional deconstruction are unchanged. Client-scope
results carry the client id; realm results leave it null.
Sync pipeline — IHostDataSeedContributor
Section titled “Sync pipeline — IHostDataSeedContributor”KeycloakClientRoleSyncContributor implements IHostDataSeedContributor and
runs KeycloakClientRoleSyncService.SyncAsync at host boot. Mirrors the
IdentityLocalRoleSeedContributor precedent; runs once per host boot in a
tenantless scope.
Idempotent upsert via (Name, TenantId, ClientId) unique index:
FindByNameAsync(name, tenantId: null, clientId)→ not found →Create + AddAsync.- Found with description drift →
Rename+UpdateAsync(covers description-only change; client role name renames would collide with the unique index so Keycloak itself wouldn’t allow them). - Found unchanged → no-op.
No orphan deletion in this phase. A client role removed in Keycloak leaves
its RoleMetadata row intact — deleting it cascades onto PermissionGrant
rows and can silently strip permissions from active users. A future
RemoveOrphanedRoles flag can opt in once we have an event-driven
reconciliation or admin-triggered audit.
Configuration — KeycloakClientRoleSyncOptions
Section titled “Configuration — KeycloakClientRoleSyncOptions”Dedicated options class bound to KeycloakAdmin:ClientRoleSync:
{ "KeycloakAdmin": { "ClientRoleSync": { "Enabled": true, "TrackedClientIds": ["showcase-admin", "showcase-patient"] } }}Explicit opt-in on TrackedClientIds keeps the sync out of Keycloak’s
internal clients (admin-cli, security-admin-console, account, etc.)
which have no application relevance.
DI — single scoped instance shared across facets
Section titled “DI — single scoped instance shared across facets”Registered in AddGranitIdentityKeycloak:
services.AddIdentityProvider<KeycloakIdentityProvider>(); // scoped, existingservices.TryAddScoped<IIdentityClientRoleManager>(sp => sp.GetRequiredService<IIdentityProvider>() as IIdentityClientRoleManager ?? throw new InvalidOperationException("..."));Both interfaces resolve to the same scoped KeycloakIdentityProvider — locked
down by unit test IdentityProvider_And_ClientRoleManager_ResolveToSameScopedInstance.
Critical because KeycloakAdminTokenService (singleton) is shared and
doubling the provider instance would double the admin token acquisition and
split internal state.
Failure policy — log & skip
Section titled “Failure policy — log & skip”Sync never crashes host startup. Per-client failures:
- Keycloak unreachable (
HttpRequestException/TaskCanceledException) → log Warning, continue. - 403 Forbidden on
view-clients→ log Error with the requiredrealm-managementroles (view-clients,query-clients,view-realm), continue. KeycloakClientNotFoundException(tracked client id not in the realm) → log Warning, continue.
Next boot = natural retry. For production hardening a scheduled job or healthcheck-gated sync can follow in Phase 3.
Required Keycloak service-account roles
Section titled “Required Keycloak service-account roles”The admin client configured via KeycloakAdmin:ClientId / ClientSecret needs
the following realm-management roles:
view-clients(list + resolve client UUIDs)query-clients(filter byclientId)view-realm(read realm metadata)
Add from the Keycloak admin console: Clients → {admin-client} → Service Accounts Roles → Assign role → realm-management: pick the three above.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Non-breaking for every existing identity provider.
- Client roles are now first-class in the authorization engine:
IGranitRoleLookup.FindByNameAsync(name, clientId: "app-a")returns a distinct row, separate from any same-named realm role. - Abstraction shape is ready-made for Entra app roles (#1099) and Cognito app-client-bound groups (#1100).
Negative
Section titled “Negative”Granit.Identity.Federated.Keycloakgains project references toGranit.Authorization,Granit.Guids, andGranit.Persistence.EntityFrameworkCoreto host the sync service. Could be extracted to aGranit.Identity.Federated.Keycloak.Authorizationpackage later if the coupling becomes painful.- Duplicate role names across clients (
adminonapp-aAND onapp-b) coexist correctly in the data layer but appear identical in admin UIs until the frontend dropdown is updated to disambiguate (tracked as a frontend follow-up in the PR description).
References
Section titled “References”- #1082 — Phase 1
RoleMetadata+ClientIdreservation. - #1098 — this ADR’s implementing PR.
- ADR-023 —
IGranitRoleLookup.FindByNameAsync(name, clientId?)already accepts theclientIdparameter. - Keycloak Admin REST API — Clients