Feature Specification: Component openbao
Created: 2026-10-07
Status: Implemented
Input: Reconstructed from the code at 093e68b6f (core/components/hosting-provider/openbao/default.nix, core/components/hosting-provider/openbao/openbao.nix, core/components/hosting-provider/openbao/loopback-api.nix, core/components/hosting-provider/openbao/pki-init.nix, core/components/hosting-provider/openbao/spire-auth.nix, core/components/hosting-provider/openbao/deployer-policies.nix, core/components/hosting-provider/openbao/cd-oidc.nix, core/components/hosting-provider/openbao/oidc.nix, core/components/hosting-provider/openbao/database-init.nix, core/components/hosting-provider/openbao/valkey-engine.nix, core/components/hosting-provider/openbao/ldap-engine.nix, core/checks/default.nix, core/checks/ci.nix, core/checks/nix-unit-tests.nix), pull requests #1820, #1822, #1853 and #1956, and issues #1759, #1760 and #1817.
User Scenarios & Testing (mandatory)
User Story 1 - Run an unattended secrets backend (Priority: P1)
A hosting provider deploys the openbao component on one node. The node
initializes OpenBao, seals the unseal key to the TPM, and unseals itself at
every boot. No person types an unseal key. The secret KV v2 mount then
holds the vars of the deployment. The component declares
provides = [ "varsBackend" "pki" ]: the node is the store of the
generateFiles secrets and the CA of the openbao-pki provider of ssl.
Why this priority: every other node reads its secrets from this backend. Without it, no credential reaches any consumer.
Independent Test: integrations-spire-openbao boots the spire and
openbao components together. integrations-openbao-pki boots an openbao
whose openbao-init seals its unseal key to a TPM, and shows that the audit
device writes each request to the journal.
Acceptance Scenarios:
- Given a fresh node with a TPM, When
openbao-initruns, Then it initializes OpenBao with one key share, seals that key to the TPM under the persistent handle0x81000001, and enables thesecretKV v2 mount. - Given an initialized node that reboots, When
openbao-initruns, Then it reads the unseal key back from the TPM and unseals OpenBao. - Given an initialized OpenBao whose sealed key or root token is missing, When
openbao-initruns, Then it fails with "the unseal seed is lost" or "the root token is lost and cannot be recovered", and does not initialize again. - Given an unsealed OpenBao, When a client writes and reads a secret, Then the journal of
openbao.serviceholds one JSON request entry and one JSON response entry for each operation, and the secret value is only anhmac-sha256:hash.
User Story 2 - Let each node log in by its SPIFFE identity (Priority: P1)
A node of the deployment presents its SPIFFE X.509 SVID to OpenBao. The
openbao-spire-auth unit writes a host-<name> policy per consumer host, and
the openbao-spire-cert-auth unit registers a cert auth role per consumer
host. The role binds to the SPIFFE ID of that host and attaches its
host-<name> policy.
The policy grants the KV paths of the vars instances that host owns or
reads.
For a host in namespacedHosts (a node of a tenant deployment), the KV,
valkey and LDAP grants are on the objects of its own deployment only. The
policy names that deployment with the cert-auth alias name of the login,
which is the deployment namespace in the CN of the SVID.
Why this priority: this login is how every consumer reads its secrets without a shared token.
Independent Test: integrations-spire-openbao. The nix-unit test
nix-unit-setups-ldap-grant asserts which cert-auth policy gets read on which
group's rotated LDAP bind credential. The nix-unit test
nix-unit-components-openbao-spire-auth asserts the policy text and the
cert-auth role of each roster entry kind for a small roster.
Acceptance Scenarios:
- Given a host in
consumerHosts, Whenopenbao-spire-cert-authruns, Then it writesauth/cert/certs/<role>withallowed_uri_sansset to the SPIFFE ID of the host,token_ttl=1handtoken_max_ttl=24h. - Given a host in
garageConsumerStemsthat is on no roster, When the node evaluates, Then an assertion fails and names that host. - Given the SPIRE trust bundle is not on disk yet, When
openbao-spire-cert-authstarts, ThenConditionPathExistsskips it,openbao-spire-authlogs a warning, and theopenbao-spire-authtimer restartsopenbao-spire-cert-auththroughopenbao-spire-auth-refresh(OnBootSec=5m,OnUnitActiveSec=15m). - Given a host that leaves the roster, When
openbao-spire-authandopenbao-spire-cert-authrun, Then they delete itshost-<name>policy and itscertauth role. - Given an agent on this node whose Workload API fails or returns a bundle with no CA that is valid now, When
openbao-spire-ca-bundleruns, Then it keeps the current bundle and thecertauth roles, and exits 0. - Given a host in
namespacedHoststhat logs in with an SVID whose CN istenant42, When it writesvars/operator-deployments/tenant42/secret/<instance>/<key>, Then OpenBao accepts the write, and it refuses the same write undertenant43, undervars/secret/and undersecret/<host>/. - Given an agent bundle at the
trust_bundle_paththat is newer than the current bundle and has no CA in common with the Workload API (the SPIRE server has a new CA), Whenopenbao-spire-ca-bundleruns, Then it polls the Workload API for at most 180 seconds until the agent attests to that CA, and then installs the bundle and re-seeds thecertauth roles. When the agent does not attest in that time, it keeps the current bundle and the roles, and the next run tries again.
User Story 3 - Issue internal TLS certificates (Priority: P2)
A node needs a certificate for node-to-node TLS. When the deployment wires
the openbao-pki ssl provider, openbao-pki-init creates an internal root
CA and a role that limits issuance to the deployment's domains. OpenBao then
serves an mTLS listener on :8200 with a leaf from its own CA.
Why this priority: internal TLS is optional. Public application vhosts use ACME.
Independent Test: integrations-openbao-pki.
Acceptance Scenarios:
- Given
services.openbao-pki-init.enable, Whenopenbao-pki-initruns, Then it enables thepkimount, writes the roleinternal, generates the root CAFediversity Internal CAonce, and writes the CA to/var/lib/openbao-init/pki-ca.pem. - Given a config without the
pkimount, Whenopenbao-pki-initruns, Then the apply fails onprevent_destroy, and the mount and its root CA stay. - Given a fresh consumer without the CA, When it fetches over plain HTTP on port
8083, Then it gets the CA certificate. - Given the
fediversity.openbao-pkioption exists, When the node evaluates, Then OpenBao listens on0.0.0.0:8200withtls_client_ca_file=/var/lib/openbao-init/spire-ca.pem, and the firewall opens port8200.
User Story 4 - Mint deployer and database credentials (Priority: P2)
CD, a workstation deploy, and the operator deploys need tokens. Applications
need database passwords that rotate. OpenBao mints these through its jwt
and oidc auth methods and its database and ldap secrets engines.
Why this priority: deploys and rotated credentials depend on it, but a node can boot and serve without them.
Independent Test: integrations-cd-jwt-login (CD login on the jwt
role). deployment-cd-env-oidc (CD and workstation token exchange against a
stub OpenBao). test-ldap-engine (rotation of authelia-bind).
test-valkey-service (rotation of the valkey app user by
openbao-valkey-engine, a second apply that changes nothing, and an optional
namespace whose instance does not answer).
Acceptance Scenarios:
- Given
services.openbao-cd-oidc.issuerandrepositoryare set, Whenopenbao-spire-authruns, Then it writes thejwtrolefediversity-deployer, bound to that repository onrefs/heads/main, with a TTL of 30m and a maximum of 2h. - Given a person in the
fediversity-adminsgroup, When that person logs in on theoidc-deployermount, Then the token carries only thefediversity-deployerpolicy. - Given a role in
services.openbao-database-init.roleswithmode = "rotating", Whenopenbao-database-initruns, Then it writes a static role, and deletes a dynamic role of the same name. - Given a dynamic role under
<mount>/roles/thatservices.openbao-database-init.rolesdoes not state asdynamic, Whenopenbao-database-initruns, Then it revokes the leases of that role and deletes it. - Given
ldapEngine.enable, Whenopenbao-ldap-engineruns, Then it mounts theldapengine per directory and rotates theauthelia-bindaccount every24h.
Edge Cases
- The TPM is not ready at boot.
openbao-initwaits for it, and seals the key beforebao operator init, so a failed seal never loses the key of an initialized OpenBao. - A transient TPM read error occurs at unseal.
tpm_unseal_key_retryingretries the read. - The storage is the old
filebackend. TheExecStartPrestepopenbao-migrate-file-to-raftmoves it to raft at/var/lib/openbao/raft. - A node of an earlier release has no audit device.
restartIfChanged = falsekeepsopenbao.serviceup on a switch, and a reload reads the old config again, so OpenBao adds the device at the next start ofopenbao.service. - The options of the
journal/audit device in the config differ from the stored device. OpenBao refuses to unseal. To change an option, the config gives the device a new name, and OpenBao then adds the new device and removes the old one. - journald does not accept the write of an audit entry. OpenBao refuses the request, or waits until journald accepts the write.
- The OIDC issuer is not up yet.
openbao-oidc-authrestarts every30s, andStartLimitIntervalSec = 0keeps it fromstart-limit-hit. - The OIDC client secret arrives empty.
openbao-oidc-authrejects the empty value and fails the start. - The OIDC issuer chains to the private PKI CA.
openbao-oidc-authsetsoidc_discovery_ca_pemto the system anchors plus that CA. - The
postgresql-adminpassword is not rendered yet. The systemd credential ofopenbao-database-initfails the start, andRestart=retries it. - A shell oneshot of an earlier release created a mount, an auth mount, an identity group or a group alias, and no state holds it. The reconciler writes an
importblock for it before the apply, so the apply adopts it and does not fail on a create. - An object that a shell oneshot of an earlier release created, and that no config names, stays: no state holds it, so no apply deletes it.
- The valkey node of one required namespace has not published its address.
openbao-valkey-engineconfigures the other namespaces, then fails, so that it starts again. - One optional namespace (an operator deployment's) has a password in KV, but no address, or nothing answers at its address. This is also the case when the deployment is gone and its KV records stay.
openbao-valkey-enginekeeps the objects of that namespace that its state holds, or configures none, and does not fail. The timeropenbao-valkey-engineruns the engine again every 2 minutes. - The valkey node of one namespace is not deployed, so KV holds no password for it.
openbao-valkey-engineconfigures no objects for that namespace, deletes those that it configured before, and does not fail. - The operator deployer token in KV still renews.
openbao-operator-deployer-tokenkeeps it and mints no new token. - The node or its TPM is lost. The raft snapshots in
openbao-backupsdo not restore, because the unseal key is sealed only to that TPM and no copy of it exists elsewhere. The secrets that only KV holds are then lost: the app encryption keys ofmastodonandpixelfed, the DNSSEC and TSIG keys, and the PKI CA. Thus a restore of the app databases cannot decrypt their encrypted columns.
Requirements (mandatory)
Functional Requirements
- FR-001: The component MUST register as
components.openbaowithname = "OpenBao",package = pkgs.openbaoandcertAudience = "bootstrap". - FR-002: The component MUST take the shared options with the default subdomain
openbao, and the optionuiDomainwith the defaultsecrets.<apex>. - FR-003: When enabled, the component MUST request a SPIFFE identity (see
../../007-resources/spiffe.md), a TLS certificate fordomainanduiDomain(see../../007-resources/ssl.md), an OIDC clientopenbao(see../../007-resources/oidc.md), and anixos-modulethat importsopenbao.nix(see../../007-resources/nixos-module.md). - FR-004: The OIDC request MUST allow the UI callback on both
domainanduiDomain, andhttp://localhost:8250/oidc/callback, with the scopesopenid,profile,emailandgroups. - FR-005: OpenBao MUST serve its admin API on the loopback address
http://127.0.0.1:8250, and every engine unit MUST use that address asVAULT_ADDR. - FR-006: OpenBao MUST use raft storage at
/var/lib/openbao/raftwithcluster_addrhttps://127.0.0.1:8251. - FR-007: nginx MUST proxy
domainanduiDomainto the loopback API. The implementation sets both settings as the optionsfediversity.openbao.domainandfediversity.openbao.uiDomainthatopenbao.nixdeclares. - FR-008:
openbao-initMUST initialize with one key share, seal the key to the TPM, keep the root token at/var/lib/openbao-init/root-token, unseal, and enable thesecretKV v2 mount. - FR-009:
openbao-initMUST fail, and MUST NOT initialize again, when OpenBao is initialized but the sealed key or the root token is missing. - FR-010: When the
fediversity.openbao-pkioption exists, the node MUST serve an mTLS listener on0.0.0.0:8200that trusts the SPIRE CA at/var/lib/openbao-init/spire-ca.pem. - FR-011:
openbao-pki-initMUST create thepkimount, the root CA and the roleinternal, limited toallowedDomains, and MUST serve the CA over plain HTTP oncaFetchPort. - FR-012:
openbao-spire-authMUST write onehost-<name>policy per roster host, andopenbao-spire-cert-authMUST write onecertauth role per roster host, bound to the SPIFFE ID of that host, with that policy. - FR-013: Each host that
garageConsumerStemsnames MUST be on the roster, also when it owns no instance, so that its policy carries the read grant. - FR-014:
openbao-spire-cert-authMUST skip, not fail, when the SPIRE trust bundle is missing, and theopenbao-spire-authtimer MUST restart it.openbao-spire-authMUST NOT wait for the bundle. - FR-015: The policies
fediversity-deployerandfediversity-operator-deployerMUST come fromdeployer-policies.nix, the same text the checkdeployment-tf-incus-operatoruses. - FR-016:
openbao-operator-deployer-tokenMUST keep a periodic token with thefediversity-operator-deployerpolicy atvars/secret/deployer/openbaoToken, with a period of768h, and renew it daily. - FR-017: When
services.openbao-cd-oidc.issueris set, OpenBao MUST admit a forge Actions token asfediversity-deployeronly forrepositoryonrefs/heads/main. - FR-018:
openbao-oidc-authMUST configure theoidcauth method with the roleoperator, map thegroupsclaim valuefediversity-adminsto thefediversity-adminspolicy, and grant that policy write only onsecret/data/vars/public/domain-claims/*. - FR-019:
openbao-oidc-authMUST configure a separateoidc-deployermount whose role gives onlyfediversity-deployer, for members offediversity-admins. - FR-020:
openbao-database-initMUST configure the postgres connection onservices.openbao-database-init.hostport5432withsslMode = "require", and MUST write a static role forrotatingand a dynamic role fordynamicroles. - FR-021:
openbao-valkey-engineMUST writedatabase/config/valkey-<key>anddatabase/static-roles/<key>for each instance whose password and address are in KV, and MUST delete them for each other instance. For an entry withrequired = true,<key>is<ns>and KV is at the prefix of the hosting provider. For an entry withrequired = false, there is one instance per deployment<id>underdeploymentVarsRootthat has a password for it,<key>is<ns>-<id>, and KV is at<deploymentVarsRoot>/<id>. For an entry withrequired = false, it MUST first open a TCP connection to the address. When KV has no address or the connection fails, it MUST keep the objects as its state holds them, MUST NOT fail, and a timer MUST run it again. - FR-022:
openbao-ldap-engineMUST mountldap/for the directory of the central group (the one group whose roster providesvarsBackend) andldap-<group>/<namespace>/per deployment, and rotate theroleNameaccount everyrotationPeriod. It MUST delete the mount of each namespace whose KV underdeploymentsKVis gone. It MUST keep a live mount that its state holds while the inputs of that directory are missing, except for a deployment mount. The management-bind password MUST go to OpenBao as a write-only value, so the state holds only a digest of it. An LDAP engine mount with noconfigMUST be made again, not imported. - FR-023: A
host-<name>policy MUST read the submission passwords of a relay only when the setup names that relay's instance invarsReadInstancesfor it. The setup names it for each node of the relay's own group that sends mail: a node whose component requestssmtp, and the providers ofoidcandldap. Only the hosts inservices.openbao-spire-auth.smtpRelayHostsMUST read the upstream submission password of the smarthost. - FR-023a: A
host-<name>policy MUST read a vars instance that another host writes only whenvarsReadInstances,oidcConsumerInstancesors3PairReadInstancesnames that instance for it, or a dedicated roster (incusStoreConsumerHosts,operatorDnsConsumerHosts,acmeDnsConsumerHosts) names the host. An instance MUST NOT be readable by every host. - FR-023b: For a host in
namespacedHosts, thehost-<name>policy MUST grant KV only under<deploymentVarsRoot>/<deployment>, valkey credentials only atdatabase/static-creds/<ns>-<deployment>and LDAP credentials only on the mount of<deployment>, where<deployment>is the policy template of the cert-auth alias name. It MUST NOT grant the per-host stemsecret/<host>/, the sharedvars/instances, or the objects of another deployment. The one exception is the read on the upstream submission password of the smarthost that FR-023 gives to a relay host. - FR-024: The component MUST declare
provides = [ "varsBackend" "pki" ], so the setups route the secrets store and the PKI CA of the group to this node. - FR-025: Each entry of
services.openbao.runtimeMUST be anopenbao-<name>oneshot that applies its collections with OpenTofu and thehashicorp/vaultprovider againstservices.openbao.runtimeApi.address, with local state at/var/lib/openbao-runtime/<name>/declarative-terraform. - FR-026: A reconciler MUST delete from OpenBao each object that leaves its config, except an object with
lifecycle.prevent_destroy. Thepkimount ofopenbao-pki-initand thedatabasemount ofopenbao-database-initMUST haveprevent_destroy. - FR-027: Before each apply, a reconciler MUST import each mount, auth mount, identity group and identity group alias of its config that OpenBao holds and its state does not.
- FR-028: The setups MUST give the directories of the operator groups to
openbao-ldap-engineonly on the node of the central group. - FR-029: The implementation MUST request
garagethroughmkBackupsGarageRequestwith the bucketopenbao-backups, andbackup.nixMUST add astreamingBackupof a raft snapshot to that bucket with the restic provider, as the userroot, with the root token over the loopback API. - FR-030: OpenBao MUST enable the audit device
journal/from its config, of typefilewithfile_path = "stdout"andelide_list_responses = "true", and MUST keep the default HMAC of secret values.openbao.serviceMUST setLogRateLimitIntervalSec = 0, so that journald does not remove audit entries. - FR-031: On a node with a SPIRE agent,
openbao-spire-ca-bundleMUST read the trust bundle from the Workload API of that agent, and from the agent'strust_bundle_pathonly when the current bundle has no CA that is valid now. It MUST NOT install a bundle in which a block does not parse or no CA is valid now. When the agent'strust_bundle_pathis newer than the current bundle and has no CA in common with the Workload API, it MUST wait at most 180 seconds for the agent to attest to that CA. It MUST NOT install the agent'strust_bundle_pathin place of a current bundle that has a CA that is valid now. It MUST restartopenbao-spire-cert-authonly when the bundle changes.
Key Entities
openbao-init: the oneshot that initializes, seals to the TPM, and unseals OpenBao. It holds the root token on disk.- Reconciler: an entry of
services.openbao.runtime(runtime/module.nix), the oneshotopenbao-<name>. The reconcilers areopenbao-spire-auth,openbao-spire-cert-auth,openbao-oidc-auth,openbao-pki-init,openbao-database-init,openbao-valkey-engineandopenbao-ldap-engine. Each has local state, because the HTTP state back-endtf-backendis served with a certificate from this OpenBao's PKI and is deployed after it. The shell oneshotsopenbao-initandopenbao-operator-deployer-tokenstay shell. - Roster:
services.openbao-spire-auth.consumerHosts,varsProviderHosts,varsReadInstances,garageConsumerStems,s3PairReadInstancesandoidcConsumerInstances. Each key becomes acertauth role and ahost-<name>policy.consumerHostsis empty by default: the setup derives the other rosters from the components that each node runs. The setup adds tovarsReadInstancesthe readers that no component request states: the deploy runners readincus-deployand the deploy credential instances, and the database-engine node readspostgresql-admin.s3PairReadInstancesgives the garage node read on the key pairs of its own group that another node generates. - SMTP relays:
services.openbao-spire-auth.smtpRelayHosts(listOf str, default[]), the roster labels of the SMTP relays. The setup sets it to the label of each node whose component providessmtp, in each group that it collects (supportingRequests.smtpRelayRoster). fediversity-deployer: the policy of a CD or workstation deploy token.fediversity-operator-deployer: the policy of the periodic token for operator deploys.fediversity-admins: the identity group and policy of a person who logs in to the UI.- Engines:
pki(internal CA),database(postgres and valkey),ldap(bind account rotation).
Success Criteria (mandatory)
Measurable Outcomes
- SC-001:
integrations-spire-openbaopasses. - SC-002:
integrations-openbao-pkipasses. - SC-003:
integrations-cd-jwt-loginpasses, including the refusals of apull_requestrun, another branch, another repository and another audience. - SC-004:
deployment-cd-env-oidcandtest-ldap-enginepass. - SC-005:
nix-unit-setups-ldap-grant,nix-unit-components-openbao-spire-authandnix-unit-components-openbao-initpass. - SC-006: After a reboot, OpenBao is unsealed with no human step.
Assumptions
- The node has a TPM 2.0 with the storage hierarchy enabled.
- The setup supplies the roster options from the requests of each component, as described in
../../005-setups-and-wiring/spec.md. - The
spirecomponent (spire.md) gives this node its SVID.openbao-spire-ca-bundlereads the trust bundle from the Workload API of the agent on this node, or from the agent'strust_bundle_paththat the deploy writes, and keeps the last valid bundle when both fail. - The
postgresqlcomponent (postgresql.md),../shared/valkey.mdand../shared/lldap.mdserve the backends the engines manage. - The
autheliacomponent (../shared/authelia.md) is the OIDC issuer for the UI login. deploy-credentials.nixandoperator-curation.nixare not part of this component.windmill/curation.niximports both, so their spec belongs with thewindmillcomponent.
Known Deviations
- Hidden state: the root token persists at
/var/lib/openbao-init/root-token, and the engine units read it. The state of each reconciler persists at/var/lib/openbao-runtime/<name>/declarative-terraformand holds the secrets its config writes, such as the OIDC client secret. A TODO inopenbao.nix(notes/bootstrap-seeds.md) plans scoped tokens instead. - The audit log is not tamper-evident. A user with root on the node can change the journal before the node log shipper sends it to Loki, and Loki is not write-once storage. The audit log has the retention of the journal and of Loki, not a retention of its own (#2270). An audit entry of a list response holds the count of the keys, not the keys.
- FR-023a: without the LDAP engine (
ldapEngine.enable = false), each roster host reads eachldap-bind-<group>instance. The setups always enable the engine, so only a use of the component outside the setups has this grant (#2291). - The backup of this component uses the fixed and public restic password
ReallySafePasswordTrustMe39, because a restore of this backup comes before the secret store can give a password (#2102). Thus the encryption of this repository gives no protection.