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 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue