AcceptedDecided
ADR 0012: First-boot provisioning + OIDC group → role materialisation
Stand-up is the most under-served part of the operator experience. Today, an operator with a fresh PowerDNS-AuthAdmin install has to:
On this page
Date: 2026-05-18
Status#
Accepted.
Context#
Stand-up is the most under-served part of the operator experience. Today, an operator with a fresh PowerDNS-AuthAdmin install has to:
- Run
docker compose up. - Sign in as the bootstrap admin from
BOOTSTRAP_ADMIN_*env. - Click through the admin UI to add their OIDC provider.
- Click through to add each PDNS backend (primary + every secondary).
- Click through to add zone templates.
- Set runtime settings (brand, site name, lockout policy).
- Manually assign roles to every user as they trickle in via OIDC, since we have no group→role mapping today.
Steps 3–7 are repeatable across every fresh install of an org - they're declarative configuration, not per-instance state. They belong in source control next to the rest of the deployment manifest.
The certifi project (sister project, same operator) shipped a
provisioning.example.yaml for the same shape of problem. It works well
in production. PowerDNS-AuthAdmin can adopt the same pattern wholesale with
local adaptations for the bigger surface area (PDNS primaries+secondaries,
multi-provider OIDC, zone templates).
Decision#
Provisioning file#
PROVISIONING_FILE env points at a YAML file that is read and applied on
first boot. The applier is idempotent:
- Every entry is upserted on its slug (or KV key for settings).
- A
provisioned_atrow is written to thesettingstable after a successful apply. Subsequent restarts see the sentinel and skip the file. - An operator force-re-applies by deleting the sentinel row and restarting.
The file is parsed by Zod with .strict() on every section - typos in
top-level keys or section entries abort startup with a precise error rather
than silently becoming a no-op.
Run trigger: docker/entrypoint.mjs calls node scripts/provision.js after
migrations and before launching the Next.js server. PROVISION_ON_BOOT=false
opts out (operators using an out-of-band provisioning workflow).
Sections covered#
settings- KV writes against the known-keys vocabulary inlib/validators/settings.ts.roles- custom roles only; system roles stay inscripts/seed.ts.teams- teams as DB rows. Members come from OIDC group mappings, not the YAML - keeping plaintext-password users out of the file is a deliberate scope choice.zone_templates- applied to new zones at create time.pdns_servers- primaries + secondaries. Secondaries reference their primary by slug; resolution happens at apply time.oidc- providers + their group→role mappings.
OIDC group → role materialisation#
Two schema additions (single migration each for PG + SQLite):
oidc_providers.group_mappings- JSON array of{ group, roleSlug, scopeType, scopeId }. Provisioning resolves team/server slug references to ids at write time; zone scope is a literal name (no FK).role_assignments.provider_id- nullable FK tooidc_providers,ON DELETE SET NULL. Set ONLY by the OIDC sign-in materialiser; admin-issued assignments stay NULL.
At every successful OIDC sign-in (app/api/auth/oidc/[provider]/callback):
- Read the user's groups claim (
claim_groups, default"groups"). - Filter
provider.groupMappingsto mappings whosegroupappears. - Resolve each filtered mapping to a
(roleId, scopeType, scopeId)tuple. Mappings whose role slug is unknown are skipped + audited (auth.oidc.group_sync.mapping_unresolved). - Load existing role assignments where
provider_id = provider.id. - Diff: ADD new tuples; REMOVE tuples no longer in the target set.
- Audit every ADD/REMOVE individually.
Properties:
- Admin-issued assignments are never touched. They have
provider_id IS NULLand the diff query filters byprovider_id = provider.id. - Group-membership churn is reflected on the next sign-in. A user removed from a group on the IdP loses their role on their next sign-in. Until then they keep the role - we don't push from the IdP.
- Failure is non-fatal. A group-sync failure logs + audits but doesn't block the sign-in itself - the user's identity is already verified at that point. An admin can reconcile manually.
Consequences#
Positive:
- A fresh PowerDNS-AuthAdmin install boots into a fully-configured state with one YAML committed alongside the rest of the deployment manifest.
- The same YAML can drive dev, staging, and prod environments by parameterising secrets and base URLs.
- OIDC group claims become a first-class authorisation surface without requiring an admin to click through user-by-user assignments.
Negative:
- Secrets live in the file verbatim. Operators must enforce file mode + ownership; for production the file is typically mounted from a secret manager (Kubernetes Secret, HashiCorp Vault, etc.).
- The
provisioned_atsentinel is the only deduplication gate. If the operator alters the file post-provision and wants the changes re-applied, they must delete the sentinel manually. The applier prints a clear message when it skips; the alternative ("always apply") would surprise operators using the admin UI as the source of truth. - Custom roles whose slug shadows a system slug (e.g. someone writes
super-admininroles:) silently override the seeded definition. The applier accepts this; a future check could refuse system slugs to prevent accidents.
Notes for operators#
- Mount the file read-only. Compose example:
./provisioning.yaml:/etc/powerdns-authadmin/provisioning.yaml:ro. - Re-provision:
DELETE FROM settings WHERE key='provisioned_at';then restart. - Disable on boot:
PROVISION_ON_BOOT=false+ a manualnpm run provisionin CI/CD. - Group mappings only fire for DB-source OIDC providers. The env
fallback provider (
OIDC_*env vars) doesn't support group mappings; switch to a DB-source provider via this file if you need them.