From 2520525ec60d8ceadfcee7b2a52413206bf24d4e33f2a8ac798c0d5eb0e0ac08 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 7 Apr 2026 21:05:05 -0400 Subject: [PATCH] feat(m5): bascule-shell prefers SPIFFE SVID URI as principal Adds bascule_shell::identity::detect_spiffe_svid which reads a SPIFFE SVID URI from /var/run/spire/svid-uri (override via SPIFFE_SVID_PATH). When present it wins over Entra/AZ-CLI/ Kerberos/cached-OIDC/system-user, becoming the SAT session_leaf actor field that QM's M5 SpiffeSvidEvaluator validates against the cluster allowlist. Why a file read instead of the SPIFFE Workload API: bascule-shell ships independently from QM and the standard SPIRE k8s sidecar writes the URI as /var/run/spire/svid-uri alongside svid.pem. The file path is hermetic for tests and matches the deploy model. If a future iteration needs continuous SVID URI rotation, switch to a notify watcher or pull spiffe::workload_api. Trust domain is parsed and surfaced as Identity.domain so the banner / dashboard can show "spiffe://gh.dev" affiliation. bascule_shell::main::set_env: auth_method == "spiffe" maps to BASCULE_ROLES = "operator" by default. SPIRE-attested workloads are explicitly cluster-issued so they get operator role until per-workload provisioning lands. The existing precedence (caller-set BASCULE_ROLES wins) is unchanged. Bascule mTLS *channel* construction (Bascule -> QM gRPC renegotiation) is intentionally NOT wired in this commit. Per ADR D9 hot path is local; the renegotiation client is deferred to M6 alongside the Rekor signing client because they share the rustls dep tree. Tested (Docker rust:1.88-bookworm): cargo build -p bascule-shell -p bascule-core clean cargo test -p bascule-core --lib sat 7/7 (M1 regression) Stacked on feat/m3-defcon-env. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Claude Code --- crates/bascule-shell/src/identity.rs | 35 ++++++++++++++++++++++++++++ crates/bascule-shell/src/main.rs | 4 ++++ 2 files changed, 39 insertions(+) diff --git a/crates/bascule-shell/src/identity.rs b/crates/bascule-shell/src/identity.rs index 3faba44..5734b35 100644 --- a/crates/bascule-shell/src/identity.rs +++ b/crates/bascule-shell/src/identity.rs @@ -10,6 +10,12 @@ pub struct Identity { } pub fn detect() -> Identity { + // M5: SPIFFE workload identity wins when present. The SVID URI + // becomes the SAT session_leaf actor field, and QM's + // SpiffeSvidEvaluator validates it against the cluster allowlist. + // Mounted by SPIRE's k8s sidecar at the standard path; override + // via SPIFFE_SVID_PATH for tests / non-standard deploys. + if let Some(id) = detect_spiffe_svid() { return id; } if let Some(id) = detect_entra_wsl2() { return id; } if let Some(id) = detect_az_cli() { return id; } if let Some(id) = detect_kerberos() { return id; } @@ -17,6 +23,35 @@ pub fn detect() -> Identity { detect_system_user() } +/// M5: read a SPIFFE SVID URI from a sidecar-rendered file. The +/// standard SPIRE k8s sidecar writes the URI as a single line in +/// `/var/run/spire/svid-uri` (or wherever SPIFFE_SVID_PATH points). +/// We deliberately do NOT pull the `spiffe` crate here — bascule-shell +/// stays small and the URI string is all we need for the SAT session +/// claim. The mTLS dance is QM's job (server side); bascule-shell is +/// the producer. +fn detect_spiffe_svid() -> Option { + let path = std::env::var("SPIFFE_SVID_PATH") + .unwrap_or_else(|_| "/var/run/spire/svid-uri".to_string()); + let content = std::fs::read_to_string(&path).ok()?; + let uri = content.lines().next()?.trim().to_string(); + if !uri.starts_with("spiffe://") { + return None; + } + // Trust domain is the host component. + let trust_domain = uri + .strip_prefix("spiffe://") + .and_then(|rest| rest.split('/').next()) + .map(|s| s.to_string()); + Some(Identity { + principal: uri, + auth_method: "spiffe".into(), + domain: trust_domain, + source: format!("SPIFFE workload SVID ({})", path), + has_token: true, + }) +} + fn detect_entra_wsl2() -> Option { if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { return None; diff --git a/crates/bascule-shell/src/main.rs b/crates/bascule-shell/src/main.rs index 1f2b6f0..4653096 100644 --- a/crates/bascule-shell/src/main.rs +++ b/crates/bascule-shell/src/main.rs @@ -134,6 +134,10 @@ fn set_env( // has something to match against. if std::env::var_os("BASCULE_ROLES").is_none() { let default_role = match id.auth_method.as_str() { + // M5: SPIFFE workload identities are explicitly cluster-issued + // -> trusted as operator until per-workload role provisioning + // lands in a future milestone. + "spiffe" => "operator", "oidc-entra" | "oidc-cached" => "operator", "kerberos" => "operator", "ssh-key" => "apprentice",