ADR-030: Client-role sync — scheduled re-sync via Granit.BackgroundJobs
Date: 2026-04-23 Authors: Jean-Francois Meyers Scope:
Granit.Identity.Federated.Keycloak.BackgroundJobs,Granit.Identity.Federated.EntraId.BackgroundJobs,Granit.Identity.Federated.Cognito.BackgroundJobs
Context
Section titled “Context”Phase 2 (ADR-025 / ADR-026 / ADR-027) ships a boot-time client-role sync:
an IHostDataSeedContributor that runs once per host restart. In
steady-state production, hosts restart rarely — which means drift between
the upstream provider and RoleMetadata can accumulate for hours or days
before the next restart catches it. ADR-029 added orphan handling but
didn’t change when the sync runs.
Phase 3 closes the cadence gap with a recurring job per provider.
Decision
Section titled “Decision”Per-provider sub-project, one job each
Section titled “Per-provider sub-project, one job each”Following the Granit convention for background jobs
(src/Granit.{Module}.BackgroundJobs/), each federated provider gets a
dedicated sub-package:
Granit.Identity.Federated.Keycloak.BackgroundJobs→KeycloakClientRoleSyncJob+ handler, job namekeycloak-client-role-sync.Granit.Identity.Federated.EntraId.BackgroundJobs→EntraIdClientRoleSyncJob+ handler, job nameentraid-client-role-sync.Granit.Identity.Federated.Cognito.BackgroundJobs→CognitoClientRoleSyncJob+ handler, job namecognito-client-role-sync.
Each sub-project depends on Granit.BackgroundJobs and on the provider
package. Hosts that don’t need scheduled re-sync simply don’t reference
the sub-package — no dead code ships with the base provider.
Default cadence: every 15 minutes
Section titled “Default cadence: every 15 minutes”[RecurringJob("*/15 * * * *", ...)] on each job. Rationale:
- Long enough to batch legitimate role changes by an admin without hammering the provider’s admin API or Graph.
- Short enough to catch drift within one SLA-friendly window (most monitoring / on-call dashboards bucket in 5–15 min intervals).
- Matches the cadence the Phase 2 plan proposed without further study.
The cron expression is hard-coded in the attribute per Granit’s
RecurringJobDiscovery reflection pattern. Hosts needing a different
cadence fork the attribute or override the
RecurringJobRegistration at DI registration time — documented in the
per-package README but not a first-class option surface in this ADR. If
demand materializes, a follow-up could introduce per-provider
Schedule option support.
Additive to the boot-time contributor
Section titled “Additive to the boot-time contributor”Both the boot-time *ClientRoleSyncContributor and the recurring
*ClientRoleSyncJob delegate to the same
*ClientRoleSyncService.SyncAsync. Consequences:
- No duplicated sync logic.
- Orphan-policy dispatch (ADR-029), idempotent upsert semantics, and restore-on-return all inherit automatically.
- Hosts opting out of scheduled re-sync keep the Phase 2 behaviour unchanged; the sub-package registration is additive.
Public promotion of the sync service
Section titled “Public promotion of the sync service”The sync service (*ClientRoleSyncService) was internal in Phase 2
(lived in Internal/Sync/). To satisfy the Wolverine handler
convention (public handler with public static HandleAsync), the
service parameter must be publicly visible — public methods cannot
take internal parameters (CS0051). Three options were considered:
- Make the service public and move it out of
Internal/Sync/(chosen). Namespace becomesGranit.Identity.Federated.*.Sync. Architecture conventionPublic_types_should_not_reside_in_Internal_namespacesstays clean. - Make the handler internal — blocked by CLAUDE.md: Wolverine discovers
handlers via
Assembly.ExportedTypeswhich requirespublic. - Introduce a wrapper interface (
IClientRoleSyncDispatcher) in the provider package and have the handler take that — extra indirection with no consumer need.
Option 1 is the smallest surface expansion and keeps the code path
identical. The service is meant to be DI-resolvable from downstream
BackgroundJobs packages, so public is the honest accessibility.
The contributors stay internal (they’re DI-registered by type, never resolved directly by callers).
Per-provider sub-package is registered explicitly
Section titled “Per-provider sub-package is registered explicitly”Hosts opt in by referencing the sub-package and registering the module:
services.AddGranitIdentityKeycloak(); // Phase 2// in the module registration graph:// GranitIdentityFederatedKeycloakBackgroundJobsModule[RecurringJob] attribute discovery picks up the job at startup via
RecurringJobDiscovery.Discover(...) which scans assemblies for types
carrying the attribute. No explicit services.AddJob<T>() call is
needed.
Enabled flag is shared
Section titled “Enabled flag is shared”The existing *ClientRoleSyncOptions.Enabled governs both the
boot-time and recurring paths. Rationale:
- One switch for “I want client-role sync on this provider” is the correct user-facing semantic.
- Separate on/off for boot-time vs recurring is a distinction without
a meaningful use case — if you want no sync, you turn it off
everywhere; if you want only boot-time, you don’t reference the
.BackgroundJobssub-package.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Drift between provider and
RoleMetadatais now bounded by 15 minutes instead of host restart cadence. - Orphan soft-delete / hard-delete (ADR-029) runs on the same schedule — admin-initiated role removals upstream flow through the configured policy within the same 15-minute window.
- The sub-package opt-in means hosts with CI-tight deploys (hot rolling restarts, GitOps drift-aware) can skip scheduling entirely.
Negative
Section titled “Negative”- 3 new NuGet packages. Each is small (1 job + 1 handler + 1 module class); the footprint is the release train, not the code.
- The cron cadence is not runtime-configurable — host ops need to
accept the 15-minute default or fork the attribute. Acceptable for
v1;
Scheduleoption support is a fast-follow candidate if demand materializes. - Sync services promoted to
public. Minor API surface expansion; they were always the expected DI resolution target for downstream jobs, so this is more a formalization than a new commitment.
Neutral
Section titled “Neutral”- In a multi-replica deployment, each replica runs its own copy of
the recurring job every 15 minutes. The sync is idempotent (unique
index + find-then-upsert), so concurrent runs are safe but wasteful.
A future
[Singleton]-style scheduling hint (orGranit.BackgroundJobs.Wolverineleader-election) can collapse to single-replica execution — tracked as a follow-up, not a blocker.
Rollout
Section titled “Rollout”- This PR (#1120): 3
.BackgroundJobssub-packages + tests + ADR. Default stays “not referenced” — zero behaviour change for existing consumers. - Follow-up (opt-in runtime schedule): expose
Scheduleon each*ClientRoleSyncOptionsif ops teams ask for it. - Follow-up (single-replica scheduling): collaborate with
Granit.BackgroundJobs.Wolverineleader-election to avoid duplicate executions across replicas.
References
Section titled “References”- ADR-025 / ADR-026 / ADR-027 — Phase 2 boot-time client-role sync.
- ADR-029 — Orphan cleanup policy (layered on top, inherited transparently by the recurring path).
- #1114 — RoleMetadata Phase 2b / 3 epic.
- #1120 — this ADR’s implementing PR (story).