feat(ansible): ADR-0024 — one FreeIPA replaces OpenLDAP, and the ipa_structure role that implements the IPA side #318

Merged
dominik.polakovics merged 1 commit from afk/316 into main 2026-08-10 16:30:24 +02:00

Closes #316

Two deliverables: ADR-0024 recording the decision, and the ipa_structure role so that a provision.yml run leaves ipa.cloonar.com fully structured. No NixOS consumer changes — those are #317.

ADR-0024

docs/adr/0024-one-freeipa-replaces-openldap.md. One FreeIPA replaces OpenLDAP entirely; a mail domain becomes the mail attribute plus a group rather than an olcDatabase suffix. It reverses the FreeIPA rejections in ADR-0022 and ADR-0023 one at a time, and the two that actually needed arguing are:

  • "single-suffix is incompatible with the multi-tenant OpenLDAP of ADR-0001" — the tenancy it protects was never enforced. The tenants already share one LMDB environment, and catchAllAcl ends by * read with no olcRequires: authc (#303). FreeIPA could not represent a separation the directory was not making.
  • "no silent migration" (ADR-0023) — true for a standalone MIT KDC, false for FreeIPA. ipa migrate-ds does not drop userPassword, and ipa-pwd-extop's PRE_BIND hook mints the Kerberos key on the first successful simple bind. /ipa/migration does nothing but perform that bind, so Dovecot with auth_bind = yes migrates people as they collect their mail — and decision 4 chose auth_bind = yes anyway, because IPA lets no bind read userPassword.

ADR-0023's {CRYPT}! partition and per-user cutover are recorded as corrected rather than replaced, since neither was implemented and the premise behind them is false for FreeIPA.

ansible/roles/ipa_structure

Runs between freeipa and verify. defaults/main.yml holds the whole shape; the task files are mechanical over it.

  • SchemacloonarAccount with cloonarMailQuota, cloonarCloudQuota, cloonarMailSendOnly, as a schema.d drop-in merged by ipa-ldap-updater --schema-file, then merged into IPA's default user objectclasses via a read-union-write on ipaconfig.
  • Quota plugin — a Web UI plugin hooked on the customization phase plus the ipaserver parameters it needs. Both are required: the UI form is a static spec, and user_mod rejects any option with no declared param.
  • Groups — five layers, per-host adm-* sudo groups, both hostgroups. Two passes so nesting never has to create a group; only the nested-group list is synced, so a person added by hand is left alone.
  • HBACallow_all disabled, hbac-admins-all in its place.
  • Sysaccounts — four read-only binds under cn=sysaccounts,cn=etc, plus the permission without which a custom attribute is invisible to them.
  • verify — seven new checks: schema present, objectclass defaulted, every group present with the intended POSIX state, memberOf flattened across every nesting edge, allow_all disabled, hostgroups present, and each sysaccount binding and searching.

Four findings that changed the design mid-flight

  • mailQuota is taken. Stock 389DS ships one (2.16.840.1.113730.3.1.21, 50ns-mail.ldif), and two attributeTypes sharing a NAME is a schema error. Everything is cloonar-prefixed. OIDs sit under 2.25.<uuid>, the ITU-T UUID arc — globally unique without an IANA PEN.
  • Directory String, not Integer. Integer would let the directory itself refuse 5 GiB (which Nextcloud reads as five bytes), but Nextcloud's quota field also takes none and default. The pattern on each IPA parameter recovers the check at the only place a quota is written.
  • ipagroup creates POSIX by default and FreeIPA never demotes, so nonposix is passed explicitly on every group. admins is IPA's own and already POSIX; the defaults record that rather than trying to demote it, which would have failed the play permanently.
  • ipasysaccount needs FreeIPA ≥ 4.13 and base gates the IdM stack out of unattended updates, so the box stays on 4.12. The binds are plain LDAP entries created over ldapi:///, where root's peercred maps to Directory Manager and no password crosses a network.

Verification

scripts/ansible-check passes — 0 failure(s), 0 warning(s) in 57 files, profile production. Beyond that, offline:

  • the schema LDIF was rendered through the real template module and parsed with an independent RFC 4512 parser; SINGLE-VALUE placement, MAY ( a $ b $ c ) spacing and the 2.25.<big int>.N OID form all check out, and the OID was re-derived from its UUID
  • the rendered JS parses under esprima; the rendered Python compiles and is pyflakes-clean
  • the verify LDIF parsing was exercised against generated fixtures — good input passes, and a promoted-to-POSIX group, an unflattened memberOf, an enabled allow_all, a missing hostgroup and dropped -o ldif-wrap=no each fail it

Nothing here has run against the live IPA. The playbook has not been executed; #315 is still open.

Operator action before the next run

The per-server sops file needs a new key — the template is updated:

nix-shell -p sops --run 'sops ansible/inventory/host_vars/ipa-cloonar.sops.yaml'
ipa_sysaccount_passwords:
  dovecot: ""
  keycloak: ""
  nextcloud: ""
  home-assistant: ""

Known gap

ansible/playbooks/accounts.yml already creates employees/finance delegation teams that hold people directly, which is layer-1 behaviour from groups outside layer 1, and employees overlaps staff. Left alone deliberately — it is a shipped mechanism and reconciling it belongs with the mail cutover in #317. The ADR records it rather than papering over it.

Closes #316 Two deliverables: **ADR-0024** recording the decision, and the **`ipa_structure`** role so that a `provision.yml` run leaves `ipa.cloonar.com` fully structured. No NixOS consumer changes — those are #317. ## ADR-0024 `docs/adr/0024-one-freeipa-replaces-openldap.md`. One FreeIPA replaces OpenLDAP entirely; a mail domain becomes the `mail` attribute plus a group rather than an `olcDatabase` suffix. It reverses the FreeIPA rejections in ADR-0022 and ADR-0023 one at a time, and the two that actually needed arguing are: - *"single-suffix is incompatible with the multi-tenant OpenLDAP of ADR-0001"* — the tenancy it protects was never enforced. The tenants already share one LMDB environment, and `catchAllAcl` ends `by * read` with no `olcRequires: authc` (#303). FreeIPA could not represent a separation the directory was not making. - *"no silent migration"* (ADR-0023) — true for a standalone MIT KDC, false for FreeIPA. `ipa migrate-ds` does not drop `userPassword`, and `ipa-pwd-extop`'s `PRE_BIND` hook mints the Kerberos key on the first successful simple bind. `/ipa/migration` does nothing but perform that bind, so **Dovecot with `auth_bind = yes` migrates people as they collect their mail** — and decision 4 chose `auth_bind = yes` anyway, because IPA lets no bind *read* `userPassword`. ADR-0023's `{CRYPT}!` partition and per-user cutover are recorded as *corrected* rather than *replaced*, since neither was implemented and the premise behind them is false for FreeIPA. ## `ansible/roles/ipa_structure` Runs between `freeipa` and `verify`. `defaults/main.yml` holds the whole shape; the task files are mechanical over it. - **Schema** — `cloonarAccount` with `cloonarMailQuota`, `cloonarCloudQuota`, `cloonarMailSendOnly`, as a `schema.d` drop-in merged by `ipa-ldap-updater --schema-file`, then merged into IPA's default user objectclasses via a read-union-write on `ipaconfig`. - **Quota plugin** — a Web UI plugin hooked on the `customization` phase plus the `ipaserver` parameters it needs. Both are required: the UI form is a static spec, and `user_mod` rejects any option with no declared param. - **Groups** — five layers, per-host `adm-*` sudo groups, both hostgroups. Two passes so nesting never has to create a group; only the nested-group list is synced, so a person added by hand is left alone. - **HBAC** — `allow_all` disabled, `hbac-admins-all` in its place. - **Sysaccounts** — four read-only binds under `cn=sysaccounts,cn=etc`, plus the permission without which a custom attribute is invisible to them. - **`verify`** — seven new checks: schema present, objectclass defaulted, every group present with the intended POSIX state, memberOf flattened across every nesting edge, `allow_all` disabled, hostgroups present, and each sysaccount binding and searching. ## Four findings that changed the design mid-flight - **`mailQuota` is taken.** Stock 389DS ships one (`2.16.840.1.113730.3.1.21`, `50ns-mail.ldif`), and two attributeTypes sharing a NAME is a schema error. Everything is `cloonar`-prefixed. OIDs sit under `2.25.<uuid>`, the ITU-T UUID arc — globally unique without an IANA PEN. - **Directory String, not Integer.** Integer would let the directory itself refuse `5 GiB` (which Nextcloud reads as five bytes), but Nextcloud's quota field also takes `none` and `default`. The `pattern` on each IPA parameter recovers the check at the only place a quota is written. - **`ipagroup` creates POSIX by default** and FreeIPA never demotes, so `nonposix` is passed explicitly on every group. `admins` is IPA's own and already POSIX; the defaults record that rather than trying to demote it, which would have failed the play permanently. - **`ipasysaccount` needs FreeIPA ≥ 4.13** and `base` gates the IdM stack out of unattended updates, so the box stays on 4.12. The binds are plain LDAP entries created over `ldapi:///`, where root's peercred maps to Directory Manager and no password crosses a network. ## Verification `scripts/ansible-check` passes — `0 failure(s), 0 warning(s) in 57 files, profile production`. Beyond that, offline: - the schema LDIF was rendered through the real `template` module and parsed with an independent RFC 4512 parser; `SINGLE-VALUE` placement, `MAY ( a $ b $ c )` spacing and the `2.25.<big int>.N` OID form all check out, and the OID was re-derived from its UUID - the rendered JS parses under `esprima`; the rendered Python compiles and is `pyflakes`-clean - the `verify` LDIF parsing was exercised against generated fixtures — good input passes, and a promoted-to-POSIX group, an unflattened memberOf, an enabled `allow_all`, a missing hostgroup and dropped `-o ldif-wrap=no` each fail it **Nothing here has run against the live IPA.** The playbook has not been executed; #315 is still open. ## Operator action before the next run The per-server sops file needs a new key — the template is updated: ``` nix-shell -p sops --run 'sops ansible/inventory/host_vars/ipa-cloonar.sops.yaml' ``` ```yaml ipa_sysaccount_passwords: dovecot: "" keycloak: "" nextcloud: "" home-assistant: "" ``` ## Known gap `ansible/playbooks/accounts.yml` already creates `employees`/`finance` delegation teams that hold people directly, which is layer-1 behaviour from groups outside layer 1, and `employees` overlaps `staff`. Left alone deliberately — it is a shipped mechanism and reconciling it belongs with the mail cutover in #317. The ADR records it rather than papering over it.
ADR-0024 records the decision to replace OpenLDAP and mail's standalone KDC
with the one FreeIPA, and reverses the FreeIPA rejections in ADR-0022 and
ADR-0023 point by point. A mail domain becomes the `mail` attribute plus a
group rather than an `olcDatabase` suffix; the unimplemented parts of
ADR-0023 — the `{CRYPT}!` partition and the per-user cutover — are corrected
rather than merely superseded, because `ipa-pwd-extop` mints a Kerberos key
on the first successful simple bind and that is the silent path ADR-0023
concluded did not exist.

The `ipa_structure` role implements the IPA side, between `freeipa` and
`verify` in provision.yml:

- the `cloonarAccount` auxiliary objectclass and its three attributes, as a
  `schema.d` drop-in merged by `ipa-ldap-updater`, plus a merge into IPA's
  default user objectclasses
- an IPA Web UI plugin and the server-side parameters it needs, so the
  quota fields are editable on the user page
- the five group layers, per-host `adm-*` sudo groups, both hostgroups, and
  the nesting that makes layer 1 the only place a person is ever put
- `allow_all` disabled and `hbac-admins-all` in its place
- four read-only bind accounts under `cn=sysaccounts,cn=etc` and the
  permission without which a custom attribute is invisible to them
- seven new acceptance checks in `verify`

Every attribute carries a `cloonar` prefix because stock 389DS already ships
a `mailQuota`. No NixOS consumer changes; those are #317.
Author
Owner

[autoland] verdict: pass

This was generated by AI while landing a PR.

PASS. Validated for landing.

  • Signal: no CI check-runs exist for this tree, so I ran the repo's own gate myself on the PR head: scripts/ansible-check → 0 failure(s), 0 warning(s) in 57 files, profile production (ansible-core 2.21.2, ansible-lint 26.6.0, collections pinned per ansible/requirements.yml).
  • Conventions: Conventional Commits title, Closes #316 present, head afk/316 sits directly on origin/main (no conflict possible).
  • Scope: diff matches #316 exactly — ADR-0024, ipa_structure role, verify extensions, no NixOS consumer changes.

Operator question resolved (sysaccount passwords): they are done in sops, exactly per the security invariant. The .sops.yaml.example is only a plaintext key template; real values go into the encrypted ansible/inventory/host_vars/ipa-cloonar.sops.yaml, which provision.yml decrypts on the control node at runtime via community.sops.load_vars. The committed encrypted file does not yet carry ipa_sysaccount_passwords (sops leaves key names readable — only the four original keys are present), so the operator must add the map before the next provisioning run; until then the play fails loudly at the ipa_structure sysaccounts task. That is the PR body's 'Operator action before the next run' and is by design.

[autoland] verdict: pass > *This was generated by AI while landing a PR.* **PASS.** Validated for landing. - **Signal**: no CI check-runs exist for this tree, so I ran the repo's own gate myself on the PR head: `scripts/ansible-check` → 0 failure(s), 0 warning(s) in 57 files, profile production (ansible-core 2.21.2, ansible-lint 26.6.0, collections pinned per `ansible/requirements.yml`). - **Conventions**: Conventional Commits title, `Closes #316` present, head `afk/316` sits directly on `origin/main` (no conflict possible). - **Scope**: diff matches #316 exactly — ADR-0024, `ipa_structure` role, verify extensions, no NixOS consumer changes. **Operator question resolved (sysaccount passwords)**: they *are* done in sops, exactly per the security invariant. The `.sops.yaml.example` is only a plaintext key template; real values go into the encrypted `ansible/inventory/host_vars/ipa-cloonar.sops.yaml`, which `provision.yml` decrypts on the control node at runtime via `community.sops.load_vars`. The committed encrypted file does **not** yet carry `ipa_sysaccount_passwords` (sops leaves key names readable — only the four original keys are present), so the operator must add the map before the next provisioning run; until then the play fails loudly at the `ipa_structure` sysaccounts task. That is the PR body's 'Operator action before the next run' and is by design.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!318
No description provided.