39 KiB
032 - OpenBao Secrets Manager with Keycloak OIDC, RBAC Policies and Static-Seal Auto-Unseal
User Story
As an Infinito.Nexus administrator, I want OpenBao deployed as a web-app-openbao role with Keycloak OIDC login, group-mapped policies, persistent storage, automatic unsealing, backup and monitoring, so that infrastructure secrets, application credentials, certificates and tokens can be stored and retrieved centrally without distributing static secrets across systems.
Upstream story: OpenProject #628 — Integrate OpenBao into Infinito.Nexus. Implementing PR: infinito-nexus/core#611.
Background
OpenBao is the Linux-Foundation-governed fork of HashiCorp Vault: an identity-based secrets and encryption manager with a KV store, a PKI engine, pluggable auth methods and a built-in web UI.
Upstream facts established by research
| Topic | Finding | Source |
|---|---|---|
| Image | openbao/openbao on Docker Hub. Tags carry no v prefix (2.6.2, not v2.6.2), unlike the GitHub release names. |
Docker Hub tags |
| Version | 2.6.2, released 2026-08-18, is the newest non-prerelease. |
Releases |
| Seal types | alicloudkms, awskms, azurekeyvault, gcpckms, kmip, ocikms, ovhcloud, pkcs11, static, tcloudpublic, transit. From v2.7.0 many become external plugins; static and transit stay built in. |
seal stanza |
seal "static" |
Takes a caller-supplied 32-byte AES-256-GCM-96 key via current_key, accepting a literal value or an env:// / file:// reference, plus a current_key_id. Upstream caveat: recommended only where an existing source of trust already holds the key. |
static seal |
| Auto-seal semantics | With any auto-seal configured, bao operator init yields recovery keys, not unseal keys, and the node unseals itself on every restart by asking the seal to decrypt the root key. Recovery keys authorise generate-root / rekey; they cannot decrypt the root key on their own. |
seal concepts |
| Keycloak OIDC | Confidential client; three redirect URIs: /v1/auth/oidc/callback (API), /ui/vault/auth/oidc/oidc/callback (UI), http://localhost:8250/oidc/callback (CLI). Configured through auth/oidc/config + auth/oidc/role/<name>. |
keycloak.mdx |
| Group mapping | The OIDC role's groups_claim (JSON-pointer capable) names the claim holding the user's groups; those values resolve against identity groups of type=external via group aliases on the auth mount, which carry the policies. |
JWT/OIDC auth |
| AppRole custom ids | POST auth/approle/role/<name>/role-id accepts a caller-supplied role_id (logical.UpdateOperation), and POST auth/approle/role/<name>/custom-secret-id accepts a caller-supplied secret_id. Both verified in path_role.go (lines 889 and 1243). |
upstream source |
| Health | GET /v1/sys/health returns 503 when sealed and 501 when uninitialised. The query params sealedcode, uninitcode, standbyok override those codes per request. |
/sys/health, api/sys_health.go |
| Metrics | GET /v1/sys/metrics?format=prometheus. Normally token-gated; a listener with telemetry { unauthenticated_metrics_access = true } exposes it without a token on that listener only. |
/sys/metrics |
In-repo analogues
- Native OIDC flavor (
services.sso.flavor: oidc), not the oauth2-proxy gate: OpenBao speaks OIDC itself, so it followsweb-app-semaphorerather thanweb-app-openproject. - RBAC via
meta/rbac.yml+ therbac_group_pathlookup, so the Keycloakgroupsclaim arrives as/roles/web-app-openbao/<role>and OpenBao binds those paths to policies. - Native Prometheus metrics via the
services.prometheus.native_metrics+templates/prometheus.yml.j2contract already used byweb-app-gitea, scraped on the internal container port over the role-local docker network. - In-container configuration follows
svc-db-elasticsearch/tasks/01_init.yml: a shell exec into the container where the credential arrives through a container env var, never through Ansible's process arguments, guarded byno_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}". - No external database. OpenBao's integrated raft storage owns all state on one persistent volume.
Confirmed Decisions
These decisions were confirmed by the operator before implementation starts and are NOT subject to re-litigation during implementation.
| # | Decision | Rationale |
|---|---|---|
| 1 | Seal is seal "static" with a 32-byte key generated by the repo credential generator into the ansible-vault-encrypted inventory and injected as env://OPENBAO_SEAL_KEY. |
OpenBao auto-unseals on every restart, so deploy and redeploy stay idempotent and no OpenBao-generated key material has to be written back into the inventory. The inventory is the "existing source of trust" upstream's caveat asks for. |
| 2 | Ansible authenticates via AppRole with caller-set ids. First init uses the root token in memory only, enables AppRole, writes the inventory-generated role_id and secret_id through role-id / custom-secret-id, then revokes the root token. Later deploys log in with those inventory credentials. |
Zero write-back and no permanently stored root token, satisfying "minimize the requirement for permanently stored bootstrap credentials". |
| 3 | Storage backend is raft (integrated storage), single node, on one persistent volume. |
Upstream-recommended, keeps the door open for the HA follow-up story, and needs no external database. |
| 4 | PKI engine ships behind a flag, default off. When on, the play mounts pki, generates an internal root CA and one issuing role. |
Proves the architecture supports the PKI story without mounting an unused CA that would still need backup, rotation and monitoring from day one. |
| 5 | LDAP auth is included, behind the ldap service flag, mapping the same LDAP role groups to the same three policies; OIDC stays the default. |
Matches the beta-lifecycle IAM rule and leaves a login path when Keycloak is unavailable — which matters more for a secrets store than for a normal app. |
| 6 | RBAC roles are administrator / operator / reader. |
The OIDC group path /roles/web-app-openbao/<role> already namespaces by application, so an openbao- prefix would be redundant, and the repo's other meta/rbac.yml files use bare role names. |
| 7 | Lifecycle on merge is alpha. |
Honest for a brand-new role using a non-default seal. The role does wire OIDC and LDAP by default, so promotion to beta is a follow-up decision, not a rewrite. |
| 8 | Metrics via unauthenticated_metrics_access = true on the internal listener, scraped on the internal container port over the role-local docker network only — never through the proxy, never public. |
The metrics endpoint needs a server-generated token that cannot be pre-seeded into a static scrape config from the inventory. Same pattern as web-app-gitea. Gives the openbao_core_unsealed gauge. |
| 9 | Listener runs tls_disable = true; TLS terminates at sys-svc-proxy. |
Public exposure stays TLS-only as the story requires, nothing is skip-verified, and the plaintext hop never leaves the per-role docker network. Consistent with every other web-app-* role. |
| 10 | CLI OIDC login (bao login -method=oidc, http://localhost:8250/oidc/callback) is out of scope. |
The redirect_uris lookup generates https://<domain>/* per SSO consumer, which covers the UI and API callbacks but not a localhost one. Machine access goes through AppRole (Decision #2) instead. Documented as a limitation in the README. |
| 11 | Both deployment modes are supported (modes.compose.enabled: true, modes.swarm.enabled: true), with swarm running a single node pinned to the manager. |
Every stateful role in the repo keeps both modes on; only host-level and infrastructure roles disable swarm. See deployment-modes.md. |
| 12 | The raft volume is nfs: false and the service carries placement: manager. |
Raft's BoltDB store is mmap + fsync based, so it hits exactly the locking and fsync semantics that keep the database engines off NFS. The deployment-modes volume model requires a placement constraint for any non-NFS volume under swarm, so the data cannot be lost on a reschedule. Same shape as svc-db-postgres and the other engines. |
| 13 | service_update_order = 'stop-first' is set in the role's compose template. |
Docker defaults to stop-first, but a rolling update that ever started a second task against the same raft directory would corrupt the store, so the role states it explicitly rather than inheriting it. Precedent: svc-prx-openresty. |
Target Schema
Role layout
roles/web-app-openbao/
├── README.md
├── files/playwright/{playwright.spec,test-baseline,test-guest-persona,test-biber-persona,test-administrator-persona,test-oidc-login,test-ldap-login,test-rbac-denial,test-seal-status}.js
├── meta/{main,info,services,domains,csp,networks,rbac,secrets,users,variants,volumes}.yml
├── tasks/{main,00_core,01_init,02_auth,03_policies,04_auth_methods,05_pki}.yml
├── tasks/utils/group_alias.yml
├── templates/{compose.yml.j2,env.j2,openbao.hcl.j2,prometheus.yml.j2,playwright.env.j2}
├── templates/policies/{ansible-admin,administrator,operator,reader}.hcl.j2
└── vars/main.yml
The policy .hcl templates are rendered to the host config directory and mounted at /openbao/policies/, deliberately outside /openbao/config/: the server parses every file in its config directory as server configuration, so a policy file there breaks startup.
meta/services.yml excerpt
sso:
bond: 1
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
flavor: oidc
ldap:
bond: 1
enabled: "{{ 'svc-db-openldap' in group_names }}"
shared: "{{ 'svc-db-openldap' in group_names }}"
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
shared: "{{ 'web-app-prometheus' in group_names }}"
native_metrics:
enabled: true
port: 8200
openbao:
# nocheck: default-placement-manager raft's BoltDB store is mmap + fsync based and cannot live on NFS, so the node is pinned in swarm
placement: manager
# nocheck: single-replica raft single-node; a second replica would need a real HA join flow
replicas: 1
healthcheck:
flavor: wget
path: "v1/sys/health?uninitcode=200&sealedcode=200&standbyok=true"
modes:
compose:
enabled: true
swarm:
enabled: true
pki:
enabled: false
cpus: 0.5
mem_reservation: 128m
mem_limit: 512m
pids_limit: 512
image: openbao/openbao
version: "2.6.2"
name: openbao
ports:
internal: { http: 8200 }
local: { http: <allocate via the suggester> }
backup:
no_stop_required: false
run_after:
- svc-db-openldap
- web-app-keycloak
lifecycle: alpha
pki is inlined under the primary entity, not declared as a service key: it is a role-local feature toggle with no provider role, so a pki: service key would fail service resolution.
meta/volumes.yml
data:
type: volume
# nocheck: nfs-false-data-loss Reason: raft/BoltDB uses mmap + fsync and corrupts on NFS; single-replica and pinned via placement: manager, so no reschedule can strand the volume.
nfs: false
mounts:
- service: openbao
target: /openbao/data
meta/rbac.yml
roles:
administrator:
description: OpenBao administrator (policy, auth-method and mount management)
operator:
description: OpenBao operator (read/write on the application secret paths)
reader:
description: OpenBao reader (read-only on the application secret paths)
meta/secrets.yml
credentials:
seal_key:
description: 32-byte key for the OpenBao static seal, injected as env://OPENBAO_SEAL_KEY. Every backup of the storage volume is undecryptable without it.
algorithm: random_hex_32
approle_role_id:
description: RoleID of the Ansible AppRole, written to auth/approle/role/ansible/role-id at bootstrap.
algorithm: alphanumeric
approle_secret_id:
description: SecretID of the Ansible AppRole, written to auth/approle/role/ansible/custom-secret-id at bootstrap.
algorithm: alphanumeric
random_hex_32 yields 64 hex characters = exactly the 32 bytes the static seal requires, with no prefix to strip at render time (unlike base64_prefixed_32, which prepends base64:).
templates/openbao.hcl.j2 shape
ui = true
storage "raft" {
path = "/openbao/data"
node_id = "{{ ... }}"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = true
telemetry { unauthenticated_metrics_access = true }
}
seal "static" {
current_key_id = "{{ ... }}"
current_key = "env://OPENBAO_SEAL_KEY"
}
api_addr = "https://{{ lookup('domain', application_id) }}"
cluster_addr = "http://127.0.0.1:8201"
cluster_addr MUST NOT be built from the container name. Compose sets container_name: explicitly, but swarm names every task <stack>_<key>.<slot>.<taskid>, so a container-name-derived address renders differently per mode. With replicas: 1 the node only ever advertises to itself, so a loopback cluster address is mode-independent; the HA follow-up story revisits it.
Acceptance Criteria
Role layout & image
roles/web-app-openbao/exists per the Target Schema, and everymeta/*.ymlpasses the role-meta lint.- The image is pinned to
openbao/openbao:2.6.2(no:latest, novprefix). ports.local.httpandnetworks.local.subnetare allocated withcli contributing network ports suggest/address suggest, not hand-picked, and collide with no existing role. Allocated8081(gap-first) and192.168.221.0/24(a new umbrella; every established/24was taken).
Routing & TLS (Decision #9)
openbao.{{ DOMAIN_PRIMARY }}resolves throughsys-svc-proxyto the OpenBao listener;GET /returns the OpenBao UI over HTTPS. (test-baseline.js; in the tor-enabled variants the canonical family is the onion, so the surface ishttp://openbao.<onion>.)- The listener runs with
tls_disable = trueand is reachable only on the role-local docker network and the localhost-boundports.local.http; it is not bound on0.0.0.0of the host. - No TLS verification is disabled anywhere in the role (
insecure,skip_verify,-kand equivalents are absent; the LDAP auth method is configured withinsecure_tls=false). - The role emits a
Content-Security-Policyheader on the canonical surface, and the OpenBao UI renders without CSP violations under it. (test-guest-persona.jsasserts the CSP injections; the UI renders and the in-pagefetchto/v1/...is not blocked.)
Deployment modes (Decisions #11, #12, #13)
modes.compose.enabledandmodes.swarm.enabledare bothtrue, and the role deploys green in both modes. Swarm verified on a liveswarm-zombiecluster (manager + 2 workers + backup node + NFS server):openbao_openbaoconverged1/1alongside Keycloak, OpenLDAP, OpenResty, Postgres and Tor, and the service reportsinitialized: true, sealed: false, recovery_seal: true, storage_type: raft— so the static seal, init and AppRole bootstrap all work unchanged under swarm.- The service carries
placement: managerwith the raft/NFS reason in its# nocheck: default-placement-managercomment, so the swarm task cannot be rescheduled away from its volume. Confirmed on the live cluster:docker service inspect openbao_openbaoreportsPlacement.Constraints ["node.role == manager"]. - The raft volume is declared
nfs: falsewith a# nocheck: nfs-false-data-lossreason; no OpenBao state is placed on an NFS-backed volume in any mode. - The role's compose template sets
service_update_order = 'stop-first', so a swarm rolling update never runs two tasks against the same raft directory. Confirmed on the live cluster: the service'sUpdateConfig.Orderisstop-firstwithReplicas 1. cluster_addrrenders identically in both modes and is not derived from the container name (see the listener/seal schema note). Confirmed in the rendered config in both modes: compose and the swarm task both carrycluster_addr = "http://127.0.0.1:8201", with raftpath = /openbao/fileandnode_id = openbao-1.- Every in-container configuration task reaches the container through
resolve_host_cid.yml—container exec <resolved CID>underansible.builtin.shell, delegated to the resolved_HOST_NODE— so the init, auth and policy tasks work unchanged under swarm. This supersedes thelookup('container_address', …)mechanism first named here: that lookup resolves only the container id and refuses to run on a non-manager node, whereas the resolver also yields the hosting node and waits for swarm convergence, which the init step needs anyway beforebao operator init. - The role uses no node-local
force_bridgenetwork, so the swarm Prometheus can reach the native-metrics target (thenative_metrics_appslookup gatesforce_bridgeapps out of the scrape). Confirmed by the deploy pre-creating both the overlay and bridge networks forweb-app-openbaofromweb-app-prometheus. make swarm-playwright role=web-app-openbaopasses the same specs as the compose run. Not run:swarm-playwrightrequires the staging directory a completed deploy creates, and theswarm-zombiedrill was cut short by the runner budget on this machine before its Playwright step. What the specs assert was instead verified directly against the live swarm service —/v1/sys/healthreturnsinitialized: true, sealed: false, and/v1/sys/metricsthrough the proxy returns 403 on the onion vhost — but the spec suite itself still needs one full swarm run.
Storage & persistence (Decision #3)
- Raft integrated storage is configured with its data directory on the named volume declared in
meta/volumes.yml. Rendered config:storage "raft" { path = "/openbao/file" }, backed by theopenbao_datavolume. docker compose down && up(container recreated, volume retained) leaves a previously written secret readable. Verified by restarting the container on the variant-2 stack: it came backinitialized: true, sealed: falsewith no operator action, and the pre-restart secret, the PKI root CA and all five policies were still present.- Under swarm, a service update (
docker service update --force) leaves a previously written secret readable and the instance auto-unsealed. The swarm service runs from the namedopenbao_datavolume withstop-firstordering andreplicas: 1, and reportsinitialized: true, sealed: falseafter converging; both volumes are named (openbao_data,openbao_logs), so no state rides on an anonymous volume that a reschedule could strand. - No OpenBao state is written into the container's ephemeral layer: the data directory is the only writable state path and it is the mounted volume.
Seal, init & bootstrap (Decisions #1, #2)
- The
seal "static"stanza reads its key viaenv://OPENBAO_SEAL_KEY; the key is ameta/secrets.ymlcredential and never appears literally in a template, a task argument or a committed file. - A restarted container comes back unsealed with no operator action (
/v1/sys/seal-statusreportssealed: false). Asserted bytest-seal-status.js(sealed: false,recovery_seal: true) after the deploy recreated the container; the play's own "wait until the static seal has auto-unsealed" step gates every later task on it. - First deploy runs
bao operator initexactly once; a second deploy against an already-initialised instance detects that state and does not re-init. Confirmed across three consecutive deploys. - The play enables AppRole, sets the Ansible
role_idandsecret_idfrom the inventory credentials viarole-id/custom-secret-id, and revokes the root token before the play ends;bao token lookupon the old root token fails afterwards. - A second deploy authenticates purely with the AppRole credentials and reconciles policies, auth methods and mounts idempotently (
changed=0on an unchanged re-run). AppRole-only re-authentication was verified across consecutive deploys after the root token was revoked.changed=0now holds: the reconciliation tasks derivechanged_whenfrom real state (read-before/read-after around each write; a marker on the enable-branch of each… || enableguard), and on the third consecutive identical run noweb-app-openbaotask reported changed. Two bugs were found and fixed while measuring this:grep -q … || enablereturns rc 0 in both branches, sochanged_when: rc == 0fired unconditionally; andbao read -format=jsonembeds a per-requestrequest_id, so a naive before/after compare could never match. - Neither the root token, the recovery keys, the seal key nor the AppRole
secret_idappear in the output of a normal-vdeploy run: every task touching them carriesno_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}". git grepfinds no root token, recovery key or seal key value in the repository.
OIDC / Keycloak
- The Keycloak client is provisioned automatically through the existing client-import path; no manual Keycloak setup is required.
- The generated redirect URIs cover both
/v1/auth/oidc/callbackand/ui/vault/auth/oidc/oidc/callback(theredirect_uriswildcard), and the OpenBao OIDC role'sallowed_redirect_urislists them explicitly. Proven by probingauth/oidc/oidc/auth_url: the UI callback returns a signed Keycloak URL while a non-listed URI returnsauth_url n/a. auth/oidc/configis written fromOIDC.CLIENT.*values only — no hardcoded realm name, issuer URL, endpoint or claim string anywhere in the role.- The user identifier is templated from
{{ OIDC.ATTRIBUTES.USERNAME }}; the literalpreferred_usernamedoes not appear in the role. - A user logs in at
https://openbao.{{ DOMAIN_PRIMARY }}/through the Keycloak chain and lands authenticated in the OpenBao UI. (test-oidc-login.js, 6.5 s.) - Logout terminates the OpenBao session and integrates with the
logoutservice when enabled. The integration half is done and verified: the deploy activates the logout proxy for the openbao vhost, injects the universal-logout code with its CSP hash, and includes openbao inLOGOUT_DOMAINS. The termination half is not asserted, and an attempt to assert it surfaced a finding now recorded in the README — navigating to/ui/vault/logoutleaves the session intact (the UI returns to/ui/vault/secrets, since the Ember app restores its token from browser storage). Proving termination needs the UI's own logout control driven from an authenticated session, which the persona blocks make awkward; the assertion was reverted rather than left failing.
LDAP (Decision #5)
- When
svc-db-openldapis ingroup_names, theldapauth method is enabled and reads its attribute names, DNs and URI fromLDAP.*only. - An LDAP user in the
administratorrole group logs in through theldapauth method and receives theadministratorpolicy. (test-ldap-login.js, assertsauth.policiescontainsadministrator.)
RBAC / policies (Decision #6)
meta/rbac.ymldeclaresadministrator,operatorandreader; the LDAP groups and the Keycloak group tree are provisioned by the existing RBAC machinery. Deploy createdcn=web-app-openbao-{administrator,operator,reader},ou=roles,…plus theldap-roles-web-app-openbaoKeycloak mapper.- Three OpenBao policies of the same names exist, written from templates, following least privilege:
readerhas read-only capability on the application secret paths,operatoradds create/update/delete there, and onlyadministratormay touchsys/policies,sys/authandsys/mounts. - The group→policy mapping is driven by
lookup('rbac_group_path', application_id='web-app-openbao', role='<role>'); no group path is assembled inline. - A user who authenticates successfully but belongs to no OpenBao role group receives only the
defaultpolicy and reaches no administrative surface. (test-ldap-login.jsasserts biber receives neitheradministratornoroperator.) - A
readeris denied a write to an application secret path, and denied read on a path outside their policy, with a 403 from OpenBao. Verified with a token actually bound toreader(minted through an application AppRole, since the revoked root and the child-policy-subset rule make token creation fromansible-adminimpossible by design): read on its own path returned the value,kv putreturned403 permission denied, andread sys/authreturned403 permission denied.
Secrets engine & machine identity
- A KV v2 secrets engine is mounted at a configurable path; the mount path is an Infinito.Nexus variable, not a literal in a task. (
OPENBAO_KV_MOUNT, mounted atinfinito.) - A secret is created and read back through an authenticated session, and the round-trip is asserted by the test suite. Verified against the live instance via an AppRole session:
bao kv put infinito/probefollowed bybao kv getreturned the written value. - Machine access is configured independently of the human OIDC path: the AppRole auth method exists with its own policy, and an application AppRole can obtain a token without any human login and without the Ansible AppRole's credentials. Verified:
bao auth listshowsapprole/alongside the humanldap/(andoidc/when SSO is on), and a provisionedprobe-readerAppRole logged in and received areader-scoped token with no human involvement. - Tokens issued to machine identities carry a finite TTL; no non-expiring shared administrator token is created. The Ansible AppRole issues
token_ttl1800 s withtoken_max_ttl3600 s, and the root token is revoked at bootstrap.
PKI (Decision #4)
services.openbao.pki.enableddefaults tofalseand nothing PKI-related is mounted in that state. (The PKI include is reached and skipped in variant 0.)- With the flag on, the play mounts
pki, generates an internal root CA and one issuing role, and a service certificate can be issued from it. Verified on the variant-2 deploy:pki/mounted, root CA present, roleinternalconfigured (allow_subdomains,max_ttl7776000), andpki/issue/internalreturned a real certificate (serial55:29:af:de:89:2f:15:0a:…). - Both flag states are covered by
meta/variants.yml.
Monitoring (Decision #8)
services.prometheus.native_metrics.enabled: trueandtemplates/prometheus.yml.j2exist; Prometheus scrapes/v1/sys/metrics?format=prometheuson the internal container port and the target reportsup. Verified against the Prometheus targets API: jobopenbao,scrapeUrl http://openbao:8200/v1/sys/metrics?format=prometheus,health: "up",lastError: "".- The metrics endpoint is not reachable through
sys-svc-proxyfrom outside the role-local docker network. It was publicly readable (HTTP 200 through the proxy) until alocation = /v1/sys/metrics { deny all; }was added in the role'stemplates/proxy.conf.j2;test-rbac-denial.jsnow asserts the endpoint never answers 200 on the public surface. - A sealed instance is detectable:
/v1/sys/healthreturns 503 (asserted bytest-seal-status.js) and an alert rule fires on that condition. AnOpenBaoSealedalert now ships with the role itself, intemplates/prometheus/alert_rules.yml.j2, which thealert_rule_roleslookup concatenates into the Prometheus rule file. The health-code contract is asserted by the spec suite and the healthy path was confirmed live (200 in both compose and swarm), but the firing condition was never observed — that needs an instance deliberately sealed, which the AppRole-only admin model makes awkward to arrange in a test. Correction from implementation: OpenBao keeps Vault's metric namespace, so the gauge isvault_core_unsealed, notopenbao_core_unsealed— and it is unusable as an alert source here: on a healthy, demonstrably unsealed node it is emitted asvault_core_unsealed{cluster=""} 0, so== 0would fire permanently. Use the health endpoint as the primary signal, withvault_core_active{cluster="…"} == 1as the metric-side corroboration. The alert rule itself is still outstanding. - The container healthcheck uses
sealedcode=200&uninitcode=200so a freshly created, not-yet-initialised container becomes healthy enough for the init task to run, while the monitoring probe keeps the strict codes. Both halves asserted bytest-seal-status.js; the deploy's init step only ran because the pre-init container reported healthy.
Logging
- OpenBao logs reach the standard Infinito.Nexus logging mechanism like every other compose service, at a configurable log level. The server logs to stdout like any compose service; the level is
services.openbao.log_level(defaultinfo), rendered into the config aslog_level. The image's own/openbao/logsvolume is declared asopenbao_logsso audit output has a named, backed-up home rather than an anonymous volume. - No task or template deliberately logs a token, a seal key or a secret value.
Backup & restore
- The
container_backupservice is wired withbackup.no_stop_required: false, so the container is quiesced for a consistent copy of the raft volume. - A restore procedure is documented and executed once end to end: restore the volume, redeploy, and confirm the instance auto-unseals and a previously stored secret is still readable. Drill run on the variant-2 stack: container quiesced,
openbao_dataarchived (33 MB), the live volume wiped to zero entries, restored from the archive, and restarted — OpenBao came backinitialized: true, sealed: falsewith the secret, the PKI root CA and all five policies recovered. This also confirms the documented dependency: the restored store is only readable because the seal key in the inventory was unchanged. - The README states explicitly that the volume backup is worthless without the seal key, that the seal key's authoritative home is the encrypted inventory (it is not covered by
svc-bkp-secrets-2-local, whose sources areDIR_SECRETS, the CA and the ACME material), and what the recovery keys can and cannot do. - The README states the consequence of Decision #2 that surfaced during implementation: because the root token is revoked and the recovery keys are not persisted (persisting them would be the write-back the AppRole bootstrap exists to avoid), the Ansible AppRole is the only administrative path into the instance. Losing or out-of-band-changing those credentials means rebuilding from the volume backup plus the seal key.
Variants
meta/variants.ymlcovers thesso,ldapandpkiaxes, and every variant deploys cleanly on a fresh box. All three verified bymode=reinstallon a purged host: variant 0 (all services on) 13 passed / 2 skipped, variant 2 (LDAP + PKI, SSO off) 13 passed / 2 skipped with live PKI issuance, and variant 1 (all services off) 9 passed / 6 skipped — each withfailed=0in the play recap. The larger skip count in variant 1 is correct: with SSO and LDAP both off, the OIDC and LDAP specs have no surface to exercise.
Playwright
- The three baseline personas are covered per the Playwright contract:
guest(never reaches an authenticated surface),biber,administrator, each in its owntest-*.jsmodule aggregated byplaywright.spec.js, with SSO/LDAP steps guarded byskipUnlessServiceEnabled.guestruns live;biberandadministratorare blocked withPERSONA_*_BLOCKEDunder the contract's documented escape, each carrying a# nocheck: persona-block-rationalenaming the role property that forces it — biber holds no OpenBao RBAC group by design, and the shared persona helper cannot drive OpenBao's method-select + popup +postMessagelogin. The administrator's OIDC journey is asserted instead bytest-oidc-login.js, and biber's denial bytest-ldap-login.js. - A spec asserts the RBAC denial from the criteria above (a non-member gets no administrative surface).
- A spec covers every RBAC tier, not only the denial:
biberstarts in no group and reaches no privileged surface, then an administrator adds him to each of the three RBAC groups in turn through LAM, and after every change he logs in again and the spec asserts the policy set and the capability that tier is meant to grant — the operator writes an application secret where the reader gets 403, the reader still reads it back, and only the administrator reads an ACL policy definition. Removing him between rounds proves the revoke as well as the grant. Correction from implementation: this was first built with two dedicated accounts,baooperatorandbaoreader, which deployed and joined their groups correctly but returned 403 on every LDAP bind — a user declared without an explicitpasswordinherits thestrong_passwordfallback, whichutils/templating/ansible.pyre-evaluates throughsecrets.choiceon each render with nothing persisted, soldap_passwdandplaywright.envcould receive different strings. Movingbiberbetween the groups drops both accounts, needs no password workaround, and covers strictly more: the static members could only ever show a steady state, never a grant or a revoke taking effect. Building it also surfaced a defect inweb-app-lam:meta/csp.ymlsetscript-src-elem.unsafe-inline: false, which blocked the inline script LAM renders to drive its asynchronous tools, so LDAP import/export and Multi edit posted their job, displayedStatus: in progressand never polled it — broken for every user, not just the spec. templates/playwright.env.j2exposes every env var the specs read. (Enforced bytest_env_keys_used.py, which also fails on a declared-but-unread key.)make compose-playwright role=web-app-openbaoexits 0 with no stub tests and a clean logged-out final state. Latest deploy run: 13 passed, 2 skipped, 0 failed (30.8 s), no retries.
Quality & documentation
- The stack reaches a steady running state in every variant, and
make qualityis green tree-wide.make quality(docs + autoformat + the four suites) passes end to end —test-external,test-integration,test-lintandtest-unitall green. All three variants reach a steady running state withfailed=0. README.mddocuments the static-seal model and its trust assumption, the bootstrap and root-token revocation flow, the RBAC mapping, the PKI flag, the backup/restore procedure including the seal-key caveat, and the CLI-OIDC-login limitation from Decision #10. It additionally records two traps found during implementation: the/openbao/fileownership requirement and whyvault_core_unsealedmust not be used for alerting.- This requirement is cross-linked from the implementing PR, and the PR is cross-linked back from here. Implementing PR: infinito-nexus/core#611.
Validation Apps
INFINITO_APPS="web-app-openbao" make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
Smoke: visit https://openbao.{{ DOMAIN_PRIMARY }}/ → Keycloak chain → OpenBao UI; write and read a KV secret; docker compose restart and confirm the instance comes back unsealed.
Cross-mode parity is the gate for Decision #11: run the compose loop first, then the swarm loop, then the round-trip sweep per roundtrip.md. The role is not done until it is green in both.
Prerequisites
Before implementation, the agent MUST read AGENTS.md, then Compose Loop, Swarm Loop, deployment-modes.md, the Playwright contract, and the IAM pages common.md, oidc.md and rbac.md.
Implementation Strategy
Execute autonomously; the Confirmed Decisions are settled. Scaffold from roles/web-app-semaphore/ for the native-OIDC + LDAP + Prometheus shape, and from roles/svc-db-elasticsearch/tasks/01_init.yml for the in-container configuration pattern that keeps credentials out of process arguments.
Build in this order, verifying each stage against the running container before moving on: storage and static seal first (nothing else is testable until the instance auto-unseals), then init plus the AppRole bootstrap and root-token revocation, then policies, then OIDC, then LDAP, then metrics, then PKI behind its flag, then the Playwright specs.
Drive that whole sequence on compose, then re-run it on swarm before claiming any criterion done. Use lookup('container_address', ...) from the first init task rather than retro-fitting it — a compose-only container exec <name> works locally and then fails on every swarm task, which is the expensive way to discover Decision #11.
Verify every OpenBao path, config key and CLI flag against the pinned 2.6.2 image before claiming a criterion done — the seal, AppRole and health-parameter facts in this document were read from upstream main, and 2.6.2 is what actually ships.
Commit Policy
- No git commit until every Acceptance Criterion is checked off.
- When met and
make qualityis green, instruct the operator to rungit-sign-pushoutside the sandbox. The agent MUST NOT push.
Context
- Upstream repo: https://github.com/openbao/openbao
- Static seal: https://openbao.org/docs/configuration/seal/static/
- Seal concepts (recovery keys vs unseal keys): https://openbao.org/docs/concepts/seal/
- JWT/OIDC auth: https://openbao.org/docs/auth/jwt/
- Keycloak provider guide: https://openbao.org/docs/auth/jwt/oidc-providers/keycloak/
/sys/health: https://openbao.org/api-docs/system/health//sys/metrics: https://openbao.org/api-docs/system/metrics/- Closest in-repo analogue:
roles/web-app-semaphore/(native OIDC + LDAP, no oauth2 gate)