feat(bascule-gateway): derive ShellClass at ceremony grant from posture

Read the cluster's operational posture level from the posture-current
ConfigMap at ceremony grant time. Derive ShellClass via
derive_shell_class() and stamp into the granted SessionScope.

- Normal posture (5) -> ShellClass::System
- Any DEFCON escalation -> ShellClass::Application
- Fail-closed: missing ConfigMap -> Lockdown -> Application
- posture_level_at_establishment stored for audit/breach comparison

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 10:37:30 -04:00
parent e28be3335d
commit 1a54cc3877
3 changed files with 66 additions and 1 deletions

View file

@ -18,6 +18,9 @@ accord-core = { path = "../../guildhouse/services/accord-core" }
accord-opa = { path = "../../guildhouse/services/accord-opa" }
qm-core = { path = "../../guildhouse/services/qm-core" }
# Cross-workspace path dep — substrate governance types (for PostureLevel).
governance-types = { path = "../../substrate/crates/governance-types" }
# Kubernetes
kube = { workspace = true }
k8s-openapi = { workspace = true }

View file

@ -98,7 +98,23 @@ impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for Bascu
};
match response {
CeremonyResponse::Granted(grant) => {
CeremonyResponse::Granted(mut grant) => {
// Derive ShellClass from the cluster's current posture level.
// Reads posture-current ConfigMap, maps level to PostureLevel,
// derives ShellClass, and stamps into the granted scope.
let posture_level = read_posture_level().await;
let shell_class = bascule_core::derive_shell_class(posture_level, None);
grant.granted_scope.shell_class = shell_class;
grant.granted_scope.posture_level_at_establishment =
Some(posture_level.to_wire());
tracing::info!(
ceremony_id = %grant.ceremony_id,
posture_level = ?posture_level,
shell_class = %shell_class,
"Session shell class derived at ceremony grant"
);
let session = self
.session_manager
.create_session(&grant)
@ -403,6 +419,10 @@ 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.
shell_class: bascule_core::ShellClass::default(),
posture_level_at_establishment: None,
}
}
@ -497,3 +517,43 @@ fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::Global
can_view_topology: core.can_view_topology,
}
}
// --- Posture level reader ---
/// 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 {
use governance_types::PostureLevel;
let client = match kube::Client::try_default().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "kube client init failed for posture read");
return PostureLevel::Lockdown;
}
};
let namespace = std::env::var("ENFORCEMENT_NAMESPACE")
.unwrap_or_else(|_| "guildhouse-infra".into());
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::Api;
let api: Api<ConfigMap> = Api::namespaced(client, &namespace);
match api.get("posture-current").await {
Ok(cm) => {
let level_u8: u8 = cm
.data
.as_ref()
.and_then(|d| d.get("level"))
.and_then(|v| v.parse().ok())
.unwrap_or(1);
PostureLevel::from_wire(level_u8).unwrap_or(PostureLevel::Lockdown)
}
Err(e) => {
tracing::warn!(error = %e, "posture-current ConfigMap read failed");
PostureLevel::Lockdown
}
}
}

View file

@ -221,6 +221,8 @@ impl SessionManager {
pathways: vec![ChangePathway::DryRunOnly],
mutation_budget: Some(0),
can_delegate: false,
shell_class: bascule_core::ShellClass::default(),
posture_level_at_establishment: None,
}
}
}