OpenBao 2.7 removed the builtin LDAP auth engine from the server binary
(openbao/openbao GH-3882). `bao auth enable ldap` answered
"plugin not found in the catalog: ldap" and the deploy stopped there.
The role now builds its own image. A multi-stage Dockerfile copies the
openbao-plugin-auth-ldap binary out of the image declared as the
`auth-ldap` service entry into /opt/openbao/plugins. A second server
config file, plugins.hcl, names that directory and registers the plugin
declaratively. It sets plugin_auto_register = true explicitly: the image
starts the server with a config directory, and on that path 2.7.1 does
not apply the documented default, so without the line the catalog stays
empty. The Dockerfile sets the file mode in a helper stage instead of
COPY --chmod, which only BuildKit accepts.
The mount is enabled with -plugin-version=latest, so it follows whichever
plugin version the image ships. A mount created by the former builtin
engine is tuned and reloaded onto the plugin, which needs `update` on
sys/plugins/reload/backend in the ansible-admin policy.
The LDAP tasks move into tasks/addons/auth-ldap.yml and the plugin is
declared under meta/addons as a required addon bridged to `ldap`. Its
Playwright spec logs in through the mount and asserts that sys/auth
reports an external running plugin version.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review had these removed across the role once already, and the 11 September
pass cleared every file that was flagged. The seal-key pin and the
recovery-key work then reintroduced the same pattern in nine new places.
Also removes two that predate the review and were never flagged: the
healthcheck note in meta/services.yml and the entrypoint note in
compose.yml.j2. Both are the same narrative-exception shape.
Param docs, file headers and the plain contract notes in the Playwright specs
stay: separate categories, none of them objected to. What the deleted
comments recorded lives in the role README.
The static seal carries one generation. Rotating the key on every deploy
spends that slack continuously, so a run that skips a generation, or dies
between the two AppRole pins, leaves the raft store readable only from a
volume backup plus the key that wrapped it -- and the recovery keys that
would authorise a rekey are discarded at init.
Frequent rotation buys little here in return. The key lives in the
vault-encrypted inventory either way, and whoever holds that inventory holds
the AppRole too, so they reach the data without needing the seal key. An
operator now rotates it deliberately by changing the inventory value; the
previous_key path stays in place to carry that.
The AppRole keeps rotating per deploy. It is the live authentication path,
and the fallback login exists to absorb exactly that.
Only two files were touched by both sides. env.js merged automatically: the
incoming side owns recordVideoOptions and this branch had cherry-picked that
same function from it, so the result carries one definition. README.md is the
generated roles index and both sides append rows; resolved as a union, taking
the incoming side for the three rows it edits (OnlyOffice, Open WebUI, OpenClaw)
and keeping ours only for OpenBao, then re-sorted.
The incoming side adds lint rules and resources that this role then violated.
Five fixes, each the remedy the failing rule names:
* services.openbao.ports.local.http 8081 -> 8093; svc-ai-litellm now holds 8081.
* meta/networks.yml subnet 192.168.221.0/24 -> 192.168.222.0/24; svc-ai-mcp-adapter
now holds 221, and the suggester only sub-allocates inside occupied /24s, so
the next free block was taken directly.
* services.email bond 1 -> 0; the entry is disabled and the resource model skips
it before reading the weight.
* README Quick Setup regenerated from the template it had drifted from.
* Credits link the implementer, matching how they are credited elsewhere.
* docs/requirements/035-mcp-proxy-expansion.md disposes of web-app-openbao as
privileged_control_plane, which is what a secrets manager is, and the list
count follows.
Verified against a worktree of #454 alone so the baseline is measured rather
than assumed: lint 501 passed with only the branch-name rule failing, which
fails identically on that baseline because the worktree branch is named
merge/*; unit 5665 passed with the same three host-environment failures the
baseline has; integration 324 passed, 0 failed.
baooperator and baoreader existed only to hold one RBAC group each, and a
role-declared account has no stable password until the inventory pins one, so
they needed a workaround to be logged in as at all. Moving biber through the
groups covers the same three tiers with an account whose password was never
broken, and additionally proves the grant and the revoke rather than a steady
state.
Matches user-administrator and web-app-semaphore, the only other roles that
set a password in meta/users.yml, and the key the meta lint allow-lists. It
adds no exposure: user-administrator already assigns that password to the
administrator account, which holds the OpenBao administrator policy through
the same LDAP groups, so both accounts are strictly less privileged than one
that already shares the secret.
meta/*.yml is static schema, not a runtime template, and a lookup into the
applications tree from there was the most runtime-dependent expression in any
meta file. The accounts take ansible_become_password instead.
The password resolves to the role's generated credential when OpenBao is
deployed and falls back to the previous random default otherwise, so no other
deployment changes.
A user with no explicit password inherits the strong_password fallback, which
is re-evaluated on every render with nothing persisted, so ldap_passwd and
playwright.env can receive different strings and the bind returns 403. Same
mechanism web-app-zammad uses for api_bot_password.
Declaring baooperator and baoreader in the role's meta/users.yml provisioned
them correctly into LDAP and Keycloak, but every login returned 403: a user
with no explicit password inherits "{{ 42 | strong_password }}", which is
re-evaluated through secrets.choice on each render with nothing persisted, so
the password written by ldap_passwd and the one rendered into playwright.env
were different strings.