Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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.