Feature Specification: Resource ldap

Created: 2026-10-07

Status: Implemented

Input: Reconstructed from the code at 093e68b6f (core/resources/ldap/default.nix, core/resources/ldap/coordinates.nix, core/resources/ldap/publish-coordinates.nix, core/resources/ldap/lldap-provider.nix, core/resources/ldap/provision.nix, core/effects/tf/common/providers.nix, core/setups/wiring/resource-routing.nix, core/setups/wiring/mail-domains.nix, core/setups/hosts-common.nix, core/checks/integrations/ldap/), pull requests #1184, #1205 and #1236, and issue #527.

User Scenarios & Testing (mandatory)

User Story 1 - Give a component a directory to authenticate against (Priority: P1)

A component needs an LDAP directory for its users. It states a request with enable, a group and a consumerName. The policy routes the request to the lldap provider through the ldap contract. The consumer node gets the URL, the host, the port, the CA file, the base DNs, a bind DN and a bind password file. The consumer connects to the URL and, for an ldaps:// URL, verifies the server certificate against the CA file. The resource gives these values to the ldap contract of the consumer node, and a static client module of the component reads them from contracts.ldap.results.<consumerName>.default. The request holds only data.

Why this priority: the single sign-on provider and other components read their users from this directory. Without it, nobody can log in.

Independent Test: contracts-ldap-lldap (a NixOS test of the provider in core/checks/integrations/ldap/lldap-behavior.nix) and test-ldap-engine. nix-unit-resources-ldap-consumer checks that a routed request gives the result to contracts.ldap.results of the consumer node, that tls gives the ldaps:// URL and caFile, and that the consumer side holds only data. nix-unit-resources-ldap-provider checks the provider side: the wants and the bind password of the lldap node, and no output without a routed provider.

Acceptance Scenarios:

  1. Given a routed request with group = "authelia-users", When the deployment is evaluated, Then the provider side gets contracts.ldap.want.<consumerName>.default.request.group.
  2. Given a routed request, When the consumer side is evaluated, Then contracts.ldap.results.<consumerName>.default of the node holds group, url, caFile, host, port, baseDN, userBaseDN, groupBaseDN, bindDN and bindPasswordFile.
  3. Given a policy with domain = "ldap.example.org" and no baseDN, When the policy is evaluated, Then baseDN is dc=example,dc=org.
  4. Given no provider is routed for ldap, When the deployment is evaluated, Then apply gives no consumer side and no provider side.
  5. Given the group has the OpenBao LDAP engine, When the consumer binds as authelia-bind, Then it can set the password of a user that is not in lldap_admin, and lldap refuses to set the password of an lldap_admin member.
  6. Given a routed request and tls.enable = true, When the consumer side is evaluated, Then the result holds url = ldaps://<host>:<tls.port>, port = tls.port and caFile = tls.caFile.
  7. Given a routed request and tls.enable = false, When the consumer side is evaluated, Then the result holds url = ldap://<host>:<port> and caFile = null.

User Story 2 - Run the directory daemon and seed its accounts (Priority: P1)

The provider node runs lldap. The policy gives the node mainConfig, which sets runDaemon. The lldap-provision unit applies the groups, the seeded users and their memberships with OpenTofu. The deployment admin (adminUser) gets single sign-on access, admin UI access and lldap admin rights.

Why this priority: an empty directory has no admin and no groups. The platform is then not usable.

Independent Test: test-lldap-seed-isolation and test-lldap-admin-release. integrations-lldap-provision checks the adoption and the prune. nix-unit-setups-ldap-grant and nix-unit-setups-ldap-directory check the wiring of the grant and the directory coordinates.

Acceptance Scenarios:

  1. Given the provider node, When lldap starts, Then it reads its admin password from bindPasswordFile and resets the admin password to that value on each start (force_ldap_user_pass_reset = "always").
  2. Given adminUser is set, When lldap-provision runs, Then adminUser is a member of authelia-users, fediversity-admins and lldap_admin.
  3. Given email is held by another account, When lldap-provision runs, Then the unit deletes that account, so it can seed adminUser with email.
  4. Given email is held by admin or by authelia-bind, When lldap-provision runs, Then the unit logs that the account is not released, does not delete it, and the apply fails on adminUser.
  5. Given a user, a group or a membership that the deployment did not seed, When lldap-provision runs, Then it stays, because the unit changes only the objects in its state.
  6. Given a seeded user, group or membership that already exists and is not in the state, When lldap-provision runs, Then the unit imports it into the state and does not create it again.
  7. Given a seeded user or group that leaves the configuration, When lldap-provision runs, Then the unit deletes it from the directory.

User Story 3 - Publish the directory coordinates (Priority: P2)

Other groups need to find the directory. The provider node publishes the URL, the user base DN and the management bind DN to the OpenBao KV store.

Why this priority: a consumer in another group cannot read the static configuration of the provider group.

Independent Test: nix-unit-setups-ldap-directory.

Acceptance Scenarios:

  1. Given a provider node with a vars-openbao backend, When lldap-publish-directory runs, Then it writes url, userBaseDN and managementBindDN to <kvPrefix>/public/<bindInstanceName>/directory.
  2. Given a provider node without a vars-openbao backend, When the node is evaluated, Then the publish unit is not active.

User Story 4 - Send password reset mail from the directory (Priority: P3)

The lldap web UI sends password reset mail. It uses the smtp resource as a relay. The link in the mail goes to the forward-auth vhost of the lldap UI, https://users.<domain>. That vhost lets the routes of the reset through without an Authelia session, so a user without a password can set one. This is the only reset for an lldap_admin member, such as the seeded administrator, because the bind account of Authelia cannot set the password of such a member.

Why this priority: a user can reset a password without an operator.

Independent Test: test-lldap-mail and nix-unit-setups-lldap-mail.

Acceptance Scenarios:

  1. Given fediversity.lldap.smtp is set, When the node is evaluated, Then lldap uses STARTTLS and reads the password from the credential lldap.smtp-password.
  2. Given the smtp-password credential is empty, When lldap starts, Then an ExecStartPre guard fails the start.
  3. Given the node serves the forward-auth vhost of the lldap UI, When the node is evaluated, Then http_url is https://users.<domain>.
  4. Given a user without a session, When the user opens the reset link and sets a password through the vhost, Then the reset succeeds, and the login routes of lldap, /api/ and /auth/refresh still send the user to Authelia.

User Story 5 - Serve the directory over LDAPS (Priority: P2)

A consumer sends the bind password to the directory. In a group with a private CA, the provider node serves LDAPS on tls.port (default 6360) with a leaf certificate for its host from the central OpenBao PKI, through the openbao-pki provider of the ssl contract. The contract result and the published coordinates then give the ldaps:// URL, and each consumer verifies the certificate against the PKI CA. The plaintext listener on port stays open during the cutover.

Why this priority: on plaintext LDAP, the bind password and the user passwords that Authelia checks cross the network in clear text.

Independent Test: contracts-ldap-lldap binds through LDAPS against a test CA. test-ldap-engine moves a mount from the plaintext URL to the LDAPS URL of the same directory and rotates a password through it. nix-unit-resources-ldap-provider checks that tls reaches the lldap daemon. nix-unit-setups-tls-routing checks that the lldap host takes a private-CA leaf only in a group with a private CA. nix-unit-setups-ldap-directory checks the published ldaps:// URL. apps-tf and the service tests of mkAppTest (core/components/operator/testing/lib.nix) bind Authelia through LDAPS, with a leaf from the PKI engine of production that the test OpenBao runs (serverNodePki in core/testers/openbao-test.nix). nix-unit-testers-openbao-pki checks that wiring.

Acceptance Scenarios:

  1. Given a group with a private CA, When the provider node is evaluated, Then lldap has ldaps_options with port = tls.port, the firewall opens tls.port and port, and lldap reads the certificate and key through LoadCredential.
  2. Given a group with a private CA, When the provider node is evaluated, Then the unit openbao-pki-<host>.service gets the leaf for the lldap host, and its renewal restarts lldap.service.
  3. Given a group without a private CA, When the provider node is evaluated, Then lldap serves plaintext LDAP only, and the URL is ldap://<host>:<port>.
  4. Given the published URL moves from ldap://<host>:<port> to ldaps://<host>:<tls.port>, When the OpenBao LDAP engine runs, Then it repoints the mount to the new URL, because the host is the same. It gives the mount the PKI CA as certificate.
  5. Given a node that runs Authelia or the vars backend in a group with an lldap host, When the node is evaluated, Then it trusts the PKI CA of the lldap host.

Edge Cases

  • fediversity.lldap.smtp is set and the configuration sets publicUrl to "": an assertion fails at eval. With smtp, the default of publicUrl is the forward-auth vhost of the UI if the node serves it, else the direct listener.
  • The node does not serve the forward-auth vhost of the lldap UI (the group has no oidc provider): http_url is the direct listener http://<host>:<httpPort>, and the reset token goes in clear.
  • email is set and adminUser is empty: an assertion fails at eval.
  • A group with an lldap node does not state ldap.adminUser in its group data: the eval fails.
  • The lldap HTTP API is not ready: lldap-provision waits up to LLDAP_READY_TIMEOUT (default 300 seconds) and then fails. Restart=on-failure retries it after 30 seconds.
  • The login as admin fails: lldap-provision logs this and fails before the apply.
  • The apply of one seeded user fails: the other users and their memberships are still applied, because each seeded user is a resource of its own.
  • The SMTP relay has a certificate from a private CA: the mail fails, because lldap trusts only the public web PKI roots.
  • The LDAPS leaf does not exist yet: lldap does not start, on port either, because LoadCredential cannot read the certificate. It starts after openbao-pki-<host>.service.
  • The LDAPS leaf renews: the renewal timer restarts lldap.service, and the open connections of the consumers close. This occurs in the last third of the lifetime of the leaf, not at each tick of the timer.
  • lldap stops while its database pool has a TLS connection that a request worker opened: lldap does not close that connection and does not stop. systemd kills it after TimeoutStopSec (10 seconds).
  • The PKI CA file is not on the engine node yet: the engine gives an ldaps:// mount no certificate, and the bind of that mount fails until the file arrives.

Requirements (mandatory)

Functional Requirements

  • FR-001: The policy MUST derive baseDN from domain when baseDN is not set: it drops the first label and gives one dc= per remaining label.
  • FR-002: The policy MUST give userBaseDN = ou=people,<baseDN> and groupBaseDN = ou=groups,<baseDN>.
  • FR-003: The policy MUST give a consumer side and a provider side only when a provider is routed for the ldap contract.
  • FR-004: The policy MUST give mainConfig with runDaemon = true only when a provider is routed for the ldap contract.
  • FR-005: The policy MUST generate the vars instance <bindInstanceName> with the secret file bindPassword (openssl rand -hex 32, owner root, mode 0444).
  • FR-006: The lldap provider MUST give url, caFile, host, port, baseDN, userBaseDN, groupBaseDN, bindDN and bindPasswordFile to each want. With tls.enable, url is ldaps://<host>:<tls.port>, port is tls.port and caFile is tls.caFile. Without it, url is ldap://<host>:<port> and caFile is null.
  • FR-007: The provider node MUST open port (default 3890) and httpPort (default 17170) in the firewall, and tls.port (default 6360) with tls.enable.
  • FR-008: lldap-provision MUST create the group fediversity-admins and each group of a seeded user, and MUST add authelia-bind to lldap_password_manager and not to lldap_admin.
  • FR-009: lldap-provision MUST delete a seeded user, group or membership that leaves the configuration, and MUST NOT change a user, group or membership that is not in its state.
  • FR-009a: lldap-provision MUST import a seeded user, group or membership that exists and is not in its state.
  • FR-010: The release pass MUST NOT delete admin or authelia-bind.
  • FR-011: The provider node MUST publish the directory coordinates to OpenBao KV when the node has a vars-openbao backend.
  • FR-012: The request MUST contain only data. The consumer side MUST give the contract result to contracts.ldap of the consumer node, not as an argument to a function of the request.
  • FR-013: The setups MUST read adminUser from ldap.adminUser in the group data. The eval MUST fail when a group with an lldap node does not state it.
  • FR-014: The lldap component MUST let only the reset routes in core/components/shared/lldap/reset-routes.nix through its forward-auth vhost without a session, and MUST limit the rate of /auth/reset/ per client address.
  • FR-015: In a group with a private CA, the provider node MUST serve LDAPS on tls.port with a leaf for its host from the openbao-pki provider of the ssl contract, and tls.caFile MUST be the PKI CA (/var/lib/openbao-init/pki-ca.pem). In a group without a private CA, the provider node MUST NOT serve LDAPS. The setup sets tls.enable with mkDefault, so a deployment can set it to false. The listener and the URL of the consumers then both stay on plaintext LDAP.
  • FR-016: Each consumer MUST connect to the result url and verify an ldaps:// listener against the PKI CA. Authelia verifies through the system trust. The OpenBao LDAP engine gives the CA to the mount as certificate and to its own bind checks as LDAPTLS_CACERT.
  • FR-017: The OpenBao LDAP engine MUST repoint a mount to a published URL with the same host and another scheme or port, and MUST NOT treat this as a second live deployment.

Key Entities

  • ldap request: enable, group and consumerName. It holds only data.
  • ldap result on the consumer node: contracts.ldap.results.<consumerName>.default, which holds the request and the contract result.
  • ldap contract result: url, caFile, host, port, baseDN, userBaseDN, groupBaseDN, bindDN and bindPasswordFile. caFile is null for an ldap:// URL.
  • ldap policy: enable, domain, host, port, httpPort, tls (enable, port, caFile), bindInstanceName, adminUser, email, baseDN, routing and the read-only crossNode.
  • lldap provider: the options under fediversity.lldap, among them runDaemon, publicUrl, smtp, tls (enable, port, certFile, keyFile, certUnits, caFile), bindPasswordFile and bindPasswordUnits.
  • Directory coordinates: url (ldaps://<host>:<tls.port> with tls.enable, else ldap://<host>:<port>), userBaseDN and managementBindDN (uid=admin,<userBaseDN>).

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: contracts-ldap-lldap, integrations-lldap-provision, test-ldap-engine, test-lldap-seed-isolation, test-lldap-admin-release and test-lldap-mail pass.
  • SC-002: nix-unit-setups-ldap-grant, nix-unit-setups-ldap-directory, nix-unit-setups-lldap-mail, nix-unit-setups-tls-routing, nix-unit-resources-ldap-consumer and nix-unit-resources-ldap-provider pass.
  • SC-003: After a deployment, adminUser logs in through single sign-on and opens the lldap web UI.

Assumptions

  • The wiring and the setup flow are as in 005. The request and policy model is as in 001.
  • The daemon is the lldap component. The main consumer is the authelia component through oidc.
  • Password reset mail goes through the smtp resource. The sender is noreply@users.<domain> from core/setups/wiring/mail-domains.nix.

Known Deviations

The code does not fully follow principles V and VII:

  • The provider node still serves plaintext LDAP on port, and the firewall keeps it open, so that a consumer that has not moved to ldaps:// keeps working during the cutover (#2139). Closing port in the firewall is a later step. lldap-provision connects through the loopback interface, so it does not need port open.
  • A group without a private CA serves plaintext LDAP only.
  • In a group without the OpenBao LDAP engine, the bindDN that fulfill gives is the lldap admin account, and the consumer binds with full admin rights. Only test topologies, such as apps-tf, run without the engine. A separate bind account would not give separation there, because the consumer can read the bindPassword of admin in KV.