# openbao — design
> **Class:** `design` · **Feature:** `openbao` (opt-in; pulls `reqwest`). States the
> shape we ship. The *why* (the secret-ownership split) is in
> [`openbao-guide.md`](openbao-guide.md) and 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
```rust
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` (`PopulateStep`s: `EnableKubernetesAuth` ·
`ConfigureKubernetes{host}` · `Policy{name,hcl}` · `KubernetesRole{…}`) is pure DATA: build it with
`for_platform(host, &[(env,sa,ns)])` (enable+configure, then per-env `env_steps`), ASSERT it
offline, and `OpenBao::apply` executes it live. `provision_env` now delegates to `env_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`](tier0-common-infra-design.md)).
- **Idempotent** — re-running provisions the same shape; safe to re-apply.
- **Scoped policy** — `env_read_policy(env)` grants read on `secret/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_host` is configured.
## Deliberately OUT of scope
- **Secret values** → `tillsynia-admin vault-write` (the values-owner).
- **`system:auth-delegator` ClusterRoleBinding** 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.