feat(bascule-agent): replace soft-mode attestation with ConfigMap posture reader

Replace hardcoded posture return in AttestationHandler (Shellstream
namespace 0x0005) with PostureReader that reads the posture-current
ConfigMap written by the substrate-operator's posture evaluator.

Data pipeline is now end-to-end:
  Keylime verifier -> posture evaluator -> ConfigMap -> bascule-agent

Behavior:
- posture_source='config': reads posture-current ConfigMap, maps
  level to PostureLevel, caches with configurable TTL (default 30s)
- posture_source='static' or dev_mode: returns configured static
  level and wire value (replaces hardcoded string for clarity)
- Graceful fallback: missing ConfigMap -> PostureLevel::Lockdown
  (fail-closed) + warning log

New dependencies: kube, k8s-openapi, governance-types (via path).
Does NOT add keylime-client — reads ConfigMap JSON directly.

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:17:00 -04:00
parent e3fb2a9a58
commit 47a5484614
6 changed files with 416 additions and 17 deletions

16
Cargo.lock generated
View file

@ -7,6 +7,7 @@ name = "accord-core"
version = "0.1.0"
dependencies = [
"chrono",
"governance-types",
"serde",
"serde_yaml",
"sha2",
@ -390,9 +391,12 @@ dependencies = [
"chrono",
"clap",
"dashmap",
"governance-types",
"hex",
"hfl-types",
"jsonwebtoken",
"k8s-openapi",
"kube",
"rand",
"reqwest",
"rmp-serde",
@ -689,6 +693,7 @@ dependencies = [
"accord-core",
"async-trait",
"chrono",
"governance-types",
"hex",
"registry-protocol",
"serde",
@ -1600,6 +1605,17 @@ dependencies = [
"polyval",
]
[[package]]
name = "governance-types"
version = "0.1.0"
dependencies = [
"hex",
"serde",
"serde_json",
"sha1",
"sha2",
]
[[package]]
name = "group"
version = "0.13.0"

View file

@ -31,6 +31,11 @@ async-trait = { workspace = true }
# Cross-workspace path deps — substrate crates
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
governance-types = { path = "../../substrate/crates/governance-types" }
# Kubernetes (for posture ConfigMap reader)
kube = { workspace = true }
k8s-openapi = { workspace = true }
# Msgpack — retained for convenience constructors and legacy decode paths
rmp-serde = "1"

View file

@ -131,11 +131,29 @@ pub struct SecretsNamespaceConfig {
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AttestationNamespaceConfig {
/// Source of posture data: `"config"` (ConfigMap) or `"static"` (hardcoded).
#[serde(default = "default_posture_source")]
pub posture_source: String,
/// Static posture level name when `posture_source = "static"`.
#[serde(default = "default_posture_level")]
pub default_posture: String,
/// Static posture wire value (1-5) when `posture_source = "static"`.
#[serde(default = "default_static_posture_wire")]
pub static_posture_wire: u8,
/// ConfigMap name to read posture from (default: `"posture-current"`).
#[serde(default = "default_configmap_name")]
pub configmap_name: String,
/// Namespace to read ConfigMap from (default: `"guildhouse-infra"`).
#[serde(default = "default_configmap_namespace")]
pub configmap_namespace: String,
/// Cache TTL in seconds for ConfigMap reads (default: 30).
#[serde(default = "default_cache_ttl_secs")]
pub cache_ttl_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
@ -197,7 +215,19 @@ fn default_posture_source() -> String {
"config".into()
}
fn default_posture_level() -> String {
"standard".into()
"normal".into()
}
fn default_static_posture_wire() -> u8 {
5 // Normal
}
fn default_configmap_name() -> String {
"posture-current".into()
}
fn default_configmap_namespace() -> String {
std::env::var("BASCULE_NAMESPACE").unwrap_or_else(|_| "guildhouse-infra".into())
}
fn default_cache_ttl_secs() -> u64 {
30
}
fn default_scope() -> String {
"operate".into()

View file

@ -19,6 +19,7 @@ mod command_filter;
mod config;
mod governance_server;
mod namespace;
mod posture_reader;
mod session_store;
mod shellstream;
mod ssh_server;
@ -90,12 +91,33 @@ async fn main() -> anyhow::Result<()> {
Namespace::Governance,
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
);
router.register(
Namespace::Attestation,
Arc::new(namespace::attestation::AttestationHandler::new(
config.agent.namespaces.attestation.default_posture.clone(),
)),
let attestation_handler = if dev_mode
|| config.agent.namespaces.attestation.posture_source == "static"
{
info!(
level = %config.agent.namespaces.attestation.default_posture,
wire = config.agent.namespaces.attestation.static_posture_wire,
"Attestation handler: static mode"
);
namespace::attestation::AttestationHandler::new_static(
config.agent.namespaces.attestation.default_posture.clone(),
config.agent.namespaces.attestation.static_posture_wire,
)
} else {
info!(
configmap = %config.agent.namespaces.attestation.configmap_name,
namespace = %config.agent.namespaces.attestation.configmap_namespace,
cache_ttl = config.agent.namespaces.attestation.cache_ttl_secs,
"Attestation handler: ConfigMap mode"
);
let reader = posture_reader::PostureReader::new(
config.agent.namespaces.attestation.configmap_namespace.clone(),
config.agent.namespaces.attestation.configmap_name.clone(),
config.agent.namespaces.attestation.cache_ttl_secs,
);
namespace::attestation::AttestationHandler::new_configmap(Arc::new(reader))
};
router.register(Namespace::Attestation, Arc::new(attestation_handler));
router.register(
Namespace::Audit,
Arc::new(namespace::audit::AuditHandler::new()),

View file

@ -1,18 +1,52 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation.
//!
//! Two operating modes controlled by `posture_source` in config:
//! - `"static"` — returns a configured posture level (dev/test, no cluster)
//! - `"config"` — reads the `posture-current` ConfigMap via [`PostureReader`]
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::shellstream::{attestation, ShellstreamResponse};
use super::NamespaceHandler;
use crate::posture_reader::PostureReader;
use crate::shellstream::{attestation, ShellstreamResponse};
/// Source of posture data for the attestation handler.
pub enum PostureSource {
/// Static posture level for dev/test (no cluster required).
Static {
level_name: String,
level_wire: u8,
},
/// Read posture from the posture-current ConfigMap.
ConfigMap { reader: Arc<PostureReader> },
}
pub struct AttestationHandler {
default_posture: String,
source: PostureSource,
}
impl AttestationHandler {
pub fn new(default_posture: String) -> Self {
Self { default_posture }
/// Create a handler with static posture level (for dev/test mode).
pub fn new_static(level_name: String, level_wire: u8) -> Self {
Self {
source: PostureSource::Static {
level_name,
level_wire,
},
}
}
/// Create a handler backed by the posture-current ConfigMap.
pub fn new_configmap(reader: Arc<PostureReader>) -> Self {
Self {
source: PostureSource::ConfigMap { reader },
}
}
}
@ -38,10 +72,30 @@ impl NamespaceHandler for AttestationHandler {
}
impl AttestationHandler {
/// Resolve the current posture level and source label.
async fn get_posture_info(&self) -> (String, u8, &'static str) {
match &self.source {
PostureSource::Static {
level_name,
level_wire,
} => (level_name.clone(), *level_wire, "static"),
PostureSource::ConfigMap { reader } => {
let (level, source) = reader.get_posture().await;
let name = serde_json::to_value(level)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "unknown".into());
(name, level.to_wire(), source)
}
}
}
async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
let (level_name, level_wire, source) = self.get_posture_info().await;
let response = rmp_serde::to_vec(&serde_json::json!({
"level": self.default_posture,
"source": "config",
"level": level_name,
"level_wire": level_wire,
"source": source,
"timestamp": Utc::now().to_rfc3339(),
}))
.unwrap_or_default();
@ -49,26 +103,115 @@ impl AttestationHandler {
}
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
let (level_name, _, source) = self.get_posture_info().await;
let bundle_id = uuid::Uuid::new_v4().to_string();
let soft_mode = matches!(&self.source, PostureSource::Static { .. });
let response = rmp_serde::to_vec(&serde_json::json!({
"bundle_id": bundle_id,
"session_id": hex::encode(session_id),
"posture": self.default_posture,
"posture": level_name,
"issued_at": Utc::now().to_rfc3339(),
"soft_mode": true,
"soft_mode": soft_mode,
"source": source,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
// Soft mode: always returns valid (no TPM to verify against)
// Verify remains soft-mode until real TPM binding is implemented
let soft_mode = matches!(&self.source, PostureSource::Static { .. });
let response = rmp_serde::to_vec(&serde_json::json!({
"valid": true,
"soft_mode": true,
"soft_mode": soft_mode,
}))
.unwrap_or_default();
ShellstreamResponse::ok(*session_id, 0, response)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn static_handler_returns_configured_level() {
let handler = AttestationHandler::new_static("elevated".into(), 4);
let session_id = [0u8; 16];
let resp = handler.handle(attestation::POSTURE, &[], &session_id).await;
assert_eq!(resp.status, 0x00); // Ok
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
assert_eq!(value["level"], "elevated");
assert_eq!(value["level_wire"], 4);
assert_eq!(value["source"], "static");
}
#[tokio::test]
async fn static_sat_bundle_includes_posture() {
let handler = AttestationHandler::new_static("normal".into(), 5);
let session_id = [0xAA; 16];
let resp = handler
.handle(attestation::SAT_BUNDLE, &[], &session_id)
.await;
assert_eq!(resp.status, 0x00);
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
assert_eq!(value["posture"], "normal");
assert_eq!(value["soft_mode"], true);
assert!(value["bundle_id"].is_string());
}
#[tokio::test]
async fn static_verify_returns_soft_mode() {
let handler = AttestationHandler::new_static("normal".into(), 5);
let session_id = [0u8; 16];
let resp = handler
.handle(attestation::ATTESTATION_VERIFY, &[], &session_id)
.await;
assert_eq!(resp.status, 0x00);
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
assert_eq!(value["valid"], true);
assert_eq!(value["soft_mode"], true);
}
#[tokio::test]
async fn configmap_handler_falls_back_without_cluster() {
let reader = Arc::new(PostureReader::new(
"test-ns".into(),
"posture-current".into(),
30,
));
let handler = AttestationHandler::new_configmap(reader);
let session_id = [0u8; 16];
let resp = handler.handle(attestation::POSTURE, &[], &session_id).await;
assert_eq!(resp.status, 0x00);
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
// Without a cluster, falls back to lockdown
assert_eq!(value["level"], "lockdown");
assert_eq!(value["level_wire"], 1);
assert_eq!(value["source"], "fallback");
}
#[tokio::test]
async fn configmap_verify_not_soft_mode() {
let reader = Arc::new(PostureReader::new(
"test-ns".into(),
"posture-current".into(),
30,
));
let handler = AttestationHandler::new_configmap(reader);
let session_id = [0u8; 16];
let resp = handler
.handle(attestation::ATTESTATION_VERIFY, &[], &session_id)
.await;
let value: serde_json::Value = rmp_serde::from_slice(&resp.payload).unwrap();
assert_eq!(value["soft_mode"], false);
}
#[tokio::test]
async fn unknown_function_returns_error() {
let handler = AttestationHandler::new_static("normal".into(), 5);
let session_id = [0u8; 16];
let resp = handler.handle(0xFFFF, &[], &session_id).await;
assert_eq!(resp.status, 0x01); // Error
}
}

View file

@ -0,0 +1,183 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Reads posture state from the `posture-current` ConfigMap.
//!
//! The ConfigMap is written by the posture evaluator in substrate-operator
//! and contains the cluster's operational posture level. This module does
//! NOT depend on `keylime-client` — it reads the ConfigMap JSON directly
//! to keep the dependency graph clean.
use governance_types::PostureLevel;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, warn};
/// Reads posture level from the `posture-current` ConfigMap.
///
/// Caches the last successful read for a configurable TTL to avoid
/// hammering the Kubernetes API server on every Shellstream POSTURE
/// request.
pub struct PostureReader {
client: tokio::sync::OnceCell<kube::Client>,
namespace: String,
configmap_name: String,
cache: RwLock<Option<CachedPosture>>,
cache_ttl: Duration,
}
struct CachedPosture {
level: PostureLevel,
fetched_at: Instant,
}
impl PostureReader {
pub fn new(namespace: String, configmap_name: String, cache_ttl_secs: u64) -> Self {
Self {
client: tokio::sync::OnceCell::new(),
namespace,
configmap_name,
cache: RwLock::new(None),
cache_ttl: Duration::from_secs(cache_ttl_secs),
}
}
/// Get the current posture level.
///
/// Returns `(PostureLevel, source_label)` where source_label is one of:
/// - `"configmap"` — fresh read from Kubernetes
/// - `"configmap-cached"` — served from cache within TTL
/// - `"configmap-stale"` — cache expired but fresh read failed
/// - `"fallback"` — no data available, returning Lockdown (fail-closed)
pub async fn get_posture(&self) -> (PostureLevel, &'static str) {
// Check cache first
{
let cache = self.cache.read().await;
if let Some(c) = cache.as_ref() {
if c.fetched_at.elapsed() < self.cache_ttl {
return (c.level, "configmap-cached");
}
}
}
// Cache miss or expired — read from ConfigMap
match self.read_from_configmap().await {
Ok(level) => {
debug!(
level = ?level,
configmap = %self.configmap_name,
"posture read from ConfigMap"
);
*self.cache.write().await = Some(CachedPosture {
level,
fetched_at: Instant::now(),
});
(level, "configmap")
}
Err(e) => {
warn!(
error = %e,
configmap = %self.configmap_name,
namespace = %self.namespace,
"posture ConfigMap read failed"
);
// Serve stale cache if available, otherwise fail-closed
let cache = self.cache.read().await;
match cache.as_ref() {
Some(c) => (c.level, "configmap-stale"),
None => (PostureLevel::Lockdown, "fallback"),
}
}
}
}
async fn read_from_configmap(&self) -> Result<PostureLevel, String> {
let client = self
.client
.get_or_try_init(|| async {
kube::Client::try_default()
.await
.map_err(|e| format!("kube client init: {e}"))
})
.await?;
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::Api;
let api: Api<ConfigMap> = Api::namespaced(client.clone(), &self.namespace);
let cm = api
.get(&self.configmap_name)
.await
.map_err(|e| format!("get '{}': {e}", self.configmap_name))?;
let level_str = cm
.data
.as_ref()
.and_then(|d| d.get("level"))
.ok_or_else(|| "ConfigMap missing 'level' key".to_string())?;
let level_u8: u8 = level_str
.parse()
.map_err(|_| format!("invalid level value: '{level_str}'"))?;
PostureLevel::from_wire(level_u8)
.ok_or_else(|| format!("level {level_u8} out of range 1-5"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn posture_level_wire_mapping() {
// Verify the mapping we rely on from governance-types
assert_eq!(PostureLevel::from_wire(1), Some(PostureLevel::Lockdown));
assert_eq!(PostureLevel::from_wire(5), Some(PostureLevel::Normal));
assert_eq!(PostureLevel::from_wire(0), None);
assert_eq!(PostureLevel::from_wire(6), None);
}
#[test]
fn posture_level_serde_names() {
// Verify the snake_case names we use in Shellstream responses
let json = serde_json::to_value(PostureLevel::Normal).unwrap();
assert_eq!(json, "normal");
let json = serde_json::to_value(PostureLevel::Lockdown).unwrap();
assert_eq!(json, "lockdown");
let json = serde_json::to_value(PostureLevel::Restricted).unwrap();
assert_eq!(json, "restricted");
}
#[tokio::test]
async fn reader_returns_fallback_without_cluster() {
let reader = PostureReader::new(
"test-ns".into(),
"posture-current".into(),
30,
);
// No kube cluster available — should fall back to Lockdown
let (level, source) = reader.get_posture().await;
assert_eq!(level, PostureLevel::Lockdown);
assert_eq!(source, "fallback");
}
#[tokio::test]
async fn reader_caches_fallback_result() {
// Verify that multiple calls don't spam warnings
let reader = PostureReader::new(
"test-ns".into(),
"posture-current".into(),
30,
);
let (l1, s1) = reader.get_posture().await;
// The fallback isn't cached (it's returned inline), but the kube
// client OnceCell retries on failure, so this will attempt again
let (l2, _s2) = reader.get_posture().await;
assert_eq!(l1, l2);
assert_eq!(l1, PostureLevel::Lockdown);
assert_eq!(s1, "fallback");
}
}