# Declarative IAM

GitOps-shaped IAM: YAML is the source of truth; the encrypted config DB is reconciled from YAML on every apply.

In **declarative** mode (`access.iam_mode: declarative`), `access.iam_users`, `access.iam_groups`, `access.auth_providers`, and `access.group_mapping_rules` are authoritative. The reconciler diffs YAML against the encrypted config DB on every `/config/apply` (or section-PUT on `access`) and applies the necessary creates, updates, and deletes atomically, in a single SQLite transaction. Admin-API IAM mutation endpoints (POST/PUT/PATCH/DELETE on `/users`, `/groups`, `/ext-auth/*`, `/migrate`, backup import) return `403 { "error": "iam_declarative" }`, so runtime drift cannot occur. Read endpoints stay accessible.

The encrypted DB still holds the state at runtime (the IAM index on the hot path reads from it for O(1) lookups). The reconciler makes sure that it matches what YAML says. SigV4 auth, group membership, and OAuth mapping resolve at request time in the same way as in `gui` mode, because they all read the same DB.

`GET /_/api/admin/config/declarative-iam-export` projects the current DB into a self-contained `access:` fragment with `iam_mode: declarative` set and secrets redacted. Re-applying that export with secrets re-injected is an idempotent no-op (the reconciler reports `is_noop` and the audit ring is not bumped).

`POST /_/api/admin/config/section/access/validate` (dry-run, same body shape as PUT) runs the same `diff_iam` the live apply does and returns a preview line in `warnings`:

```
declarative IAM preview: users(+1/~2/-0) groups(+0/~1/-0) providers(+0/~0/-0) mapping_rules=keep
```

An idempotent validate reports `declarative IAM preview: no IAM changes (idempotent apply)`. A flip to declarative with empty IAM yields a warning that the live apply would refuse (see "The empty-YAML gate").

## Wire shape

```yaml
# validate
access:
  iam_mode: declarative

  iam_groups:
    - name: admins
      description: "Full access"
      permissions:
        - effect: Allow
          actions: ["*"]
          resources: ["*"]
    - name: Engineering
      description: "Read-only releases/"
      permissions:
        - effect: Allow
          actions: ["read", "list"]
          resources: ["releases/*"]

  iam_users:
    - name: dana
      access_key_id: AKIADANA00001
      secret_access_key: "replace-with-secret-before-apply"
      enabled: true
      groups: ["admins"]        # by NAME, not DB id
      permissions: []           # direct perms on top of group-inherited

    - name: ci-uploader
      access_key_id: AKIACIUP00001
      secret_access_key: "replace-with-secret-before-apply"
      enabled: true
      groups: ["Engineering"]
      permissions:
        - effect: Allow
          actions: ["write"]
          resources: ["releases/firmware/*"]

  auth_providers:
    - name: google-corp
      provider_type: oidc
      enabled: true
      priority: 10
      display_name: "Google Workspace"
      client_id: "11111.apps.googleusercontent.com"
      client_secret: "replace-with-secret-before-apply"
      issuer_url: "https://accounts.google.com"
      scopes: "openid email profile"
      # Optional, for an identity provider in a private network:
      # extra_config: { allow_local: true, ca_cert_path: /etc/deltaglider/idp-ca.pem }

  group_mapping_rules:
    - provider: google-corp     # by NAME (null/absent = all providers)
      priority: 10
      match_type: email_domain
      match_field: email
      match_value: acme.example
      group: Engineering         # by NAME
```

**Names, not IDs.** Users reference groups by name. Mapping rules reference providers and groups by name. DB row IDs are ephemeral autoincrement values and never appear in YAML. The reconciler resolves names → IDs at apply time.

## Diff semantics

Per entity type (users, groups, providers, mapping rules), by NAME:

| YAML | DB | Action |
|---|---|---|
| present | present + all fields equal | no-op (idempotent path) |
| present | present + any field differs | **UPDATE** (DB row id preserved) |
| present | missing | CREATE |
| missing | present | DELETE (cascades via FKs) |

A user that an OAuth login created (`auth_source: external`) is never written in YAML, so its absence alone does not delete it. The reconciler deletes such a user only when the OAuth flow can create it again: no direct permissions, and exactly the groups that the mapping rules give it. A user with permissions or groups that an admin added keeps its row. A YAML user with the name of an OAuth-created user and a different access key fails the apply, because the name would otherwise give that YAML user's credentials to the holder of the identity-provider account. Rename one of the two.

Mapping rules are wipe-and-rebuild (no stable per-row identity beyond the tuple of fields; replacing is identical in observable effect).

**Validation is separate from side effects.** Every YAML-only error (duplicate names, duplicate access keys, unknown group refs, invalid permissions, `$`-prefixed reserved names, a new or changed provider whose `provider_type` is not `oidc`, an OIDC `issuer_url` that the provider's `extra_config.allow_local` setting does not permit) surfaces before any DB write. A single error means zero state change. A provider of another type that the database already holds unchanged still loads at boot, with a warning in the log, but no user can sign in with it.

**Permission templates.** `resources` and string condition values may contain `${iam:username}` and `${iam:access_key_id}` (the `iam:` prefix is required, because it distinguishes these request-time identity substitutions from `${env:NAME}` load-time config expansion). The reconciler stores those templates literally in the DB; runtime IAM index rebuild expands them per user after group permissions are merged. Identity values are inserted as they are, and a value that contains `/`, `*`, `?`, `$`, `{`, `}` or `%` is refused at expansion (that user then gets no permissions), so user names and access keys cannot inject a path level or a wildcard. Unknown `${...}` variables (including a bare, unprefixed `${username}`) fail validation before any reconcile write.

**ID preservation.** When a user exists in both YAML and DB by name, the row stays (UPDATE), never DELETE+INSERT. `external_identities` reference `user_id`, so rotating an access key via YAML preserves the OAuth linkage.

## External identities

Two surfaces treat external identities differently:

- The **main config file** and the declarative config-apply path never touch external identities. They are created at runtime by the OAuth callback flow and live in the database. The reconciler's contract below describes this surface.
- The **full-IAM import artifact** (`declarative-iam-validate` / `-apply`) does restore them, because that artifact exists to make a wipe lossless. See "Full-IAM round-trip" below.

The reconciler's contract for the main config / config-apply surface:

- `external_identities` are preserved through user UPDATEs (same DB id → same bindings).
- `external_identities` are cascade-deleted when a YAML-authoritative delete removes the user or provider they reference. The user is gone, so the binding has no use.

If an OAuth callback is in-flight when a reconcile fires, the callback inserts the external identity into a user row that the reconcile may then delete (if YAML doesn't list that user). The callback flow fails; the next login creates a fresh external user.

### Full-IAM round-trip

The full-IAM export (`GET /_/api/admin/config/declarative-iam-export?include_secrets=true`) is the one surface that *does* carry the DB-only state, so a committed export is lossless:

- Every user carries `auth_source` (`"external"` for OAuth-provisioned rows); the import restores the provenance instead of downgrading the row to `local`.
- The export emits an `external_identities` list. Each entry references the user and the provider by name, and carries the IdP `subject`, email, and claims. The import upserts each binding keyed on `(provider, subject)`, the exact pair the OAuth callback looks up, so a recovered database re-links the binding to the freshly created user row instead of letting the next login provision a duplicate user.
- Bindings are never deleted by the reconciler. An `external_identities` list can be absent from hand-authored YAML or from a redacted export. (`include_secrets=false` drops the bindings, because `raw_claims` can carry IdP personal data.) An absent list means "leave the database alone", never "delete the missing rows".
- The main config file rejects `access.external_identities` outright. That file never manages OAuth bindings, and if it dropped them from a pasted full-IAM export without a warning, the bindings would be lost.
- User names are unique in the database, so every user maps to exactly one YAML entry. The reconciler keys users by name. As a safety check, the export refuses with HTTP 409 when it finds two users with one name, because such a pair cannot round-trip through YAML.

## Secrets in exports

The canonical exporter redacts every secret on the way out. A YAML pulled from `/config/export` has:

- `iam_users[*].secret_access_key` → `""`
- `auth_providers[*].client_secret` → `null`

Secrets in YAML applied through `config apply` or loaded from disk pass through `${env:NAME}` expansion first (see [Configuration](https://deltaglider.com/docs/reference/configuration.md)); a raw admin-API document body resolves a `${env:NAME}` reference only when the config file loaded at startup already uses that same name, or when the operator lists the name in `DGP_CONFIG_ENV_ALLOWLIST`. The server never reads any other environment variable for an admin request, because an admin could otherwise read every secret of the proxy process through error messages or exports. A reference to a name that the file does not use takes its `:-default`, or the request fails. The persist-variant serializer keeps whatever YAML carries on disk across admin-API round-trips.

## The empty-YAML gate

A flip from `gui` to `declarative` with empty `iam_users` AND empty `iam_groups` is refused. Auth providers and mapping rules in the YAML do not change this, because the reconcile still deletes every local user and group:

```
Refusing to flip to iam_mode: declarative with no iam_users or iam_groups in YAML —
this would wipe the existing users/groups in the encrypted config DB.
Add access.iam_users / access.iam_groups to the YAML first, or keep
iam_mode: gui to preserve the DB as source of truth.
```

The gate fires only on the `gui→declarative` transition. Declarative-to-declarative with empty YAML is allowed (deliberately clearing all IAM). Gui-to-gui is a no-op.

## Mode transitions

- `gui → declarative` with non-empty YAML: reconcile runs. DB converges to YAML.
- `declarative → declarative`: reconcile always runs.
- `declarative → gui`: no-op on the DB. State preserved; admin-API IAM mutations unlock.
- `gui → gui`: no-op.

## Audit trail

Every mutation the reconciler performs emits an audit ring entry tagged `declarative`. Each entry carries the user agent and the client IP of the admin request that started the apply:

- `iam_reconcile_user_create` / `_update` / `_delete`
- `iam_reconcile_group_create` / `_update` / `_delete`
- `iam_reconcile_provider_create` / `_update` / `_delete`
- `iam_reconcile_mapping_rules_replaced`

The mode transition itself (`iam_mode` field change) is audited at WARN level by `apply_config_transition`. The `/config/apply` response warnings summarize what the reconciler did; zero activity produces no warning (idempotency signal):

```
declarative IAM reconciled: 5 users (+1/~1/-0), 3 groups (+0/~2/-0),
                             2 providers (+0/~0/-0), 7 mapping rules replaced
```

## Adversarial edges

| Input | Outcome |
|---|---|
| Two YAML users with same `access_key_id` | Validation rejects, zero DB writes |
| User's `groups:` references an unknown group | Validation rejects with the specific missing group name |
| Mapping rule references missing provider/group | Validation rejects with the specific missing name |
| User names starting with `$` (reserved) | Validation rejects (`$anonymous`, `$bootstrap` are synthetic principals) |
| User in YAML has same access_key as a to-be-deleted DB user | Validation rejects (prevents mid-transaction UNIQUE violation) |
| YAML user has permissions with invalid shape | Validation rejects, per-entity error message |
| YAML has zero rules, DB has some | Reconciler clears the mapping_rules table |
| Idempotent re-apply (YAML unchanged) | No DB writes, `diff.is_empty()` |
