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) <noreply@anthropic.com>
Signed-off-by: Claude Code <claude@guildhouse.dev>
This commit is contained in:
Claude Code 2026-04-07 21:05:05 -04:00
parent df5a2a6f88
commit 2520525ec6
2 changed files with 39 additions and 0 deletions

View file

@ -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<Identity> {
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<Identity> {
if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
return None;

View file

@ -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",