diff --git a/bascule-gateway/src/breach.rs b/bascule-gateway/src/breach.rs new file mode 100644 index 0000000..a7bf628 --- /dev/null +++ b/bascule-gateway/src/breach.rs @@ -0,0 +1,255 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Breach evaluator — compares posture changes against active sessions +//! and determines the appropriate response. + +use bascule_core::{derive_shell_class, ShellClass}; +use governance_types::{BreachResponse, PostureLevel}; +use uuid::Uuid; + +/// Action to take on an active session after a posture change. +#[derive(Debug, Clone)] +pub enum BreachAction { + /// Posture still satisfies session requirements. + NoAction, + + /// Downgrade shell class (System → Application). + Downgrade { + session_id: Uuid, + from: ShellClass, + to: ShellClass, + reason: String, + }, + + /// Terminate the session immediately. + Terminate { + session_id: Uuid, + reason: String, + }, +} + +/// Evaluate a posture change for a specific session. +/// +/// Compares new posture against the session's establishment posture +/// and the Accord's `BreachResponse` to determine the action. +pub fn evaluate_breach( + session_id: Uuid, + established_posture: u8, + established_shell_class: ShellClass, + new_posture_level: u8, + breach_response: &BreachResponse, + system_threshold: Option, +) -> BreachAction { + let new_level = match PostureLevel::from_wire(new_posture_level) { + Some(l) => l, + None => return BreachAction::NoAction, // Invalid level — don't act on garbage + }; + + let new_shell_class = derive_shell_class(new_level, system_threshold); + + // If the new shell class still satisfies the established one, no breach + if new_shell_class.satisfies(established_shell_class) { + return BreachAction::NoAction; + } + + // Posture has degraded — apply BreachResponse policy + match breach_response { + BreachResponse::LogOnly => { + tracing::info!( + %session_id, + old = established_posture, + new = new_posture_level, + "Posture breach detected (LogOnly — no enforcement)" + ); + BreachAction::NoAction + } + BreachResponse::AlertDelegates => { + // Delegate notification happens elsewhere (WitnessForwarder). + // For session purposes, same as LogOnly. + tracing::info!( + %session_id, + old = established_posture, + new = new_posture_level, + "Posture breach detected (AlertDelegates)" + ); + BreachAction::NoAction + } + BreachResponse::ReducePosture { .. } => { + if established_shell_class == ShellClass::System { + BreachAction::Downgrade { + session_id, + from: ShellClass::System, + to: ShellClass::Application, + reason: format!( + "posture degraded from {} to {} (below System threshold)", + established_posture, new_posture_level + ), + } + } else { + BreachAction::NoAction + } + } + BreachResponse::SuspendTrust => BreachAction::Terminate { + session_id, + reason: format!( + "trust suspended: posture degraded to {}", + new_posture_level + ), + }, + BreachResponse::RevokeAccord => BreachAction::Terminate { + session_id, + reason: "accord revoked due to posture breach".into(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uuid() -> Uuid { + Uuid::nil() + } + + #[test] + fn no_breach_posture_unchanged() { + let action = evaluate_breach( + uuid(), + 5, // Normal + ShellClass::System, + 5, // Still Normal + &BreachResponse::ReducePosture { + target_level: PostureLevel::Lockdown, + }, + None, + ); + assert!(matches!(action, BreachAction::NoAction)); + } + + #[test] + fn no_breach_posture_improved() { + let action = evaluate_breach( + uuid(), + 4, // Elevated + ShellClass::Application, + 5, // Normal (improved) + &BreachResponse::ReducePosture { + target_level: PostureLevel::Lockdown, + }, + None, + ); + assert!(matches!(action, BreachAction::NoAction)); + } + + #[test] + fn breach_reduce_posture_downgrades_system() { + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 3, // Restricted — below Normal threshold + &BreachResponse::ReducePosture { + target_level: PostureLevel::Restricted, + }, + None, + ); + assert!(matches!(action, BreachAction::Downgrade { .. })); + } + + #[test] + fn breach_suspend_trust_terminates() { + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 3, + &BreachResponse::SuspendTrust, + None, + ); + assert!(matches!(action, BreachAction::Terminate { .. })); + } + + #[test] + fn breach_revoke_accord_terminates() { + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 3, + &BreachResponse::RevokeAccord, + None, + ); + assert!(matches!(action, BreachAction::Terminate { .. })); + } + + #[test] + fn already_application_reduce_posture_no_action() { + let action = evaluate_breach( + uuid(), + 4, + ShellClass::Application, + 2, // Critical — but already Application + &BreachResponse::ReducePosture { + target_level: PostureLevel::Lockdown, + }, + None, + ); + assert!(matches!(action, BreachAction::NoAction)); + } + + #[test] + fn breach_with_custom_threshold() { + // Threshold at Elevated(4) — Normal(5) and Elevated(4) both qualify + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 4, // Elevated — still above threshold + &BreachResponse::ReducePosture { + target_level: PostureLevel::Lockdown, + }, + Some(PostureLevel::Elevated), + ); + assert!(matches!(action, BreachAction::NoAction)); + + // Drop below custom threshold + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 3, // Restricted — below Elevated threshold + &BreachResponse::ReducePosture { + target_level: PostureLevel::Lockdown, + }, + Some(PostureLevel::Elevated), + ); + assert!(matches!(action, BreachAction::Downgrade { .. })); + } + + #[test] + fn log_only_no_enforcement() { + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 3, + &BreachResponse::LogOnly, + None, + ); + assert!(matches!(action, BreachAction::NoAction)); + } + + #[test] + fn invalid_posture_level_no_action() { + let action = evaluate_breach( + uuid(), + 5, + ShellClass::System, + 0, // Invalid wire value + &BreachResponse::SuspendTrust, + None, + ); + assert!(matches!(action, BreachAction::NoAction)); + } +} diff --git a/bascule-gateway/src/main.rs b/bascule-gateway/src/main.rs index bf63878..eb1b019 100644 --- a/bascule-gateway/src/main.rs +++ b/bascule-gateway/src/main.rs @@ -1,5 +1,6 @@ mod audit_pipeline; mod auth; +mod breach; mod ceremony; mod config; mod executor; @@ -168,6 +169,23 @@ spec: reaper_manager.run_reaper().await; }); + // 11b. Spawn posture polling task for breach detection + let breach_manager = session_manager.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + let breach_response = governance_types::BreachResponse::ReducePosture { + target_level: governance_types::PostureLevel::Lockdown, + }; + tracing::info!("Posture breach polling loop started (30s interval)"); + loop { + interval.tick().await; + let level = server::read_posture_level().await; + breach_manager + .on_posture_change(level.to_wire(), &breach_response) + .await; + } + }); + if let Some(cm) = &ceremony_manager { let cm = cm.clone(); tokio::spawn(async move { diff --git a/bascule-gateway/src/server.rs b/bascule-gateway/src/server.rs index 8856f3c..fa661c2 100644 --- a/bascule-gateway/src/server.rs +++ b/bascule-gateway/src/server.rs @@ -419,10 +419,11 @@ fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> Sessi pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(), mutation_budget: proto.mutation_budget, can_delegate: proto.can_delegate, - // ShellClass is server-derived from posture, not client-requested. - // Set to default here; stamped by the ceremony grant path. + // ShellClass and delegation are server-derived, not client-requested. + // Set to defaults here; stamped by the ceremony grant path. shell_class: bascule_core::ShellClass::default(), posture_level_at_establishment: None, + delegation: bascule_core::DelegationScope::default(), } } @@ -523,7 +524,7 @@ fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::Global /// Read the cluster's current operational posture level from the /// `posture-current` ConfigMap. Falls back to `PostureLevel::Lockdown` /// (fail-closed) if the ConfigMap is missing or unreadable. -async fn read_posture_level() -> governance_types::PostureLevel { +pub(crate) async fn read_posture_level() -> governance_types::PostureLevel { use governance_types::PostureLevel; let client = match kube::Client::try_default().await { diff --git a/bascule-gateway/src/session_manager.rs b/bascule-gateway/src/session_manager.rs index cf0a101..18dec6e 100644 --- a/bascule-gateway/src/session_manager.rs +++ b/bascule-gateway/src/session_manager.rs @@ -195,6 +195,77 @@ impl SessionManager { } } + /// Apply a breach action to an active session. + pub async fn apply_breach_action(&self, action: crate::breach::BreachAction) { + use crate::breach::BreachAction; + match action { + BreachAction::NoAction => {} + BreachAction::Downgrade { + session_id, + from, + to, + reason, + } => { + if let Some(mut session) = self.sessions.get_mut(&session_id) { + session.scope.shell_class = to; + tracing::warn!( + session_id = %session_id, + from = %from, + to = %to, + reason = %reason, + "Session shell class downgraded due to posture breach" + ); + } + } + BreachAction::Terminate { + session_id, + reason, + } => { + tracing::error!( + session_id = %session_id, + reason = %reason, + "Session terminated due to posture breach" + ); + self.end_session(&session_id).await; + } + } + } + + /// Handle a posture change notification. + /// + /// Iterates all active sessions and evaluates breach for each. + /// Called by the posture polling loop when the posture-current + /// ConfigMap reports a new level. + pub async fn on_posture_change( + &self, + new_level: u8, + breach_response: &governance_types::BreachResponse, + ) { + use crate::breach::evaluate_breach; + use bascule_core::session::SessionState; + + let actions: Vec = self + .sessions + .iter() + .filter(|entry| entry.value().state == SessionState::Active) + .map(|entry| { + let session = entry.value(); + evaluate_breach( + *entry.key(), + session.scope.posture_level_at_establishment.unwrap_or(0), + session.scope.shell_class, + new_level, + breach_response, + None, + ) + }) + .collect(); + + for action in actions { + self.apply_breach_action(action).await; + } + } + /// Create a default read-only scope for the given namespaces. pub fn default_read_scope(namespaces: &[String]) -> SessionScope { use bascule_core::scope::{ @@ -223,6 +294,7 @@ impl SessionManager { can_delegate: false, shell_class: bascule_core::ShellClass::default(), posture_level_at_establishment: None, + delegation: bascule_core::DelegationScope::default(), } } }