Articles · Operations & Administration
Config as code
Try out the running example
Identity configuration drifts worse than infrastructure: a client secret rotated in one environment, a role edited in the admin UI at midnight, a redirect URI that exists only in staging. The infrastructure world solved this with declarative files and idempotent apply. Sentinel does the same for realms, orgs, roles and clients — with one deliberate refusal you’ll want to understand.
The file
version: 1
realms:
- key: clinic # natural key — stable, never rename
displayName: Clinic
isDefault: true
organizations:
- key: lakeside
displayName: Lakeside Clinic
domains:
- domain: lakeside.example
kind: emailDomain
verified: true
roles:
- key: support # realm-level: no `organization` key
displayName: Support engineer
grants:
- pattern: "sentinel:org:view"
- key: org-admin
displayName: Organization administrator
organization: lakeside # org-local role
grants:
- pattern: "sentinel:org:manage"
- pattern: "sentinel:org:impersonate"
oidcClients:
- clientId: clinic-spa
type: public # PKCE-only, no secret
firstParty: true
redirectUris: [https://app.clinic.example/callback]
allowedScopes: [openid, profile, email]
- clientId: partner-portal
type: confidential
secretRef: CLINIC_SECRET_PARTNER_PORTAL # env var NAME, not a value
requireConsent: true
redirectUris: [https://portal.partner.example/oidc/callback]
allowedScopes: [openid, profile]
accessTokenLifetime: "00:05:00"
The same schema also declares identity providers, SAML connections,
workload trusts and webhooks. Grant patterns
are validated against the permission grammar
before anything applies. And the parser is strict: a typo’d section (reallms:)
throws DeclarativeConfigParseException instead of silently configuring nothing —
in YAML and JSON alike (A_typo_fails_parsing_instead_of_configuring_nothing).
Secrets never live in the file. secretRef names an environment variable resolved at
apply time (ISecretResolver is replaceable if yours live elsewhere). An unresolvable
ref is a warning that skips that entry — it never blanks a stored secret, and a
confidential client is still created, secretless and unable to authenticate until you
set one: fail-closed, not fail-broken.
Apply, idempotently
services.AddSentinelDeclarativeConfig(); // after your store registrations
var declared = SentinelConfigParser.ParseYaml(ConfigAsCodeComposition.LoadDeclaredYaml());
var report = await applier.ApplyAsync(declared, dryRun: false);
if (report.HasErrors)
{
// Fail-closed: booting with silently unapplied config is how drift starts.
throw new InvalidOperationException("Declarative config apply reported errors:\n "
+ string.Join("\n ", report.Entries.Where(e => e.Kind == ConfigChangeKind.Error)));
}
The applier writes through the same public store ports the admin API uses — IAdminStore,
IOidcStore, and friends. Everything is matched by natural key: realm/org/role keys,
clientId, a domain’s (org, value). Missing → Create. Drifted → Update, with the
changed fields named in the entry detail ("allowedScopes", "displayName 'A' → 'B'",
"secret" — drift in a hashed client secret is detected by verifying the resolved value
against the stored hash, so the plaintext is never persisted for comparison). Same →
Unchanged. Re-applying an unchanged file is a strict no-op:
(await applier.ApplyAsync(declared)).IsNoOp // true — the signal boot-time apply relies on
Role grants reconcile additively, and dryRun: true produces the full report with zero
writes — preview the diff in CI before the deploy applies it.
The refusal
Delete a client from the file and re-apply: nothing happens. Absence is not deletion — a truncated file or a bad merge must never be able to take down your login. If you want to see what’s dangling, opt a section into prune reporting:
prune:
oidcClients: true
WouldPrune clinic/oidcClient/partner-portal — prune requested — v1 refuses to
auto-delete (absence != deletion); remove via the admin API
The entry is reported, the store untouched, and IsNoOp still holds — prune reporting
is information, not action (Prune_is_reported_and_refused_never_deleted). Actual
deletion stays a deliberate, audited admin-API operation.
The reference server: file in, invitation out
The Sentinel server container runs this exact flow. Point
SENTINEL_CONFIG at your file and boot: schema first, then the declarative apply
(fail-closed, every non-Unchanged entry logged), then — on a first run — bootstrap:
the realm your file declared, a built-in realm-admin role, and a credential-less admin
user for SENTINEL_BOOTSTRAP_ADMIN_EMAIL. Then stdout gets the one secret worth
printing:
============================================================
SENTINEL FIRST-RUN BOOTSTRAP
realm : clinic
admin : admin@example.com
INVITATION LINK (single-use, expires 2026-08-21T09:00:00Z):
POST https://id.example.com/auth/invitation/accept
{ "token": "…", "password": "<choose one>" }
============================================================
No generated password ever touches stdout or a log pipeline — the invitation is a signed, single-use token; accepting it is where the admin sets their own first password and verifies the email. Replaying it loses. From there, every subsequent boot is just the idempotent apply: the file is the environment.
Config-as-code covers the declarable plane — realms, roles, clients, trusts. Users, sessions and grants to people remain runtime data, born from logins, imports and the admin API. The line is deliberate: if it belongs in git, it’s in the file; if it belongs to a person, it isn’t.