src/openbao.rs
//! OpenBao (Vault-compatible) **structure provisioner** — the platform's half of
//! the secret-ownership split.
//!
//! Per the anti-Ragnarök ownership design, provisioning divides in two:
//! **Skidbladnir provisions the vault _structure_** (the k8s auth method, a
//! read-only policy per environment, and a k8s-auth role binding each env's app
//! ServiceAccount to its policy) and the app reads its secrets through the
//! OpenBao Agent injector; **`tillsynia-admin` writes the secret _values_**
//! (`secret/data/<env>/app`, from the live k8s Secret). This module is the
//! structure half — the "OpenBao client" — kept to the plain REST API via
//! `reqwest::blocking` (matching this crate's other Vault HTTP code in `pki.rs`),
//! no vault crate. Behind the `openbao` feature.
//!
//! It codifies what was previously a hand-run `curl` turn-on into typed,
//! idempotent calls: enable the Kubernetes auth method, point it at the in-cluster
//! API, then per env write a scoped read policy + the role that lets that env's
//! pods log in and read only their own path.
//!
//! What stays OUTSIDE this module (deliberately): writing secret _values_
//! (`tillsynia-admin vault-write`), the `system:auth-delegator` ClusterRoleBinding
//! and the Agent-injector install (k8s objects / helm — the applier's job, and
//! human-approved RBAC by design). See `Njord/.nornir/tillsynia-admin.md`.
use anyhow::{Context, Result, bail};
use serde_json::json;
/// A minimal privileged OpenBao client (root/admin token) for structure setup.
/// Its REST calls go through the shared [`crate::vault_http::VaultHttp`] transport
/// (reuse-law L5, twinned with `pki.rs`); this type keeps the idempotency-swallow
/// response handling.
pub struct OpenBao {
http: crate::vault_http::VaultHttp,
}
impl OpenBao {
/// `addr` e.g. `http://openbao.openbao.svc:8200` (in-cluster) or a
/// `kubectl port-forward` target; `token` a token with `sys` + `auth` write.
pub fn new(addr: impl Into<String>, token: impl Into<String>) -> Result<Self> {
let http =
crate::vault_http::VaultHttp::new(addr, token, std::time::Duration::from_secs(15))?;
Ok(Self { http })
}
/// Read `VAULT_ADDR`/`BAO_ADDR` (default `http://127.0.0.1:8200`) +
/// `VAULT_TOKEN`/`BAO_TOKEN` (required) — same convention as tillsynia-admin.
pub fn from_env() -> Result<Self> {
let addr = std::env::var("VAULT_ADDR")
.or_else(|_| std::env::var("BAO_ADDR"))
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
let token = std::env::var("VAULT_TOKEN")
.or_else(|_| std::env::var("BAO_TOKEN"))
.context("VAULT_TOKEN (or BAO_TOKEN) required")?;
Self::new(addr, token)
}
/// POST `<addr>/v1/<path>` through the shared [`crate::vault_http::VaultHttp`]
/// transport. 2xx = ok; a 400 "already in use" (re-enabling a mount) is treated
/// as success so the whole turn-on is idempotent — the openbao-specific response
/// wrapper over the shared transport.
fn post(&self, path: &str, body: serde_json::Value) -> Result<()> {
let (status, text) = self.http.post_json(path, &body)?;
if status.is_success() {
return Ok(());
}
if status.as_u16() == 400 && text.contains("already in use") {
return Ok(()); // mount already enabled — idempotent
}
bail!("vault POST {path}: HTTP {status} {text}");
}
/// Enable the Kubernetes auth method at `auth/kubernetes` (idempotent).
pub fn enable_kubernetes_auth(&self) -> Result<()> {
self.post("sys/auth/kubernetes", json!({ "type": "kubernetes" }))
}
/// Point the k8s auth method at the cluster API. Running in-cluster, the
/// OpenBao pod uses its own mounted SA token + CA, so only the host is needed
/// (`https://kubernetes.default.svc`).
pub fn configure_kubernetes(&self, kubernetes_host: &str) -> Result<()> {
self.post(
"auth/kubernetes/config",
json!({ "kubernetes_host": kubernetes_host }),
)
}
/// Write an ACL policy from raw HCL.
pub fn write_policy(&self, name: &str, hcl: &str) -> Result<()> {
self.post(
&format!("sys/policies/acl/{name}"),
json!({ "policy": hcl }),
)
}
/// Bind a ServiceAccount (`sa` in namespace `ns`) to a k8s-auth `role`
/// granting `policies` (comma-separated), with token `ttl` (e.g. `1h`).
pub fn write_kubernetes_role(
&self,
role: &str,
sa: &str,
ns: &str,
policies: &str,
ttl: &str,
) -> Result<()> {
self.post(
&format!("auth/kubernetes/role/{role}"),
json!({
"bound_service_account_names": sa,
"bound_service_account_namespaces": ns,
"policies": policies,
"ttl": ttl,
}),
)
}
/// Read-only KV v2 policy HCL for one environment: read `secret/data/<env>/*`
/// and list its metadata — nothing else (per-env isolation, incl. prod).
pub fn env_read_policy(env: &str) -> String {
format!(
"path \"secret/data/{env}/*\" {{\n capabilities = [\"read\"]\n}}\n\
path \"secret/metadata/{env}/*\" {{\n capabilities = [\"read\", \"list\"]\n}}\n"
)
}
/// Provision one environment's structure in one call: its scoped read policy
/// and the k8s-auth role binding `sa`/`ns` to it (both named `env`). The role's
/// token TTL defaults to 1h. Values are written separately by tillsynia-admin.
///
/// Delegates to [`OpenBao::apply`] over the per-env [`PopulatePlan::env_steps`],
/// so the per-env structure has ONE definition shared with the platform plan (L5).
pub fn provision_env(&self, env: &str, sa: &str, ns: &str) -> Result<()> {
self.apply(&PopulatePlan::env_steps(env, sa, ns))
}
/// Apply a structural-populate [`PopulatePlan`] live, step by step. Each POST is
/// idempotent (a re-enabled mount / re-written policy is treated as success), so
/// the whole plan is safe to re-run. The plan is BUILT + asserted offline
/// ([`PopulatePlan::for_platform`]); this is the thin live executor — the deferred
/// back half that needs a reachable OpenBao.
pub fn apply(&self, plan: &PopulatePlan) -> Result<()> {
for step in &plan.steps {
match step {
PopulateStep::EnableKubernetesAuth => self.enable_kubernetes_auth()?,
PopulateStep::ConfigureKubernetes { host } => self.configure_kubernetes(host)?,
PopulateStep::Policy { name, hcl } => self.write_policy(name, hcl)?,
PopulateStep::KubernetesRole {
role,
sa,
ns,
policies,
ttl,
} => self.write_kubernetes_role(role, sa, ns, policies, ttl)?,
}
}
Ok(())
}
}
/// One structural step in an OpenBao bring-up — the vault STRUCTURE as data (never
/// secret VALUES), so a whole populate is planned + asserted offline and applied
/// idempotently by [`OpenBao::apply`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PopulateStep {
/// Enable the Kubernetes auth method at `auth/kubernetes`.
EnableKubernetesAuth,
/// Point the k8s auth method at the cluster API `host`.
ConfigureKubernetes { host: String },
/// Write ACL policy `name` from `hcl`.
Policy { name: String, hcl: String },
/// Bind ServiceAccount `sa`/`ns` to k8s-auth role `role` granting `policies`
/// (comma-separated) with token `ttl`.
KubernetesRole {
role: String,
sa: String,
ns: String,
policies: String,
ttl: String,
},
}
/// A complete, ORDERED OpenBao structural-populate plan — the k8s-auth method, and
/// per environment a scoped read policy + the role binding its app ServiceAccount to
/// it. **Pure DATA** (no HTTP), so a platform bring-up asserts the exact structure it
/// will create offline; [`OpenBao::apply`] executes it live (the deferred back half).
/// This is the OpenBao half of the Tier-0.5 common-infra keystone: after the `openbao`
/// component's wave is up, apply this plan to populate its structure.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PopulatePlan {
pub steps: Vec<PopulateStep>,
}
impl PopulatePlan {
/// The per-environment structure (no auth-method setup): a scoped read policy +
/// the k8s-auth role binding `sa`/`ns` to it (both named `env`, 1h token TTL) —
/// the exact pair [`OpenBao::provision_env`] writes, as data. Shared by
/// [`PopulatePlan::for_platform`] so the convention lives in ONE place (L5).
pub fn env_steps(env: &str, sa: &str, ns: &str) -> PopulatePlan {
PopulatePlan {
steps: vec![
PopulateStep::Policy {
name: env.to_string(),
hcl: OpenBao::env_read_policy(env),
},
PopulateStep::KubernetesRole {
role: env.to_string(),
sa: sa.to_string(),
ns: ns.to_string(),
policies: env.to_string(),
ttl: "1h".to_string(),
},
],
}
}
/// The FULL structural populate for a platform: enable + configure the k8s auth
/// method against `host`, then per `(env, sa, ns)` its [`env_steps`] (scoped read
/// policy + role). The one-call plan a Tier-0.5 bring-up builds, asserts offline,
/// and hands to [`OpenBao::apply`] once the OpenBao component is up.
pub fn for_platform(host: &str, envs: &[(&str, &str, &str)]) -> PopulatePlan {
let mut steps = vec![
PopulateStep::EnableKubernetesAuth,
PopulateStep::ConfigureKubernetes {
host: host.to_string(),
},
];
for (env, sa, ns) in envs {
steps.extend(PopulatePlan::env_steps(env, sa, ns).steps);
}
PopulatePlan { steps }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_policy_is_scoped_to_the_env() {
let p = OpenBao::env_read_policy("test1");
assert!(p.contains("secret/data/test1/*"));
assert!(p.contains("capabilities = [\"read\"]"));
// must NOT grant another env
assert!(!p.contains("test2"));
assert!(!p.contains("prod"));
}
#[test]
fn addr_trims_trailing_slash() {
let ob = OpenBao::new("http://openbao.openbao.svc:8200/", "t").unwrap();
// The shared transport trims the trailing slash, so the /v1 join is single-slash.
assert_eq!(
ob.http.url("sys/x"),
"http://openbao.openbao.svc:8200/v1/sys/x"
);
}
/// The platform populate plan is ordered and per-env scoped: enable + configure
/// the k8s auth method FIRST, then for each env a scoped read policy then its
/// role — and one env's policy must NOT grant another env's path. RED-when-broken
/// on any ordering/scoping/count drift (the plan is the structure applied live).
#[test]
fn populate_plan_for_platform_is_ordered_and_env_scoped() {
let plan = PopulatePlan::for_platform(
"https://kubernetes.default.svc",
&[
("prod", "holger-sa", "holger"),
("staging", "holger-sa", "holger-staging"),
],
);
// Auth-method setup comes first, in order.
assert_eq!(plan.steps[0], PopulateStep::EnableKubernetesAuth);
assert_eq!(
plan.steps[1],
PopulateStep::ConfigureKubernetes {
host: "https://kubernetes.default.svc".into()
}
);
// prod: Policy then Role, scoped — the policy must not leak into staging.
match &plan.steps[2] {
PopulateStep::Policy { name, hcl } => {
assert_eq!(name, "prod");
assert!(
hcl.contains("secret/data/prod/*"),
"prod policy is scoped: {hcl}"
);
assert!(
!hcl.contains("staging"),
"prod policy must NOT grant staging"
);
}
other => panic!("expected prod Policy, got {other:?}"),
}
assert_eq!(
plan.steps[3],
PopulateStep::KubernetesRole {
role: "prod".into(),
sa: "holger-sa".into(),
ns: "holger".into(),
policies: "prod".into(),
ttl: "1h".into(),
}
);
// staging policy is likewise isolated (does not grant prod).
match &plan.steps[4] {
PopulateStep::Policy { name, hcl } => {
assert_eq!(name, "staging");
assert!(hcl.contains("secret/data/staging/*"));
assert!(!hcl.contains("prod"), "staging policy must NOT grant prod");
}
other => panic!("expected staging Policy, got {other:?}"),
}
// 2 setup + 2 per env = 6 steps exactly (no missing/extra structure).
assert_eq!(plan.steps.len(), 6);
}
/// `provision_env`'s per-env structure is EXACTLY `PopulatePlan::env_steps` (the
/// one definition both the single-env call and the platform plan consume, L5):
/// a scoped Policy then a 1h-TTL Role, both named for the env.
#[test]
fn env_steps_is_the_single_per_env_definition() {
let steps = PopulatePlan::env_steps("prod", "sa", "ns").steps;
assert_eq!(steps.len(), 2);
assert!(matches!(&steps[0], PopulateStep::Policy { name, .. } if name == "prod"));
assert!(matches!(
&steps[1],
PopulateStep::KubernetesRole { role, ttl, .. } if role == "prod" && ttl == "1h"
));
// And the platform plan's per-env tail is these same two steps.
let plan = PopulatePlan::for_platform("h", &[("prod", "sa", "ns")]);
assert_eq!(&plan.steps[2..], &steps[..]);
}
}
// The live REST transport (the deferred back half): asserted against an in-process
// mock Vault so every endpoint's request URL, `X-Vault-Token` header, and JSON body
// are pinned, plus the 400-"already in use" idempotency branch. Mirrors pki.rs's
// `vault_tests`, but SINGLE-THREADED: `MockServer`'s capture channel is unbounded, so
// the blocking POST completes against the mock's gatling background `Job`, then
// `next_request()` drains the already-buffered capture — no `thread::scope`/`spawn`
// (ROOT-LAW-#0). Gated on `openbao` so the lean offline core never compiles reqwest.
#[cfg(all(test, feature = "openbao"))]
mod http_tests {
use super::*;
use crate::mockhttp::MockServer;
/// LAW 1 (inject-assert): `enable_kubernetes_auth` POSTs the EXACT enable request
/// the OpenBao `sys` API expects — `/v1/sys/auth/kubernetes`, the admin token in
/// `X-Vault-Token`, body `{"type":"kubernetes"}`. RED if the path/header/body drift.
#[test]
fn enable_kubernetes_auth_request_shape() {
let server = MockServer::start(1, 200, "{}");
let ob = OpenBao::new(server.base_url(), "root.admin-token").unwrap();
ob.enable_kubernetes_auth().expect("enable ok");
let req = server.next_request();
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/v1/sys/auth/kubernetes");
assert_eq!(req.header("X-Vault-Token"), Some("root.admin-token"));
let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
assert_eq!(body["type"], "kubernetes");
}
/// `configure_kubernetes` POSTs `/v1/auth/kubernetes/config` with the cluster host
/// in `kubernetes_host` — the in-cluster API pointer, token-authenticated.
#[test]
fn configure_kubernetes_request_shape() {
let server = MockServer::start(1, 200, "{}");
let ob = OpenBao::new(server.base_url(), "tok-cfg").unwrap();
ob.configure_kubernetes("https://kubernetes.default.svc")
.expect("configure ok");
let req = server.next_request();
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/v1/auth/kubernetes/config");
assert_eq!(req.header("X-Vault-Token"), Some("tok-cfg"));
let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
assert_eq!(body["kubernetes_host"], "https://kubernetes.default.svc");
}
/// `write_policy` POSTs the named ACL policy to `/v1/sys/policies/acl/<name>` with
/// the raw HCL in `policy` — the scoped read policy the env's pods get.
#[test]
fn write_policy_request_shape() {
let server = MockServer::start(1, 200, "{}");
let ob = OpenBao::new(server.base_url(), "tok-pol").unwrap();
let hcl = OpenBao::env_read_policy("prod");
ob.write_policy("prod", &hcl).expect("write_policy ok");
let req = server.next_request();
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/v1/sys/policies/acl/prod");
assert_eq!(req.header("X-Vault-Token"), Some("tok-pol"));
let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
assert_eq!(body["policy"], hcl);
// The transmitted HCL is the real scoped policy, not an empty stand-in.
assert!(
body["policy"]
.as_str()
.unwrap()
.contains("secret/data/prod/*")
);
}
/// `write_kubernetes_role` POSTs `/v1/auth/kubernetes/role/<role>` binding the SA /
/// namespace to the policy with the token TTL — the login binding for the env pods.
#[test]
fn write_kubernetes_role_request_shape() {
let server = MockServer::start(1, 200, "{}");
let ob = OpenBao::new(server.base_url(), "tok-role").unwrap();
ob.write_kubernetes_role("prod", "holger-sa", "holger", "prod", "1h")
.expect("write_kubernetes_role ok");
let req = server.next_request();
assert_eq!(req.method, "POST");
assert_eq!(req.path, "/v1/auth/kubernetes/role/prod");
assert_eq!(req.header("X-Vault-Token"), Some("tok-role"));
let body: serde_json::Value = serde_json::from_str(&req.body).unwrap();
assert_eq!(body["bound_service_account_names"], "holger-sa");
assert_eq!(body["bound_service_account_namespaces"], "holger");
assert_eq!(body["policies"], "prod");
assert_eq!(body["ttl"], "1h");
}
/// The idempotency branch (L63/L77): a 400 whose body says "already in use" — a
/// re-enabled mount — is treated as SUCCESS so the whole turn-on re-runs cleanly.
#[test]
fn post_400_already_in_use_is_idempotent_ok() {
let server = MockServer::start(
1,
400,
r#"{"errors":["path is already in use at kubernetes/"]}"#,
);
let ob = OpenBao::new(server.base_url(), "tok").unwrap();
// Re-enabling the mount returns 400 "already in use" → Ok, not Err.
let res = ob.enable_kubernetes_auth();
let _ = server.next_request();
assert!(
res.is_ok(),
"400 'already in use' must be idempotent Ok: {res:?}"
);
}
/// RED-when-broken guard on the OTHER side of the branch: a 400 that is NOT
/// "already in use" (a genuine bad request) must bail with the status + body,
/// never be swallowed by the idempotency shortcut.
#[test]
fn post_400_other_error_bails() {
let server = MockServer::start(1, 400, r#"{"errors":["missing required field"]}"#);
let ob = OpenBao::new(server.base_url(), "tok").unwrap();
let res = ob.configure_kubernetes("https://k8s");
let _ = server.next_request();
let err = res.unwrap_err().to_string();
assert!(err.contains("400"), "surfaces HTTP status: {err}");
assert!(
err.contains("missing required field"),
"includes body: {err}"
);
}
/// A non-400 failure (e.g. 403 permission denied) also bails — the idempotency
/// shortcut is 400-only, so a forbidden token is a hard error.
#[test]
fn post_403_bails() {
let server = MockServer::start(1, 403, r#"{"errors":["permission denied"]}"#);
let ob = OpenBao::new(server.base_url(), "bad-token").unwrap();
let res = ob.write_policy("prod", "x");
let _ = server.next_request();
let err = res.unwrap_err().to_string();
assert!(err.contains("403"), "surfaces HTTP 403: {err}");
}
/// End-to-end `apply`: the full platform plan drives EXACTLY its steps, in order,
/// each as its own token-authenticated POST — enable, configure, then per env a
/// scoped policy + role. Asserts the live executor walks the offline-asserted plan
/// faithfully (every path + the env scoping actually goes on the wire).
#[test]
fn apply_platform_plan_drives_every_step_in_order() {
let plan = PopulatePlan::for_platform(
"https://kubernetes.default.svc",
&[("prod", "holger-sa", "holger")],
);
// 2 setup + 2 per env = 4 POSTs.
let server = MockServer::start(plan.steps.len(), 200, "{}");
let ob = OpenBao::new(server.base_url(), "admin.tok").unwrap();
ob.apply(&plan).expect("apply ok");
let reqs: Vec<_> = (0..plan.steps.len())
.map(|_| server.next_request())
.collect();
assert_eq!(reqs.len(), 4);
// All token-authenticated.
for r in &reqs {
assert_eq!(r.method, "POST");
assert_eq!(r.header("X-Vault-Token"), Some("admin.tok"));
}
// In-order paths mirror the plan exactly.
assert_eq!(reqs[0].path, "/v1/sys/auth/kubernetes");
assert_eq!(reqs[1].path, "/v1/auth/kubernetes/config");
assert_eq!(reqs[2].path, "/v1/sys/policies/acl/prod");
assert_eq!(reqs[3].path, "/v1/auth/kubernetes/role/prod");
// The configure body carried the host; the role body carried the SA binding.
let cfg: serde_json::Value = serde_json::from_str(&reqs[1].body).unwrap();
assert_eq!(cfg["kubernetes_host"], "https://kubernetes.default.svc");
let role: serde_json::Value = serde_json::from_str(&reqs[3].body).unwrap();
assert_eq!(role["bound_service_account_names"], "holger-sa");
assert_eq!(role["ttl"], "1h");
}
/// `provision_env` is the single-env convenience over `apply(env_steps)`: exactly
/// two POSTs — the scoped policy then the 1h-TTL role, both named for the env — with
/// the policy's HCL scoped to that env's secret path on the wire.
#[test]
fn provision_env_writes_scoped_policy_then_role() {
let server = MockServer::start(2, 200, "{}");
let ob = OpenBao::new(server.base_url(), "admin.tok").unwrap();
ob.provision_env("staging", "holger-sa", "holger-staging")
.expect("provision_env ok");
let policy = server.next_request();
assert_eq!(policy.path, "/v1/sys/policies/acl/staging");
let pbody: serde_json::Value = serde_json::from_str(&policy.body).unwrap();
assert!(
pbody["policy"]
.as_str()
.unwrap()
.contains("secret/data/staging/*")
);
assert!(
!pbody["policy"].as_str().unwrap().contains("prod"),
"env-isolated"
);
let role = server.next_request();
assert_eq!(role.path, "/v1/auth/kubernetes/role/staging");
let rbody: serde_json::Value = serde_json::from_str(&role.body).unwrap();
assert_eq!(rbody["bound_service_account_names"], "holger-sa");
assert_eq!(rbody["bound_service_account_namespaces"], "holger-staging");
assert_eq!(rbody["policies"], "staging");
assert_eq!(rbody["ttl"], "1h");
}
}
// DONE (reuse-law, L5): `openbao.rs::OpenBao::post` and `pki.rs::vault::HttpVaultClient`
// twinned the same Vault-REST-with-token transport (blocking reqwest POST to
// `{addr}/v1/{path}` + `X-Vault-Token` header + JSON body). That transport now lives
// ONCE in `crate::vault_http::VaultHttp` (returns `(StatusCode, String)`), consumed by
// both callers; each keeps its own thin response wrapper — openbao's 400-"already in
// use" idempotency swallow here, pki's error-surface + `parse_issue_response` there.
// The union-gated `both_callers_route_through_the_shared_vault_http_transport` test in
// `vault_http.rs` is the RED-when-broken proof both hit the shared seam.