Skip to content

0013 — OpenBAO access through the bao CLI

Status: Accepted; refines 0002, amends 0011 Date: 2026-09-27

Context

sluisctl credential ssh|db|client each reimplement one slice of what the bao CLI already does: log in, then make the one call a role allows. That slice grows every time OpenBAO grows — a new secrets engine, a flag sign learns, a bug fixed upstream that this repository would otherwise have to notice and port. 0002 already drew this line for reading a secret store's own data (sluisctl secrets, removed in v1.30.0: "a courier command for it here duplicated a client the store itself should ship"); it did not yet draw it for minting a credential, and sluisctl credential is exactly that courier, kept a second time.

A second pressure points the same way. docs/connect/postgresql.md and docs/connect/ssh.md each carry their own copy of the login-then-sign sentence, because each courier repeats it. A caller who wants something bao already supports — bao ssh -mode=ca for the interactive session ssh itself drives, bao kv get -field=... for a single value, an engine this repository has never heard of — has no path through this tool at all today.

One gap upstream, not a design gap here: -format=env does not exist on bao kv get. Every consumer of a secret as a set of environment variables — Docker Compose's env_file, just's dotenv-load, a Node process reading .env — wants exactly that shape, and today getting it means a caller's own jq pipeline reimplementing dotenv quoting, once per repository, less carefully each time.

Decision

sluisctl bao <args…> authenticates, then runs the real bao binary with the arguments unchanged. It logs in to OpenBAO's JWT mount exactly as sluisctl credential and sluisctl token --audience openbao already do — the sign-in (or the job's own token) exchanged for openbao, then auth/jwt-roster/login — and hands the resulting token to bao as BAO_TOKEN, in the child process's environment alone. Everything after sluisctl bao is OpenBAO's own syntax: its subcommands, its flags, its bugs and its fixes stay upstream, and a release of this tool never has to catch up with a release of that one.

Amended: the login targets the namespace the caller is about to operate in only by default. --login-ns (or $SLUISCTL_BAO_LOGIN_NAMESPACE) lets it happen at a PARENT of that namespace instead, for an installation that keeps its logins at one namespace while data lives in per-project children — see docs/connect/openbao.md#logins-at-a-parent-namespace. The target namespace must still be the login namespace or a descendant of it, checked before any exchange, for the same reason the paragraph below gives; only where the login itself may happen has moved.

The login targets the namespace the caller is about to operate in, by default. OpenBAO's own contract (truvity/openbao's docs/integrations/sluis.md, "Root and every environment namespace get the same two auth mounts... the namespace a person logs in to is the environment's name") gives every namespace its own jwt-roster door, and a token minted in one namespace is only good in that namespace and its children — never a sibling. So sluisctl bao reads bao's own -namespace flag — or its documented shortcut -ns (bao kv get -h: "-ns can be used as shortcut"), or BAO_NAMESPACE, then VAULT_NAMESPACE — out of what it is about to run, in that order, and logs in there. Both spellings of the flag are ONE setting: whichever was typed last wins, regardless of which of the two it was, the same as bao itself applies. bao's own flags, including -namespace/-ns, go after the subcommand (bao kv get -ns=dev secret/foo, which bao accepts as readily as before it) — sluisctl's own flags (--address, --ca-cert, --issuer, --client, --audience, --mount, --login-role, --login-ns) go before the subcommand, and the first argument that is not one of those ends sluisctl's own parsing. That is the whole separation rule: nothing here parses bao's syntax beyond finding that one flag under its two spellings.

The token is cached, under this tool's own config directory, 0600, one file per OpenBAO address, LOGIN namespace and subject — the login namespace rather than the target, so two targets under the same parent login (--login-ns) share the one cache entry — never ~/.vault-token and never bao's own token-helper file, so a login minted for one exchanged identity is never picked up by bao's own tooling running as somebody else. sluisctl bao --forget revokes it and removes the cache entry, at that same login namespace.

-format=env on kv get is the one exception, and it is a stop-gap: sluisctl bao kv get ... -format=env runs the real bao in JSON, and renders the dotenv itself, ONLY when the installed bao does not already answer -format=env on its own — checked once, cheaply, against that binary's own -format help. The day an installed bao supports it natively, this stops intercepting, because a wrapper that keeps "fixing" a gap upstream has already closed is a second, competing answer to the same question. This repository proposes the feature upstream under the same rule 0002 already states for everything else: OpenBAO's data plane is OpenBAO's to own.

SSH, unchanged in shape from what 0011 already decided for machines, now runs through the passthrough rather than a dedicated subcommand: sluisctl bao ssh -mode=ca … for the interactive session ssh itself drives (agent handling, host key checking and all, exactly as ssh behaves when pointed at any other CA-issued certificate), or sluisctl bao write -field=signed_key <mount>/sign/<role> public_key=@key.pub > key-cert.pub for scp, git, CI and Ansible, which want the certificate as a file rather than a live session. OpenBAO's host certificates, and people on opkssh, are unaffected; opkssh remains the long-term target for people, still blocked on ES384 verification support upstream.

Postgres moves to sluisctl psql / sluisctl pg -- (a second PR, following this one): the same authentication, then a Postgres client certificate, handed to psql or an arbitrary command through libpq's own environment variables rather than a pg_service entry this tool used to write.

Removed within 1.x (0007): sluisctl credential ssh|db|client. Each becomes a refusal naming its replacement, the same shape 0002 already used for sluisctl secrets.

Consequences

sluisctl stops growing with OpenBAO. A new secrets engine, a new flag on sign, a fix to how kv get renders a value: all of it is bao's to ship, and a caller has it the day they upgrade bao, not the day this repository catches up.

One dependency this tool now has an opinion about: bao must be on PATH. Where sluisctl credential needed nothing but this binary, sluisctl bao needs the real one installed beside it, and says so plainly when it is missing.

The interception is temporary by design, and says so. An installation that pins an old bao keeps the stop-gap for as long as it runs that version; the day it upgrades past upstream support, the behaviour is identical from the caller's side and the code path is simply not exercised any more.

A caller who wants bao's own global flags must place them after the subcommand. This is not new: bao has always accepted them there. What changes is that placing one before the subcommand no longer works through sluisctl bao, because that position is reserved for sluisctl's own flags. docs/connect/openbao.md says so where a caller would look.

Alternatives considered

Keep growing sluisctl credential per kind, adding sluisctl credential kv or similar as new needs arrive. Rejected: this is the exact shape 0002 already rejected for reading data, now arriving again for everything else bao can do. Every new kind is this repository re-implementing a client for somebody else's data plane.

Parse and forward every bao flag explicitly, so sluisctl bao's own --help could describe the whole surface. Rejected: that is reimplementing bao's CLI grammar one flag at a time, which is precisely the maintenance burden this decision exists to end. Finding one flag (-namespace, under either of its two spellings) to route the login correctly is a narrow, justified exception; parsing all of them is the feature this ADR refuses to grow back.

Ship the dotenv conversion as a permanent feature, since some installations will run an old bao for a long time. Rejected: permanent scope creep for a gap that is upstream's to close, and the detection already makes an old bao cost nothing extra to a caller who stays current — the stop-gap is free to keep and costs this repository nothing once it stops being exercised.