openbao — design
Class:
design· Feature:openbao(opt-in; pullsreqwest). States the shape we ship. The why (the secret-ownership split) is inopenbao-guide.mdand Njord's.nornir/tillsynia-admin.md.
What it is
skidbladnir::openbao::OpenBao — a small privileged client that provisions the structure of an OpenBao (Vault-compatible) secret store over its REST API. It is the platform's half of a two-party split:
| Who | Owns | How |
Skidbladnir (openbao) |
vault structure — k8s auth method, per-env read policy, per-env role | this module, over the shared vault_http::VaultHttp transport (twinned with pki.rs) |
| tillsynia-admin | secret values — secret/data/<env>/app |
vault-write (reads the live k8s Secret) |
| the app | reads its path at runtime | OpenBao Agent injector (vault.hashicorp.com/* annotations) |
This mirrors the pki module's "Vault as a seam" stance: real HTTP when the feature is on, no vault crate, nothing pulled into the offline core when off.
Shared transport (reuse-law L5). The token-authenticated POST {addr}/v1/{path} (+ X-Vault-Token header + JSON body) is NOT hand-rolled here — it lives ONCE in skidbladnir::vault_http::VaultHttp (returns (StatusCode, String)), consumed by both OpenBao::post and pki.rs's HttpVaultClient. Only the RESPONSE handling stays local: OpenBao::post swallows a 400 "already in use" as idempotent success, whereas pki surfaces the error and parses the leaf. The vault_http module is gated on the union of the two reqwest-pulling features (vault | openbao); the RED-when-broken both_callers_route_through_the_shared_vault_http_transport test proves both callers emit POST /v1/<path> with the token header via the one transport.
Surface
let ob = OpenBao::from_env()?; // VAULT_ADDR + VAULT_TOKEN
ob.enable_kubernetes_auth()?; // idempotent (400 "already in use" = ok)
ob.configure_kubernetes("https://kubernetes.default.svc")?;
ob.provision_env("test1", "test1-njord", "test1")?; // read policy + role in one call
// Whole-platform structural populate as a PURE, ordered plan (Tier-0.5 hook):
let plan = PopulatePlan::for_platform("https://kubernetes.default.svc",
&[("prod","holger-sa","holger"), ("staging","holger-sa","staging")]);
ob.apply(&plan)?; // thin idempotent live executor
// lower level: write_policy(name, hcl) · write_kubernetes_role(role, sa, ns, policies, ttl)
// env_read_policy(env) -> HCL (pure)
- Structural-populate plan —
PopulatePlan(PopulateSteps:EnableKubernetesAuth·ConfigureKubernetes{host}·Policy{name,hcl}·KubernetesRole{…}) is pure DATA: build it withfor_platform(host, &[(env,sa,ns)])(enable+configure, then per-envenv_steps), ASSERT it offline, andOpenBao::applyexecutes it live.provision_envnow delegates toenv_steps, so the per-env structure has ONE definition (L5). This is the OpenBao half of the Tier-0.5 common-infra bring-up (tier0-common-infra-design.md). - Idempotent — re-running provisions the same shape; safe to re-apply.
- Scoped policy —
env_read_policy(env)grants read onsecret/data/<env>/*(+ list metadata) and nothing else: per-env isolation, prod included. - In-cluster auth — running in the OpenBao pod, the k8s auth method uses the pod's own mounted SA token + CA, so only
kubernetes_hostis configured.
Deliberately OUT of scope
- Secret values →
tillsynia-admin vault-write(the values-owner). system:auth-delegatorClusterRoleBinding and the Agent-injector install → k8s objects / helm, and human-approved RBAC by design (not automated here).
examples/openbao-provision.rs drives it live from env:sa:ns args.