Ctrl K
ComunidadGuías…

provisioning.example.yaml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# =============================================================================
# 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