Gunnar

nordisk/Skidbladnir

.nornir/openbao-design.md

# 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.