provisioning.example.yaml
Rama main · réplica de lectura del repositorio original.
# =============================================================================
# PowerDNS-AuthAdmin - exhaustive first-boot provisioning template
# =============================================================================
#
# This file is the canonical "every setting at a glance" reference for the
# YAML-driven IaC layer. Copy it, comment out what you don't need, and point
# `PROVISIONING_FILE=/path/to/your.yaml` at it before first boot.
#
# How the applier runs:
#
# 1. On startup the app calls `applyProvisioning()` if `PROVISIONING_FILE`
# is set AND the `settings.provisioned_at` row is absent.
# 2. Each top-level block is processed in order: settings → roles → teams
# → zone_templates → clusters → pdns_servers → oidc → saml → ldap
# → demo_zones. Inter-block references resolve by slug (e.g. an oidc
# group_mapping pointing to a role defined a few blocks up just works).
# 3. On success the applier writes `settings.provisioned_at = <ISO ts>`.
# Subsequent boots skip the file - the admin UI is the source of truth
# from that point on. To force a re-apply on a running database:
# # Postgres
# psql "$DATABASE_URL" -c "DELETE FROM settings WHERE key='provisioned_at';"
# # SQLite
# sqlite3 /data/powerdns_authadmin.db \
# "DELETE FROM settings WHERE key='provisioned_at';"
# 4. Parse errors abort startup. The app refuses to boot rather than
# half-apply a malformed file - fail loud, fix the YAML, retry.
#
# Secrets policy:
#
# API keys (PDNS) and client secrets (OIDC) live in this file in plaintext
# and are encrypted at rest with `APP_ENCRYPTION_KEY` before they hit the
# database. Treat this file as sensitive: chmod 600, owner = the app's
# runtime user, mounted read-only in containers. Prefer mounting from a
# secret store (Docker secrets, K8s Secret, HashiCorp Vault).
#
# Every top-level section is optional. Drop the blocks you don't need.
# Unknown keys at any level are rejected at parse time - typos fail fast
# instead of silently becoming no-ops.
# =============================================================================
# -----------------------------------------------------------------------------
# version (optional)
# -----------------------------------------------------------------------------
# When set, the applier refuses to run unless the app's reported version
# starts with this prefix. Belt-and-braces for "we changed the schema, your
# IaC is now stale." Leave unset to opt out.
# -----------------------------------------------------------------------------
# version: "0.1"
# -----------------------------------------------------------------------------
# settings - global key/value writes to the `settings` table
# -----------------------------------------------------------------------------
# Every key here mirrors the admin Settings page (see
# lib/validators/settings.ts → KNOWN_SETTING_KEYS). Unknown keys reject at
# parse time. Each key is independently optional.
# -----------------------------------------------------------------------------
settings:
# Plain-text site name. Shown in the nav and the login page <title>.
site_name: "My Org DNS"
# Optional brand logo. Accepts an absolute https:// URL OR an inline
# `data:image/...` URI. Inline data: is convenient but rows show up in
# audit snapshots - keep it under ~1.5 MB or use an external URL.
# brand_logo_url: "https://example.com/static/logo.png"
# Support contact line shown on the login page footer + 5xx error pages.
support_contact: "ops@example.com"
# Optional intro paragraph shown above the sign-in form. Use it to
# tell the operator which SSO provider to choose, or to call out
# an outage. Max 2000 chars.
login_intro: "Sign in with your company SSO. Reach out in #dns-ops for access."
# Failed-login lockout policy. Defaults: 5 attempts in 900 seconds → 15-min
# lockout. Both fields are independently tunable.
login_lockout_threshold: 5 # int 1..100 - attempts before lockout
login_lockout_seconds: 900 # int 60..86400 - lockout duration
# Self-service password reset (the login page "Forgot password?" link).
# Default true. Set false to hide the link and make the endpoint a no-op -
# local accounts only; SSO users always reset through their IdP.
allow_password_reset: true
# TTL pre-filled when adding a record or a zone-template row. Default 3600.
# A zone's X-AUTHADMIN-DEFAULT-TTL metadata overrides it for that zone.
default_record_ttl: 300 # int 1..2147483647
# Keep PTR records in hosted reverse zones in sync when A/AAAA records are
# saved or deleted. Default false. A zone's X-AUTHADMIN-AUTO-PTR metadata
# (on / off) overrides it; the record editor offers a per-save checkbox.
# Reverse zones are never created and a PTR pointing at another name is
# never replaced without an explicit choice.
auto_ptr: false
# Default sign-in method. Decides which provider /login auto-redirects to
# on a fresh visit. Three accepted forms:
#
# - "local" - show the form (the default; recovery via /login?force-local=1).
# - bare slug, e.g. "company-sso" - resolved against the slugs reserved
# in this file or already in the DB. Slugs are globally unique across
# OIDC / SAML / LDAP, so a bare slug is unambiguous.
# - typed-prefix, e.g. "oidc:company-sso" - equivalent; useful when
# scripting against the DB directly.
#
# The applier resolves whatever form you write into the canonical
# typed-prefix form before persisting. Edited at runtime from
# /admin/authentication.
auth_default_provider: "local"
# Example bare-slug form (uncomment to make `company-sso` defined below
# the default - operators hitting /login auto-redirect through its initiate):
# auth_default_provider: "company-sso"
# -----------------------------------------------------------------------------
# roles - custom (org-specific) roles
# -----------------------------------------------------------------------------
# The 5 system roles (super-admin, team-owner, operator, zone-editor,
# read-only) are seeded by `db:seed` and CANNOT be redefined here - the
# applier will refuse a slug collision. Use this section only for the
# custom roles your org needs on top.
#
# `permissions` is a list of strings drawn from lib/rbac/permissions.ts.
# Unknown permission names are validated at sign-in (the CASL builder
# logs + skips them). The full vocabulary, grouped:
#
# Zones: zone.read, zone.create, zone.update, zone.delete,
# zone.export, zone.import,
# zone.settings.read (see the Zone settings tab; writing it
# is zone.update)
# Records: record.read, record.create, record.update, record.delete,
# record.update.apex-ns (EXTRA, on top of the matching
# record.* one, to write the apex NS -
# the zone's delegation. NS below the
# apex is an ordinary record.)
# SOA: soa.read (see the SOA tab), soa.update (edit it - this
# REPLACES the record.* requirement, it is not additive)
# DNSSEC: dnssec.read, dnssec.configure
# Metadata: metadata.read, metadata.write
# TSIG keys: tsig.read, tsig.manage (tsig.read = list-only; manage
# = create/regenerate/reveal)
# Autoprimary: autoprimary.manage
# Templates: template.use, template.manage
# Users: user.read, user.create, user.update, user.delete,
# user.disable, user.reset-password
# Teams: team.read, team.create, team.update, team.delete,
# team.manage-members
# Roles: role.read, role.create, role.update, role.delete,
# role.assign
# PDNS servers: server.read, server.create, server.update, server.delete
# API tokens: token.read.own, token.create.own, token.delete.own,
# token.read.all, token.delete.all
# Audit/settings: audit.read, settings.read, settings.write
# Auth IdPs: auth.read, auth.manage (OIDC, SAML, LDAP)
# System: system.backup
#
# Leaving soa.read / zone.settings.read off a role HIDES those tabs entirely -
# which is how you give a hosting customer their records without giving them
# the zone's authority. See docs/07-RBAC.md.
# -----------------------------------------------------------------------------
roles:
- slug: zone-noc # lowercase kebab-case
name: NOC Zone Operator
description: "Read everything; write only records inside zones the NOC owns."
requires_mfa: true # forces second-factor enrolment (TOTP or passkey) before any action
permissions:
- zone.read
- record.create
- record.update
- record.delete
- audit.read
- slug: billing-readonly
name: Billing (read-only)
description: "Auditor access for the finance team."
requires_mfa: false
permissions:
- zone.read
- audit.read
- server.read
# -----------------------------------------------------------------------------
# teams - multi-team RBAC unit
# -----------------------------------------------------------------------------
# A team is a named ownership boundary inside the app. Zones can be team-
# scoped (Phase 5); role assignments can target a team (`scope: team:<slug>`
# in oidc/group_mappings or in admin-issued assignments).
#
# Adding/removing members happens via the admin UI OR indirectly via OIDC
# group → role mappings (a `scope: team:<slug>` assignment counts as
# membership for visibility purposes).
# -----------------------------------------------------------------------------
teams:
- slug: noc # lowercase kebab-case
name: Network Operations
description: "On-call DNS team."
contact: "NOC Lead <noc-lead@example.com>"
mail: "noc@example.com"
- slug: corp-it
name: Corporate IT
description: "Internal IT services."
contact: "corp-it-lead@example.com"
# `mail` is optional; omitted here on purpose to show it's not required.
# -----------------------------------------------------------------------------
# zone_templates - scaffolding for new zones
# -----------------------------------------------------------------------------
# Applied at zone-creation time. The create-zone form preselects a template
# when the operator picks a backend listed in `default_for_primary_slugs`.
# Templates carry three layers of state, applied in this order:
#
# 1. PDNS createZone() - wire-format kind (Master/Slave/Native), seed
# NS records, seed SOA via the soa_* timers.
# 2. PUT /zones/<id> - zone-object settings (soa_edit, soa_edit_api,
# api_rectify). See the PDNS docs for what each accepts.
# 3. PUT /zones/<id>/metadata/<kind> - per-kind metadata bag.
#
# Failures in steps 2 + 3 are logged but don't abort the create - the zone
# exists; the operator can fix metadata later from the Zone settings tab.
# -----------------------------------------------------------------------------
zone_templates:
- slug: internal # lowercase kebab-case
name: Internal corp zones
description: "Default scaffolding for internal-facing zones (corp.example, etc.)"
# ------- SOA timers (seconds) -------
# Defaults shown match the schema's. Cap is min 60 / max int32 except
# soa_expire which has a min of 3600.
soa_ttl: 3600 # TTL on the SOA record itself
soa_refresh: 3600 # secondary refresh interval
soa_retry: 900 # secondary retry on refresh failure
soa_expire: 604800 # secondary expiry if primary unreachable
soa_minimum: 3600 # negative-cache TTL (RFC 2308)
# ------- Wire-format zone kind -------
# PDNS still uses Master/Slave on the wire even though the modern docs
# call them Primary/Secondary. The applier accepts either spelling.
# Native means "no AXFR/IXFR to secondaries; stored locally only."
# Master/Primary = source of truth; Slave/Secondary = read mirror.
kind: Native # Native | Master | Slave | Primary | Secondary
# ------- Seeded NS records -------
# Two is the RFC 2182 recommendation. List MUST be fully-qualified
# (trailing dot). For Primary+Secondaries topologies, every Secondary's
# supermaster-registered hostname (SELF_NS) MUST appear here so PDNS'
# auto-secondary verification accepts the NOTIFY.
nameservers:
- ns1.internal.example.
- ns2.internal.example.
# ------- Seeded prelude records -------
# Applied alongside NS + SOA at create. `name` is relative to the apex
# ("@" = apex, "www" = www.<zone>). `type` is the record type; `ttl`
# in seconds; `content` is the wire-format RDATA. `disabled: true`
# creates the record in disabled state.
records:
- { name: "@", type: TXT, ttl: 3600, content: '"v=spf1 -all"' }
- {
name: _dmarc,
type: TXT,
ttl: 3600,
content: '"v=DMARC1; p=reject; rua=mailto:dmarc-reports@example.com"',
}
- { name: www, type: CNAME, ttl: 3600, content: "@" }
- { name: legacy, type: A, ttl: 300, content: "192.0.2.10", disabled: true }
# ------- Zone-object settings (PDNS PUT /zones/<id>) -------
# All optional. Omit a field to leave PDNS's default in place.
#
# soa_edit / soa_edit_api: SOA serial mutation policies. The most
# common combo is `INCEPTION-INCREMENT` + `DEFAULT` which keeps the
# serial monotonic across API edits. See:
# https://doc.powerdns.com/authoritative/dnssec/operational.html#soa-edit-api
soa_edit: INCEPTION-INCREMENT
soa_edit_api: DEFAULT
# api_rectify: when true, PDNS rebuilds NSEC/NSEC3 + ordername after
# every API edit. Required for DNSSEC zones; harmless for non-DNSSEC.
api_rectify: true
# ------- Per-kind metadata bag (PDNS PUT /zones/<id>/metadata/<kind>) -------
# Free-form map: each key is a metadata kind; each value is a list of
# strings. Allowed kinds vary across PDNS versions - individual PUT
# failures are logged but never abort the apply.
metadata:
ALLOW-AXFR-FROM:
- "10.0.0.0/8"
ALSO-NOTIFY:
- "192.0.2.53:53"
# Examples of other commonly-used kinds. PDNS accepts most as
# multi-valued lists; some (TSIG-ALLOW-AXFR) accept multiple keys
# by listing each on its own line.
# NSEC3PARAM: ["1 0 5 abcdef"]
# PRESIGNED: ["1"]
# API-RECTIFY: ["1"] # equivalent to api_rectify above
# TSIG-ALLOW-AXFR: ["zone-transfer-key"]
# ------- Auto-select on backend pick -------
# When the create-zone form's backend picker resolves to one of these
# primary slugs, this template is preselected. List the primaries
# whose default zone-shape this template represents.
default_for_primary_slugs:
- primary-internal
- slug: external
name: External / customer-facing zones
description: "Public DNS - longer SOA timers, 3 NS, no metadata bag."
soa_ttl: 86400
soa_refresh: 86400
soa_retry: 7200
soa_expire: 1209600
soa_minimum: 3600
kind: Master # legacy spelling accepted; "Primary" is equivalent
nameservers:
- ns1.example.com.
- ns2.example.com.
- ns3.example.com.
records: [] # empty list ⇒ no prelude records
metadata: {} # empty map ⇒ no metadata writes
default_for_primary_slugs:
- primary-public
# -----------------------------------------------------------------------------
# clusters - multi-primary peer groups
# -----------------------------------------------------------------------------
# A cluster is N writable PDNS peers backed by a replicated store (Galera,
# Postgres logical replication, etc.). The cluster appears as ONE logical
# backend in every operator-facing picker; the peer-selection strategy
# (UI label) picks which peer to read from / write to per request.
#
# A "group" (cluster row) relates backends that replicate together - the
# writable peers of a multi-primary cluster, OR a primary together with its
# secondaries (ADR-0014). Any role may join a group via `cluster_slug`.
# -----------------------------------------------------------------------------
clusters:
- slug: prod-cluster # lowercase kebab-case
name: "Production cluster (3 peers)"
description: "Three writable peers sharing one Galera-replicated MariaDB."
# Strategy options (column name is `write_strategy`; the UI calls it
# "Peer selection strategy"):
#
# round_robin Spread requests across peers in order. The default.
# random Uniform random choice per request.
# lowest_latency Use the peer with the lowest p50 from the sampler.
# Falls back to round_robin until the sampler has
# data.
# least_load Use the peer with the fewest zones from the sampler.
# Useful when peers are unevenly sized.
write_strategy: round_robin
# A primary + its secondaries also form a group. The single writable Primary
# is the group's peer; the Secondaries ride along as read-only members.
- slug: public-group
name: "Public group (primary + 2 secondaries)"
description: "One writable Primary mirrored by two read-only Secondaries."
# -----------------------------------------------------------------------------
# pdns_servers - the PDNS Authoritative backends
# -----------------------------------------------------------------------------
# Primaries are full read/write backends. Secondaries are read-only mirrors
# we poll for sync state + stats. Cluster peers are primaries that share a
# replicated store (see clusters: above).
#
# Cross-row rules enforced at parse:
# • cluster_slug → optional on ANY role; must resolve to a cluster defined
# in this file or already in the DB. Group a primary with
# its secondaries, or the peers of a multi-primary cluster.
# • is_default → only a primary may be the default; set on EXACTLY ONE
# row (the applier doesn't enforce uniqueness; the admin
# UI does). A `write_mode: read_only` row can't be it.
# • write_mode → "auto" (default) trusts the daemon's observed /config;
# "read_only" vetoes ALL writes to this backend. Use it for
# a node fed by database replication whose DB user has no
# write access - PowerDNS reports such a daemon as a plain
# standalone primary, so nothing else can detect it.
# • monitor_accept_single_ns → optional; true accepts single-nameserver zones
# on a standalone backend (zone monitoring then reports
# them as accepted information, not a warning). Ignored
# with a cluster_slug. Omit to leave the UI's choice alone.
#
# `api_key` is the PDNS X-API-Key in plaintext; encrypted with
# APP_ENCRYPTION_KEY before persisting. `base_url` must point at the PDNS
# HTTP API path (`/api/v1`). The SSRF guard (APP_PDNS_ALLOW_PRIVATE_NETWORKS,
# APP_PDNS_ALLOW_INSECURE_HTTP) gates which URLs are accepted at runtime.
# -----------------------------------------------------------------------------
pdns_servers:
# --- Standalone primary (no cluster, no secondaries) ---
- slug: primary-internal # lowercase kebab-case
name: "Internal primary (Sydney)"
description: "Authoritative for internal.example. and corp.example."
base_url: "http://pdns-internal.svc:8081/api/v1" # https:// required in prod unless APP_PDNS_ALLOW_INSECURE_HTTP=true
server_id: localhost # PDNS' internal server-id (almost always "localhost")
api_key: "REPLACE_ME_internal_pdns_api_key"
is_default: true # exactly ONE row in the file should be default
# --- Primary with three Secondaries ---
# The Secondaries auto-bootstrap via PDNS supermaster: on a NOTIFY from
# the primary they create the zone and AXFR it, provided each zone's NS
# list contains the receiving Secondary's `supermasters.nameserver` value.
- slug: primary-public
name: "Public primary (Sydney)"
description: "Authoritative for example.com and customer zones."
base_url: "https://pdns-public.example.com/api/v1"
server_id: localhost
api_key: "REPLACE_ME_public_pdns_api_key"
is_default: false
cluster_slug: public-group # group this primary with its secondaries below
- slug: secondary-public-eu
name: "Public secondary (Frankfurt)"
description: "Read-only mirror of primary-public."
base_url: "https://pdns-fra.example.com/api/v1"
server_id: localhost
api_key: "REPLACE_ME_fra_secondary_api_key"
cluster_slug: public-group # join the same group as the primary it mirrors
- slug: secondary-public-us
name: "Public secondary (Ashburn)"
base_url: "https://pdns-iad.example.com/api/v1"
server_id: localhost
api_key: "REPLACE_ME_iad_secondary_api_key"
cluster_slug: public-group
# --- Cluster peers (members of prod-cluster) ---
# Every peer in a cluster shares the same backend store, so they all see
# the same zones + records. The app reads/writes through one peer per
# request (chosen by the cluster's peer-selection strategy). All cluster
# members are role=primary; cluster_slug binds them to the cluster row.
- slug: peer-1
name: "Cluster peer 1 (Sydney AZ-A)"
base_url: "http://pdns-peer-1:8081/api/v1"
server_id: localhost
api_key: "REPLACE_ME_peer_1_api_key"
cluster_slug: prod-cluster # must match a cluster defined above
- slug: peer-2
name: "Cluster peer 2 (Sydney AZ-B)"
base_url: "http://pdns-peer-2:8081/api/v1"
server_id: localhost
api_key: "REPLACE_ME_peer_2_api_key"
cluster_slug: prod-cluster
- slug: peer-3
name: "Cluster peer 3 (Sydney AZ-C)"
base_url: "http://pdns-peer-3:8081/api/v1"
server_id: localhost
api_key: "REPLACE_ME_peer_3_api_key"
cluster_slug: prod-cluster
# -----------------------------------------------------------------------------
# demo_zones - generated demo zones (optional)
# -----------------------------------------------------------------------------
# Each entry produces N synthetic zones with realistic record content via
# the PDNS HTTP API. Useful for showcasing the UI on a fresh stack;
# disable in production by removing this whole block.
#
# Exactly one of `target_server` / `target_cluster` must be set per entry.
# Cluster targets route through the cluster's peer-selection strategy
# (round_robin spreads the N zones across the peers).
# -----------------------------------------------------------------------------
# demo_zones:
# - target_server: primary-internal # OR target_cluster: prod-cluster (not both)
# count: 10 # number of zones to create (1..500)
# records_per_zone: 10 # records per zone, excluding NS+SOA (1..100)
# name_prefix: corp # zones are <prefix>-N.<base_domain>.
# base_domain: example # apex of the generated zones (default "demo")
# kind: Master # Native | Master | Primary
# nameservers: # optional; defaults to ns1.<base>./ns2.<base>.
# - ns1.internal.example.
# - ns2.internal.example.
# -----------------------------------------------------------------------------
# oidc - identity providers
# -----------------------------------------------------------------------------
# Multi-provider supported. Each row is created from scratch on first
# apply; the applier never deletes providers (only creates + updates by
# slug). `client_secret` is plaintext here; encrypted at rest.
#
# Group → role materialisation:
# On every successful sign-in, the user's `claim_groups` claim is
# matched against `group_mappings`. Each match yields a role assignment
# tagged `provider_id = <this provider>`, so the NEXT sign-in revokes
# it when the user is no longer in the group. Admin-issued assignments
# (provider_id IS NULL) are never touched.
#
# `scope` syntax in group_mappings:
#
# global applies everywhere
# team:<slug> applies to the named team itself (no zone access)
# zone:<zone-name> the role's zone-bound permissions on every zone with that
# name, on any backend (canonical fqdn); nothing else
# server:<slug> applies to the backend object itself; does NOT grant the
# zones on it (zones carry no server binding the authorizer
# can match) - use zone: scopes or zone grants for that
#
# Mappings whose role / team / server can't be resolved at sign-in are
# logged + audited under auth.oidc.group_sync.mapping_unresolved and
# skipped - the rest of the sign-in proceeds.
# -----------------------------------------------------------------------------
oidc:
- slug: company-sso # lowercase kebab-case
name: "Company SSO"
issuer_url: "https://auth.example.com/realms/example"
client_id: "powerdns-authadmin"
client_secret: "REPLACE_ME_company_sso_client_secret"
scopes: "openid profile email groups" # space-separated OAuth scopes; default "openid profile email"
# Claim names - change only if your IdP uses non-standard claim keys.
claim_email: email
claim_name: name
claim_groups: groups
enabled: true # false hides the provider from the login page
# DEPRECATED - kept for one minor for backward compat. Set the global
# `auth_default_provider` setting from /admin/authentication instead;
# the applier translates a legacy `force_default: true` here into that
# setting at apply time and logs a deprecation warning.
force_default: false
# Optional inline logo for the login button. https:// only.
# icon_url: "https://auth.example.com/static/logo.png"
# Restrict auto-provisioning of NEW users to these email domains.
# Existing users keep signing in regardless. Empty/unset ⇒ no restriction.
allowed_email_domains:
- example.com
- contractors.example.com
group_mappings:
# global → everyone in this IdP group is super-admin everywhere
- group: pdns-superadmins
role: super-admin
scope: global
- group: pdns-operators
role: operator
scope: global
# team-scoped → role applies to the team itself (view/update/members);
# it does not open the team's zones - give the team zone grants for that.
# The team slug must exist (defined above or pre-existing).
- group: noc-zone-editors
role: zone-editor
scope: team:noc
# zone-scoped → the role's zone-bound permissions on ONE zone name
# (canonical fqdn - trailing dot), matched on every backend.
- group: corp-zone-readers
role: read-only
scope: zone:corp.example.
# server-scoped → role applies to the backend object only; it does NOT
# grant the zones on it (see the scope syntax note above).
- group: public-dns-only
role: zone-editor
scope: server:primary-public
# Custom roles defined in `roles:` above are usable here by slug -
# the materialiser doesn't care whether the role is system-seeded
# or org-custom.
- group: dns-noc-team
role: zone-noc
scope: global
# A second provider - Google personal sign-in, defined but disabled.
# Flip `enabled: true` from the admin UI when you're ready.
- slug: google
name: "Google"
issuer_url: "https://accounts.google.com"
client_id: "REPLACE_ME_google_client_id.apps.googleusercontent.com"
client_secret: "REPLACE_ME_google_client_secret"
scopes: "openid profile email"
claim_email: email
claim_name: name
claim_groups: groups
enabled: false
# DEPRECATED - see the note on the first provider above.
force_default: false
group_mappings: [] # explicit empty list - no group sync
# -----------------------------------------------------------------------------
# saml: SAML 2.0 identity providers (ADR-0021)
# -----------------------------------------------------------------------------
# One entry per IdP. Identifiers + group sync work the same way as OIDC; the
# transport differences are: SP signing keypair is REQUIRED (we sign every
# AuthnRequest), assertions MUST be signed by default, and the optional SP
# encryption keypair adds support for EncryptedAssertion.
#
# Public certs (IdP signing cert, SP signing cert, SP encryption cert) sit as
# PEM blocks in plain text. Private keys are encrypted at rest by the applier;
# YAML still holds them plaintext, so this file MUST live in a secret store /
# git-crypt setup (same posture as OIDC client secrets, PDNS API keys, …).
# -----------------------------------------------------------------------------
saml:
- slug: corp-sso # lowercase kebab-case; reserved across every provider type
name: "Corporate SSO"
idp_entity_id: "https://idp.example.com/saml"
idp_sso_url: "https://idp.example.com/saml/sso"
# Optional. When unset, /api/auth/saml/<slug>/slo clears the local session
# only - the IdP keeps its own session until it expires.
idp_slo_url: "https://idp.example.com/saml/slo"
# Public X.509 cert the IdP signs Responses with.
idp_signing_cert: |
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUREPLACE_ME_with_your_IdPs_signing_certificate
-----END CERTIFICATE-----
# SP signing keypair. Generate locally with:
# openssl req -x509 -newkey rsa:2048 -keyout sp.key -out sp.crt \
# -nodes -days 1825 -subj "/CN=corp-sso"
sp_signing_key: |
-----BEGIN PRIVATE KEY-----
REPLACE_ME_with_the_SP_private_key_PEM
-----END PRIVATE KEY-----
sp_signing_cert: |
-----BEGIN CERTIFICATE-----
REPLACE_ME_with_the_SP_signing_certificate_PEM
-----END CERTIFICATE-----
# Optional: enable EncryptedAssertion. Both halves required together.
# sp_encryption_key: |
# -----BEGIN PRIVATE KEY-----
# ...
# -----END PRIVATE KEY-----
# sp_encryption_cert: |
# -----BEGIN CERTIFICATE-----
# ...
# -----END CERTIFICATE-----
# require_encrypted_assertion: false
require_signed_response: true
signature_algorithm: sha256 # sha1 | sha256 | sha512
name_id_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
claim_email: email
claim_name: name
claim_groups: groups
enabled: true
# allowed_email_domains:
# - example.com
group_mappings:
- group: DomainAdmins
role: super-admin
scope: global
- group: NetOps
role: operator
scope: global
- group: NocZoneEditors
role: zone-editor
scope: team:noc
# -----------------------------------------------------------------------------
# LDAP providers (ADR-0020)
#
# Direct-bind sign-in against Active Directory or OpenLDAP. The applier
# encrypts `bind_password` (and `tls_ca_cert`, if you pin a CA) with the
# same AES-256-GCM envelope used everywhere else - they NEVER round-trip
# back through the API in plaintext.
#
# Slugs are reserved in the cross-type `auth_provider_slugs` table the
# same way as OIDC + SAML; a slug collision across types fails the
# provisioning step with a clean error.
# -----------------------------------------------------------------------------
ldap:
# ----- Worked AD example -----------------------------------------------
- slug: corp-ad
name: "Corporate AD"
server_url: "ldaps://ad.corp.example.com:636"
# StartTLS upgrades a plain ldap:// connection after connect.
# MUST be false on ldaps:// URLs (the implicit-TLS port doesn't
# speak StartTLS) - the validator refuses the redundant pairing.
start_tls: false
bind_dn: "CN=svc-authadmin,OU=ServiceAccounts,DC=corp,DC=example,DC=com"
bind_password: "REPLACE_ME_ad_service_account_password"
user_search_base: "OU=Employees,DC=corp,DC=example,DC=com"
# AD's typical OR filter - matches `sAMAccountName` (the
# Active Directory login name), `userPrincipalName`, or `mail`.
user_search_filter: "(|(sAMAccountName={{username}})(userPrincipalName={{username}})(mail={{username}}))"
# AD fully resolves `memberOf` on the user record at query time,
# so the optional second-search pair is unnecessary. Leave
# group_search_base / group_search_filter out for AD.
group_attr: memberOf
claim_email: mail
claim_name: displayName
# Optional CA pin for an internal certificate authority. Concatenated
# PEM is accepted (cert + intermediates). Use this OR
# LDAP_TLS_INSECURE_SKIP_VERIFY=true (NEVER both - the pin is the
# secure equivalent).
# tls_ca_cert: |
# -----BEGIN CERTIFICATE-----
# ...
# -----END CERTIFICATE-----
enabled: true
allowed_email_domains:
- corp.example.com
group_mappings:
# Map AD groups (by their full DN - AD's memberOf is DN-valued)
# to roles. Group strings are case-sensitive matches against the
# values in the user's memberOf attribute.
- group: "CN=PDNS Super Admins,OU=Groups,DC=corp,DC=example,DC=com"
role: super-admin
scope: global
- group: "CN=NOC,OU=Groups,DC=corp,DC=example,DC=com"
role: zone-editor
scope: team:noc
- group: "CN=DNS Public Zone Readers,OU=Groups,DC=corp,DC=example,DC=com"
role: read-only
scope: zone:corp.example.
# ----- Worked OpenLDAP 2.6 example -----------------------------------
# OpenLDAP with the memberof overlay enabled in cn=config behaves
# like AD (group memberships resolved on the user record). Without
# the overlay, configure the optional second search below - we
# match groupOfNames / groupOfUniqueNames by `member={{userDn}}`.
- slug: dev-openldap
name: "Dev OpenLDAP"
# Plain ldap:// is refused by the validator unless start_tls=true
# is set here (RFC 4511 §4.14 upgrade) or
# LDAP_ALLOW_INSECURE_PORT_389=true is set in the environment.
server_url: "ldap://openldap.dev.example.com:389"
start_tls: true
bind_dn: "cn=svc-authadmin,ou=ServiceAccounts,dc=example,dc=com"
bind_password: "REPLACE_ME_openldap_service_account_password"
user_search_base: "ou=Users,dc=example,dc=com"
user_search_filter: "(|(uid={{username}})(mail={{username}}))"
# OpenLDAP without the memberof overlay: configure the second
# search. The filter substitutes {{userDn}} with the LDAP-escaped
# DN of the just-authenticated user.
group_search_base: "ou=Groups,dc=example,dc=com"
group_search_filter: "(&(objectClass=groupOfNames)(member={{userDn}}))"
# Empty attr is the conventional "no in-entry attribute, always
# consult the second search" path; keep memberOf as the default
# so installs that later flip the overlay on don't need a config
# change here.
group_attr: memberOf
claim_email: mail
claim_name: cn
enabled: true
group_mappings:
- group: "cn=admins,ou=Groups,dc=example,dc=com"
role: super-admin
scope: global
- group: "cn=devs,ou=Groups,dc=example,dc=com"
role: read-only
scope: global