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:
- Given a routed request with
group = "authelia-users", When the deployment is evaluated, Then the provider side getscontracts.ldap.want.<consumerName>.default.request.group. - Given a routed request, When the consumer side is evaluated, Then
contracts.ldap.results.<consumerName>.defaultof the node holdsgroup,url,caFile,host,port,baseDN,userBaseDN,groupBaseDN,bindDNandbindPasswordFile. - Given a policy with
domain = "ldap.example.org"and nobaseDN, When the policy is evaluated, ThenbaseDNisdc=example,dc=org. - Given no provider is routed for
ldap, When the deployment is evaluated, Thenapplygives no consumer side and no provider side. - 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 inlldap_admin, and lldap refuses to set the password of anlldap_adminmember. - Given a routed request and
tls.enable = true, When the consumer side is evaluated, Then the result holdsurl = ldaps://<host>:<tls.port>,port = tls.portandcaFile = tls.caFile. - Given a routed request and
tls.enable = false, When the consumer side is evaluated, Then the result holdsurl = ldap://<host>:<port>andcaFile = 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:
- Given the provider node, When
lldapstarts, Then it reads its admin password frombindPasswordFileand resets theadminpassword to that value on each start (force_ldap_user_pass_reset = "always"). - Given
adminUseris set, Whenlldap-provisionruns, ThenadminUseris a member ofauthelia-users,fediversity-adminsandlldap_admin. - Given
emailis held by another account, Whenlldap-provisionruns, Then the unit deletes that account, so it can seedadminUserwithemail. - Given
emailis held byadminor byauthelia-bind, Whenlldap-provisionruns, Then the unit logs that the account is not released, does not delete it, and the apply fails onadminUser. - Given a user, a group or a membership that the deployment did not seed, When
lldap-provisionruns, Then it stays, because the unit changes only the objects in its state. - Given a seeded user, group or membership that already exists and is not in the state, When
lldap-provisionruns, Then the unit imports it into the state and does not create it again. - Given a seeded user or group that leaves the configuration, When
lldap-provisionruns, 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:
- Given a provider node with a vars-openbao backend, When
lldap-publish-directoryruns, Then it writesurl,userBaseDNandmanagementBindDNto<kvPrefix>/public/<bindInstanceName>/directory. - 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:
- Given
fediversity.lldap.smtpis set, When the node is evaluated, Then lldap uses STARTTLS and reads the password from the credentiallldap.smtp-password. - Given the
smtp-passwordcredential is empty, Whenlldapstarts, Then anExecStartPreguard fails the start. - Given the node serves the forward-auth vhost of the lldap UI, When the node is evaluated, Then
http_urlishttps://users.<domain>. - 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/refreshstill 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:
- Given a group with a private CA, When the provider node is evaluated, Then lldap has
ldaps_optionswithport = tls.port, the firewall openstls.portandport, and lldap reads the certificate and key throughLoadCredential. - Given a group with a private CA, When the provider node is evaluated, Then the unit
openbao-pki-<host>.servicegets the leaf for the lldap host, and its renewal restartslldap.service. - 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>. - Given the published URL moves from
ldap://<host>:<port>toldaps://<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 ascertificate. - 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.smtpis set and the configuration setspublicUrlto"": an assertion fails at eval. Withsmtp, the default ofpublicUrlis 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
oidcprovider):http_urlis the direct listenerhttp://<host>:<httpPort>, and the reset token goes in clear. emailis set andadminUseris empty: an assertion fails at eval.- A group with an lldap node does not state
ldap.adminUserin its group data: the eval fails. - The lldap HTTP API is not ready:
lldap-provisionwaits up toLLDAP_READY_TIMEOUT(default 300 seconds) and then fails.Restart=on-failureretries it after 30 seconds. - The login as
adminfails:lldap-provisionlogs 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
porteither, becauseLoadCredentialcannot read the certificate. It starts afteropenbao-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 nocertificate, and the bind of that mount fails until the file arrives.
Requirements (mandatory)
Functional Requirements
- FR-001: The policy MUST derive
baseDNfromdomainwhenbaseDNis not set: it drops the first label and gives onedc=per remaining label. - FR-002: The policy MUST give
userBaseDN = ou=people,<baseDN>andgroupBaseDN = ou=groups,<baseDN>. - FR-003: The policy MUST give a consumer side and a provider side only when a provider is routed for the
ldapcontract. - FR-004: The policy MUST give
mainConfigwithrunDaemon = trueonly when a provider is routed for theldapcontract. - FR-005: The policy MUST generate the vars instance
<bindInstanceName>with the secret filebindPassword(openssl rand -hex 32, ownerroot, mode0444). - FR-006: The
lldapprovider MUST giveurl,caFile,host,port,baseDN,userBaseDN,groupBaseDN,bindDNandbindPasswordFileto each want. Withtls.enable,urlisldaps://<host>:<tls.port>,portistls.portandcaFileistls.caFile. Without it,urlisldap://<host>:<port>andcaFileisnull. - FR-007: The provider node MUST open
port(default3890) andhttpPort(default17170) in the firewall, andtls.port(default6360) withtls.enable. - FR-008:
lldap-provisionMUST create the groupfediversity-adminsand each group of a seeded user, and MUST addauthelia-bindtolldap_password_managerand not tolldap_admin. - FR-009:
lldap-provisionMUST 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-provisionMUST import a seeded user, group or membership that exists and is not in its state. - FR-010: The release pass MUST NOT delete
adminorauthelia-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.ldapof the consumer node, not as an argument to a function of the request. - FR-013: The setups MUST read
adminUserfromldap.adminUserin the group data. The eval MUST fail when a group with an lldap node does not state it. - FR-014: The
lldapcomponent MUST let only the reset routes incore/components/shared/lldap/reset-routes.nixthrough 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.portwith a leaf for its host from theopenbao-pkiprovider of thesslcontract, andtls.caFileMUST 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 setstls.enablewithmkDefault, so a deployment can set it tofalse. The listener and the URL of the consumers then both stay on plaintext LDAP. - FR-016: Each consumer MUST connect to the result
urland verify anldaps://listener against the PKI CA. Authelia verifies through the system trust. The OpenBao LDAP engine gives the CA to the mount ascertificateand to its own bind checks asLDAPTLS_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,groupandconsumerName. 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,bindDNandbindPasswordFile.caFileisnullfor anldap://URL. - ldap policy:
enable,domain,host,port,httpPort,tls(enable,port,caFile),bindInstanceName,adminUser,email,baseDN,routingand the read-onlycrossNode. - lldap provider: the options under
fediversity.lldap, among themrunDaemon,publicUrl,smtp,tls(enable,port,certFile,keyFile,certUnits,caFile),bindPasswordFileandbindPasswordUnits. - Directory coordinates:
url(ldaps://<host>:<tls.port>withtls.enable, elseldap://<host>:<port>),userBaseDNandmanagementBindDN(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-releaseandtest-lldap-mailpass. - 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-consumerandnix-unit-resources-ldap-providerpass. - SC-003: After a deployment,
adminUserlogs 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
lldapcomponent. The main consumer is theautheliacomponent throughoidc. - Password reset mail goes through the
smtpresource. The sender isnoreply@users.<domain>fromcore/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 toldaps://keeps working during the cutover (#2139). Closingportin the firewall is a later step.lldap-provisionconnects through the loopback interface, so it does not needportopen. - A group without a private CA serves plaintext LDAP only.
- In a group without the OpenBao LDAP engine, the
bindDNthatfulfillgives is the lldapadminaccount, and the consumer binds with full admin rights. Only test topologies, such asapps-tf, run without the engine. A separate bind account would not give separation there, because the consumer can read thebindPasswordofadminin KV.