feat(gateway): session downgrade on posture breach
Add breach evaluator that compares posture changes against active sessions and applies BreachResponse policy: - LogOnly/AlertDelegates: log, no session enforcement - ReducePosture: downgrade System -> Application, session continues - SuspendTrust: terminate session immediately - RevokeAccord: terminate session, Accord dead Posture change detection via 30s polling loop on posture-current ConfigMap (matching existing reaper interval pattern). No mid-session upgrade — downgrade only, upgrade requires new ceremony. 9 unit tests for breach evaluation covering all BreachResponse variants. Signed-off-by: Tyler King <tking@guildhouse.dev> Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
parent
1a54cc3877
commit
ece4e2349f
4 changed files with 349 additions and 3 deletions
255
bascule-gateway/src/breach.rs
Normal file
255
bascule-gateway/src/breach.rs
Normal file
|
|
@ -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<PostureLevel>,
|
||||||
|
) -> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod audit_pipeline;
|
mod audit_pipeline;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod breach;
|
||||||
mod ceremony;
|
mod ceremony;
|
||||||
mod config;
|
mod config;
|
||||||
mod executor;
|
mod executor;
|
||||||
|
|
@ -168,6 +169,23 @@ spec:
|
||||||
reaper_manager.run_reaper().await;
|
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 {
|
if let Some(cm) = &ceremony_manager {
|
||||||
let cm = cm.clone();
|
let cm = cm.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
|
||||||
mutation_budget: proto.mutation_budget,
|
mutation_budget: proto.mutation_budget,
|
||||||
can_delegate: proto.can_delegate,
|
can_delegate: proto.can_delegate,
|
||||||
// ShellClass is server-derived from posture, not client-requested.
|
// ShellClass and delegation are server-derived, not client-requested.
|
||||||
// Set to default here; stamped by the ceremony grant path.
|
// Set to defaults here; stamped by the ceremony grant path.
|
||||||
shell_class: bascule_core::ShellClass::default(),
|
shell_class: bascule_core::ShellClass::default(),
|
||||||
posture_level_at_establishment: None,
|
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
|
/// Read the cluster's current operational posture level from the
|
||||||
/// `posture-current` ConfigMap. Falls back to `PostureLevel::Lockdown`
|
/// `posture-current` ConfigMap. Falls back to `PostureLevel::Lockdown`
|
||||||
/// (fail-closed) if the ConfigMap is missing or unreadable.
|
/// (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;
|
use governance_types::PostureLevel;
|
||||||
|
|
||||||
let client = match kube::Client::try_default().await {
|
let client = match kube::Client::try_default().await {
|
||||||
|
|
|
||||||
|
|
@ -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<crate::breach::BreachAction> = 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.
|
/// Create a default read-only scope for the given namespaces.
|
||||||
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
|
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
|
||||||
use bascule_core::scope::{
|
use bascule_core::scope::{
|
||||||
|
|
@ -223,6 +294,7 @@ impl SessionManager {
|
||||||
can_delegate: false,
|
can_delegate: false,
|
||||||
shell_class: bascule_core::ShellClass::default(),
|
shell_class: bascule_core::ShellClass::default(),
|
||||||
posture_level_at_establishment: None,
|
posture_level_at_establishment: None,
|
||||||
|
delegation: bascule_core::DelegationScope::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue