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.