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:
Tyler J King 2026-04-15 15:16:11 -04:00
parent 1a54cc3877
commit ece4e2349f
4 changed files with 349 additions and 3 deletions

View 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));
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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(),
}
}
}