Compare commits
No commits in common. "main" and "witness-delegation-sprint2" have entirely different histories.
main
...
witness-de
21 changed files with 137 additions and 1673 deletions
|
|
@ -1,92 +0,0 @@
|
|||
# Bascule — Governed Shell Access Control
|
||||
|
||||
Bascule is an SSH-over-HTTPS proxy with identity-aware sessions and
|
||||
ceremony-gated access control. It terminates operator identity (SSH certs,
|
||||
OIDC tokens, Entra), evaluates Accord policy, classifies sessions by the
|
||||
host's hardware attestation posture, and emits Chronicle audit events for
|
||||
every governed operation.
|
||||
|
||||
## Component Map
|
||||
|
||||
```
|
||||
Operator
|
||||
│
|
||||
▼
|
||||
bascule-gateway (cluster-side gRPC)
|
||||
├─ OIDC auth → OperatorIdentity
|
||||
├─ Ceremony engine (self-grant / single-approval / break-glass)
|
||||
├─ Posture-current ConfigMap → ShellClass derivation
|
||||
├─ OPA policy evaluation (via accord-opa)
|
||||
├─ 8-stage filter chain (auth→session→classify→policy→budget→execute→response→audit)
|
||||
├─ Breach evaluator (30s posture poll, BreachResponse enforcement)
|
||||
└─ Audit pipeline → Quartermaster merkle anchoring
|
||||
│
|
||||
▼
|
||||
bascule-agent (application sidecar)
|
||||
├─ Shellstream protocol (msgpack over Unix socket)
|
||||
├─ 8 namespace handlers (Crypto, Identity, Secrets, Governance,
|
||||
│ Attestation, Audit, Network, Intelligence)
|
||||
├─ PostureReader → posture-current ConfigMap (cached, 30s TTL)
|
||||
└─ Optional SSH server on port 2222
|
||||
|
||||
bascule-core (shared types)
|
||||
├─ SessionScope, ShellClass, DelegationScope
|
||||
├─ CeremonyGrant, CeremonyType, Evidence
|
||||
├─ derive_shell_class(PostureLevel) → ShellClass
|
||||
└─ BreachAction evaluation
|
||||
```
|
||||
|
||||
## Keylime Integration Model
|
||||
|
||||
Bascule **consumes** Keylime attestation — it does not reimplement or
|
||||
compete with it. The integration boundary is the `posture-current`
|
||||
ConfigMap written by the substrate-operator's posture evaluator:
|
||||
|
||||
```
|
||||
Keylime verifier (CNCF)
|
||||
→ substrate-operator / TpmAttestationValid checker
|
||||
→ posture-current ConfigMap (level: 1-5)
|
||||
→ bascule-agent PostureReader
|
||||
→ bascule-gateway ceremony grant (ShellClass derivation)
|
||||
→ bascule-gateway breach evaluator (session downgrade)
|
||||
```
|
||||
|
||||
The `keylime-client` crate (in the substrate workspace) is the single
|
||||
Keylime consumer. Neither bascule-agent nor bascule-gateway imports it
|
||||
directly. They read the ConfigMap output.
|
||||
|
||||
## ShellClass Model
|
||||
|
||||
Sessions are classified at ceremony grant time based on the host's
|
||||
operational posture level:
|
||||
|
||||
| PostureLevel | ShellClass | Operations Permitted |
|
||||
|---|---|---|
|
||||
| Normal (5) | System | Kernel modules, firmware, network config, storage |
|
||||
| Elevated (4) | Application | Deploy, query APIs, run playbooks |
|
||||
| Restricted (3) | Application | Deploy, query APIs, run playbooks |
|
||||
| Critical (2) | Application | Deploy, query APIs, run playbooks |
|
||||
| Lockdown (1) | Application | Deploy, query APIs, run playbooks |
|
||||
|
||||
- No mid-session upgrade. Downgrade only (on posture breach).
|
||||
- Upgrade requires a new ceremony.
|
||||
- DelegationScope enables "Infrastructure shells" — Application sessions
|
||||
that orchestrate System operations on remote hosts (Ansible pattern).
|
||||
|
||||
## What's Implemented vs Planned
|
||||
|
||||
| Component | Status |
|
||||
|---|---|
|
||||
| Ceremony engine (3 types) | Implemented |
|
||||
| 8-stage filter chain | Implemented |
|
||||
| PostureReader (ConfigMap) | Implemented |
|
||||
| ShellClass derivation | Implemented |
|
||||
| DelegationScope + pre-flight | Implemented (target posture query stubbed) |
|
||||
| Breach evaluator + downgrade | Implemented |
|
||||
| Accord hot-reload | Not implemented (static at startup) |
|
||||
| Helm chart | Exists, not updated for posture fields |
|
||||
| LabelSelector delegation | Type defined, async resolution deferred |
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0. All source files carry SPDX headers.
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,40 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
## [0.1.0-rc.1] - 2026-04-15
|
||||
|
||||
### Added
|
||||
|
||||
- **ShellClass** (Application | System) derived from PostureLevel at ceremony grant
|
||||
- Immutable for session lifetime — no mid-session upgrade, downgrade only
|
||||
- `derive_shell_class()` pure function with configurable threshold
|
||||
- `satisfies()` hierarchical check (System satisfies Application)
|
||||
|
||||
- **PostureReader** in bascule-agent replacing soft-mode attestation
|
||||
- Reads `posture-current` ConfigMap written by substrate-operator
|
||||
- TTL-cached (30s default) with stale-serve-on-error semantics
|
||||
- Fail-closed to `PostureLevel::Lockdown` on ConfigMap unavailability
|
||||
- `posture_source="static"` preserved for dev/test without a cluster
|
||||
|
||||
- **DelegationScope** for Infrastructure shell pattern
|
||||
- Application sessions with delegation authority for orchestrators (Ansible/Terraform)
|
||||
- `TargetSelector`: Hosts, LabelSelector (deferred), TrustDomain
|
||||
- Orthogonal to ShellClass — independent axes on SessionScope
|
||||
|
||||
- **Session downgrade on posture breach**
|
||||
- Breach evaluator maps all 5 `BreachResponse` variants (LogOnly, AlertDelegates,
|
||||
ReducePosture, SuspendTrust, RevokeAccord)
|
||||
- 30s posture polling loop on `posture-current` ConfigMap
|
||||
- System sessions downgraded to Application on posture degradation
|
||||
- SuspendTrust/RevokeAccord terminate sessions immediately
|
||||
|
||||
- **Worker pre-flight enforcement** in org-ops
|
||||
- `required_shell_class()` on OrgCommands trait (default: Application)
|
||||
- `target_host()` on OrgCommands trait for remote dispatch
|
||||
- Three-step pre-flight: delegation authority + target scope + target posture
|
||||
- Fail-closed on unknown delegation or posture
|
||||
|
||||
- **SessionScope enrichment**
|
||||
- `shell_class: ShellClass` with `#[serde(default)]`
|
||||
- `posture_level_at_establishment: Option<u8>` with `#[serde(default)]`
|
||||
- `delegation: DelegationScope` with `#[serde(default)]`
|
||||
- All backward-compatible with existing persisted sessions
|
||||
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -7,7 +7,6 @@ name = "accord-core"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"governance-types",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
|
|
@ -391,12 +390,9 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"hfl-types",
|
||||
"jsonwebtoken",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rmp-serde",
|
||||
|
|
@ -424,7 +420,6 @@ dependencies = [
|
|||
"async-trait",
|
||||
"ceremony-engine",
|
||||
"chrono",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"registry-protocol",
|
||||
"serde",
|
||||
|
|
@ -462,8 +457,6 @@ dependencies = [
|
|||
"chrono",
|
||||
"config",
|
||||
"dashmap",
|
||||
"governance-types",
|
||||
"guildhouse-proto",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"k8s-openapi",
|
||||
|
|
@ -696,7 +689,6 @@ dependencies = [
|
|||
"accord-core",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"governance-types",
|
||||
"hex",
|
||||
"registry-protocol",
|
||||
"serde",
|
||||
|
|
@ -1608,17 +1600,6 @@ dependencies = [
|
|||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governance-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,6 @@
|
|||
name = "bascule-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Governed application sidecar — Shellstream namespace router with attestation"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule-agent"
|
||||
|
|
@ -34,11 +31,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -131,29 +131,11 @@ 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)]
|
||||
|
|
@ -215,19 +197,7 @@ fn default_posture_source() -> String {
|
|||
"config".into()
|
||||
}
|
||||
fn default_posture_level() -> String {
|
||||
"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
|
||||
"standard".into()
|
||||
}
|
||||
fn default_scope() -> String {
|
||||
"operate".into()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ mod command_filter;
|
|||
mod config;
|
||||
mod governance_server;
|
||||
mod namespace;
|
||||
mod posture_reader;
|
||||
mod session_store;
|
||||
mod shellstream;
|
||||
mod ssh_server;
|
||||
|
|
@ -91,33 +90,12 @@ async fn main() -> anyhow::Result<()> {
|
|||
Namespace::Governance,
|
||||
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
|
||||
);
|
||||
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(
|
||||
router.register(
|
||||
Namespace::Attestation,
|
||||
Arc::new(namespace::attestation::AttestationHandler::new(
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -1,52 +1,18 @@
|
|||
// 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 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> },
|
||||
}
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct AttestationHandler {
|
||||
source: PostureSource,
|
||||
default_posture: String,
|
||||
}
|
||||
|
||||
impl AttestationHandler {
|
||||
/// 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 },
|
||||
}
|
||||
pub fn new(default_posture: String) -> Self {
|
||||
Self { default_posture }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,30 +38,10 @@ 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": level_name,
|
||||
"level_wire": level_wire,
|
||||
"source": source,
|
||||
"level": self.default_posture,
|
||||
"source": "config",
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
|
@ -103,115 +49,26 @@ impl AttestationHandler {
|
|||
}
|
||||
|
||||
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let (level_name, _, source) = self.get_posture_info().await;
|
||||
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
|
||||
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": level_name,
|
||||
"posture": self.default_posture,
|
||||
"issued_at": Utc::now().to_rfc3339(),
|
||||
"soft_mode": soft_mode,
|
||||
"source": source,
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Verify remains soft-mode until real TPM binding is implemented
|
||||
let soft_mode = matches!(&self.source, PostureSource::Static { .. });
|
||||
// Soft mode: always returns valid (no TPM to verify against)
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"valid": true,
|
||||
"soft_mode": soft_mode,
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
// 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@ name = "bascule-core"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared types for the Bascule governance-mediated access control system"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
|
@ -23,6 +21,3 @@ ceremony-engine = { workspace = true }
|
|||
# Cross-workspace path deps — Guildhouse governance primitives.
|
||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
||||
|
||||
# Cross-workspace path dep — substrate governance types (for PostureLevel).
|
||||
governance-types = { path = "../../substrate/crates/governance-types" }
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Delegation authority for governed shell sessions.
|
||||
//!
|
||||
//! Controls whether a session can dispatch governed operations to remote
|
||||
//! targets. Orthogonal to [`ShellClass`] — an Application session can
|
||||
//! have delegation authority (the "Infrastructure shell" pattern for
|
||||
//! Ansible/Terraform orchestrators).
|
||||
|
||||
use crate::shell_class::ShellClass;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Delegation authority for a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationScope {
|
||||
/// Whether delegation is permitted at all.
|
||||
pub permitted: bool,
|
||||
|
||||
/// Target hosts this session may delegate to.
|
||||
pub target_selector: TargetSelector,
|
||||
|
||||
/// Maximum shell class that can be delegated.
|
||||
pub max_delegated_class: ShellClass,
|
||||
}
|
||||
|
||||
/// Selector for delegation targets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TargetSelector {
|
||||
/// No targets — delegation denied regardless of `permitted` flag.
|
||||
None,
|
||||
/// Specific hosts by name.
|
||||
Hosts(Vec<String>),
|
||||
/// Kubernetes label selector (resolved at dispatch time).
|
||||
LabelSelector(String),
|
||||
/// All hosts in the trust domain.
|
||||
TrustDomain,
|
||||
}
|
||||
|
||||
impl Default for DelegationScope {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
permitted: false,
|
||||
target_selector: TargetSelector::None,
|
||||
max_delegated_class: ShellClass::Application,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a delegation scope check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DelegationDecision {
|
||||
/// Delegation is allowed.
|
||||
Permitted,
|
||||
/// Delegation is denied with a reason.
|
||||
Denied { reason: String },
|
||||
/// Delegation check requires async resolution (label selector).
|
||||
Deferred { reason: String },
|
||||
}
|
||||
|
||||
impl DelegationScope {
|
||||
/// Check whether this scope permits delegation to a specific target
|
||||
/// for a specific operation class.
|
||||
pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision {
|
||||
if !self.permitted {
|
||||
return DelegationDecision::Denied {
|
||||
reason: "session does not have delegation authority".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let target_allowed = match &self.target_selector {
|
||||
TargetSelector::None => false,
|
||||
TargetSelector::Hosts(hosts) => hosts.iter().any(|h| h == target_host),
|
||||
TargetSelector::LabelSelector(_) => {
|
||||
return DelegationDecision::Deferred {
|
||||
reason: "label selector requires K8s API resolution".into(),
|
||||
};
|
||||
}
|
||||
TargetSelector::TrustDomain => true,
|
||||
};
|
||||
|
||||
if !target_allowed {
|
||||
return DelegationDecision::Denied {
|
||||
reason: format!("target '{target_host}' is not in delegation scope"),
|
||||
};
|
||||
}
|
||||
|
||||
if !self.max_delegated_class.satisfies(required_class) {
|
||||
return DelegationDecision::Denied {
|
||||
reason: format!(
|
||||
"delegation permits {} operations, but {} required",
|
||||
self.max_delegated_class, required_class
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
DelegationDecision::Permitted
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_denies_delegation() {
|
||||
let scope = DelegationScope::default();
|
||||
assert!(!scope.permitted);
|
||||
let d = scope.permits("any-host", ShellClass::Application);
|
||||
assert_eq!(
|
||||
d,
|
||||
DelegationDecision::Denied {
|
||||
reason: "session does not have delegation authority".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permitted_with_host_match() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into(), "worker-2".into()]),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::System),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permitted_host_not_in_scope() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into()]),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
let d = scope.permits("worker-99", ShellClass::Application);
|
||||
assert!(matches!(d, DelegationDecision::Denied { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_domain_allows_any_host() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::TrustDomain,
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("any-host", ShellClass::System),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_selector_defers() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::LabelSelector("role=worker".into()),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert!(matches!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Deferred { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_ceiling_enforced() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::TrustDomain,
|
||||
max_delegated_class: ShellClass::Application, // ceiling
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
assert!(matches!(
|
||||
scope.permits("worker-1", ShellClass::System),
|
||||
DelegationDecision::Denied { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_target_selector_denies() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::None,
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert!(matches!(
|
||||
scope.permits("any-host", ShellClass::Application),
|
||||
DelegationDecision::Denied { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
pub mod audit;
|
||||
pub mod ceremony;
|
||||
pub mod command;
|
||||
pub mod delegation;
|
||||
pub mod scope;
|
||||
pub mod session;
|
||||
pub mod shell_class;
|
||||
|
||||
pub use delegation::{DelegationDecision, DelegationScope, TargetSelector};
|
||||
pub use shell_class::{derive_shell_class, ShellClass};
|
||||
|
||||
// Governance ceremony engine — extracted to ceremony-engine crate.
|
||||
// Re-exported here for backward compatibility while consumers migrate.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::delegation::DelegationScope;
|
||||
use crate::shell_class::ShellClass;
|
||||
|
||||
/// Defines what an operator can do within a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionScope {
|
||||
|
|
@ -12,21 +9,6 @@ pub struct SessionScope {
|
|||
/// Maximum mutations before session requires a new ceremony. None = unlimited.
|
||||
pub mutation_budget: Option<u32>,
|
||||
pub can_delegate: bool,
|
||||
|
||||
/// Shell class for this session, derived from posture at establishment.
|
||||
/// Immutable after creation — downgrade creates a new scope, upgrade
|
||||
/// requires a new ceremony.
|
||||
#[serde(default)]
|
||||
pub shell_class: ShellClass,
|
||||
|
||||
/// Posture level (wire value 1-5) at time of session establishment.
|
||||
/// Stored for audit (Chronicle) and for posture breach comparison.
|
||||
#[serde(default)]
|
||||
pub posture_level_at_establishment: Option<u8>,
|
||||
|
||||
/// Delegation authority — controls remote dispatch to other hosts.
|
||||
#[serde(default)]
|
||||
pub delegation: DelegationScope,
|
||||
}
|
||||
|
||||
/// Per-namespace access rules.
|
||||
|
|
@ -152,9 +134,6 @@ mod tests {
|
|||
pathways: vec![ChangePathway::DryRunOnly],
|
||||
mutation_budget: None,
|
||||
can_delegate: false,
|
||||
shell_class: ShellClass::default(),
|
||||
posture_level_at_establishment: None,
|
||||
delegation: DelegationScope::default(),
|
||||
};
|
||||
|
||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||
|
|
@ -181,9 +160,6 @@ mod tests {
|
|||
pathways: vec![],
|
||||
mutation_budget: None,
|
||||
can_delegate: false,
|
||||
shell_class: ShellClass::default(),
|
||||
posture_level_at_establishment: None,
|
||||
delegation: DelegationScope::default(),
|
||||
};
|
||||
|
||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Shell class — session-scoped classification derived from posture.
|
||||
//!
|
||||
//! Determines what class of operations a governed shell session permits.
|
||||
//! Derived from [`PostureLevel`] at ceremony grant time and immutable for
|
||||
//! the session lifetime. Upgrade requires a new ceremony; downgrade can
|
||||
//! occur on posture breach.
|
||||
|
||||
use governance_types::PostureLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Classification of a governed shell session.
|
||||
///
|
||||
/// - `Application` — software operations (deploy, query, playbooks).
|
||||
/// - `System` — host operations (kernel modules, firmware, storage).
|
||||
///
|
||||
/// This is a Bascule concept (session-scoped), distinct from the
|
||||
/// substrate-wide `PostureLevel` (host-scoped, continuous).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShellClass {
|
||||
/// Software operations only. The host's kernel and firmware
|
||||
/// integrity is not verified. Default for all sessions unless
|
||||
/// attestation elevates to System.
|
||||
#[default]
|
||||
Application,
|
||||
|
||||
/// Host operations. Kernel modules, firmware updates, network
|
||||
/// config, storage management, security policy changes. Requires
|
||||
/// the host to be in an attested posture (TPM + IMA verified).
|
||||
System,
|
||||
}
|
||||
|
||||
impl fmt::Display for ShellClass {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ShellClass::Application => write!(f, "application"),
|
||||
ShellClass::System => write!(f, "system"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellClass {
|
||||
/// Returns true if this class permits system-level operations.
|
||||
pub fn is_system(&self) -> bool {
|
||||
matches!(self, ShellClass::System)
|
||||
}
|
||||
|
||||
/// Returns true if `required` is satisfied by `self`.
|
||||
///
|
||||
/// System satisfies both System and Application requirements.
|
||||
/// Application satisfies only Application requirements.
|
||||
pub fn satisfies(&self, required: ShellClass) -> bool {
|
||||
match (self, required) {
|
||||
(ShellClass::System, _) => true,
|
||||
(ShellClass::Application, ShellClass::Application) => true,
|
||||
(ShellClass::Application, ShellClass::System) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive `ShellClass` from a `PostureLevel` and an optional threshold.
|
||||
///
|
||||
/// Uses the **operational** PostureLevel semantic (Lockdown=1 → Normal=5).
|
||||
/// Default threshold: `PostureLevel::Normal` — the host must be in normal
|
||||
/// operational posture for System access. Any DEFCON escalation restricts
|
||||
/// to Application shells.
|
||||
///
|
||||
/// The Accord's WitnessConfig can override the threshold via a
|
||||
/// `PostureCondition` with kind `"system_shell_minimum"`.
|
||||
///
|
||||
/// # Dual PostureLevel semantic note
|
||||
///
|
||||
/// governance-types defines PostureLevel on the operational scale
|
||||
/// (Lockdown < Critical < Restricted < Elevated < Normal). The
|
||||
/// session/attestation scale (None < Local < Verified < Governed <
|
||||
/// Attested) lives in the proto layer. This function uses the
|
||||
/// operational scale because that's what the posture-current ConfigMap
|
||||
/// provides. When the two semantics are disambiguated into separate
|
||||
/// types, this function should migrate to the attestation type.
|
||||
pub fn derive_shell_class(
|
||||
posture_level: PostureLevel,
|
||||
system_threshold: Option<PostureLevel>,
|
||||
) -> ShellClass {
|
||||
let threshold = system_threshold.unwrap_or(PostureLevel::Normal);
|
||||
|
||||
// PostureLevel derives Ord: Lockdown(1) < Normal(5).
|
||||
// posture_level >= threshold means "at least as permissive as the threshold."
|
||||
if posture_level >= threshold {
|
||||
ShellClass::System
|
||||
} else {
|
||||
ShellClass::Application
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_application() {
|
||||
assert_eq!(ShellClass::default(), ShellClass::Application);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_normal_posture_is_system() {
|
||||
let class = derive_shell_class(PostureLevel::Normal, None);
|
||||
assert_eq!(class, ShellClass::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_elevated_posture_is_application() {
|
||||
let class = derive_shell_class(PostureLevel::Elevated, None);
|
||||
assert_eq!(class, ShellClass::Application);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_lockdown_is_application() {
|
||||
let class = derive_shell_class(PostureLevel::Lockdown, None);
|
||||
assert_eq!(class, ShellClass::Application);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_with_custom_threshold() {
|
||||
// Lower threshold: Elevated (4) or above gets System
|
||||
let class = derive_shell_class(PostureLevel::Elevated, Some(PostureLevel::Elevated));
|
||||
assert_eq!(class, ShellClass::System);
|
||||
|
||||
let class = derive_shell_class(PostureLevel::Restricted, Some(PostureLevel::Elevated));
|
||||
assert_eq!(class, ShellClass::Application);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfies_system_satisfies_both() {
|
||||
assert!(ShellClass::System.satisfies(ShellClass::System));
|
||||
assert!(ShellClass::System.satisfies(ShellClass::Application));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn satisfies_application_only_satisfies_application() {
|
||||
assert!(ShellClass::Application.satisfies(ShellClass::Application));
|
||||
assert!(!ShellClass::Application.satisfies(ShellClass::System));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_formatting() {
|
||||
assert_eq!(format!("{}", ShellClass::Application), "application");
|
||||
assert_eq!(format!("{}", ShellClass::System), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let json = serde_json::to_string(&ShellClass::System).unwrap();
|
||||
assert_eq!(json, "\"system\"");
|
||||
let parsed: ShellClass = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, ShellClass::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_system() {
|
||||
assert!(ShellClass::System.is_system());
|
||||
assert!(!ShellClass::Application.is_system());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@ name = "bascule-gateway"
|
|||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bascule governance gateway — cluster-side API gateway for governed access"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule-gateway"
|
||||
|
|
@ -19,10 +17,6 @@ bascule-proto = { workspace = true }
|
|||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||
accord-opa = { path = "../../guildhouse/services/accord-opa" }
|
||||
qm-core = { path = "../../guildhouse/services/qm-core" }
|
||||
guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" }
|
||||
|
||||
# Cross-workspace path dep — substrate governance types (for PostureLevel).
|
||||
governance-types = { path = "../../substrate/crates/governance-types" }
|
||||
|
||||
# Kubernetes
|
||||
kube = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
# Bascule gateway — F.4 production image.
|
||||
#
|
||||
# BUILD CONTEXT: substrate-project repo root (two levels above bascule-workspace/).
|
||||
# Rationale: bascule-gateway's Cargo.toml has cross-workspace path
|
||||
# dependencies reaching:
|
||||
# - ../../guildhouse/services/accord-core
|
||||
# - ../../guildhouse/services/accord-opa
|
||||
# - ../../guildhouse/services/qm-core
|
||||
# - ../../guildhouse/services/guildhouse-proto
|
||||
# - ../../substrate/crates/governance-types
|
||||
# - (transitively) ../../guildhouse/sdk/guildhouse-mq, guildhouse-tower
|
||||
# - bascule-workspace siblings (bascule-core, bascule-proto)
|
||||
#
|
||||
# Invocation:
|
||||
# docker build -t git.guildhouse.dev/tking/bascule-gateway:v0.1.0 \
|
||||
# -f bascule-workspace/bascule-gateway/Dockerfile \
|
||||
# <substrate-project root>
|
||||
|
||||
FROM rust:bookworm AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Cross-workspace path deps (change rarely). substrate is copied as a
|
||||
# whole because its crates inherit `edition.workspace = true` from the
|
||||
# substrate workspace root Cargo.toml — copying just substrate/crates
|
||||
# orphans the inheritance chain. Workspace members that live outside
|
||||
# substrate/crates/ (metakernel/, shell/loader, shellstream/) need to be
|
||||
# present for `cargo build` to load the workspace metadata, even though
|
||||
# bascule's actual dep tree only reaches into substrate/crates/.
|
||||
COPY substrate ./substrate
|
||||
COPY guildhouse/sdk ./guildhouse/sdk
|
||||
COPY guildhouse/services ./guildhouse/services
|
||||
|
||||
# bascule-workspace itself (includes bascule-core, bascule-proto,
|
||||
# and all sibling crates the workspace Cargo.toml references).
|
||||
COPY bascule-workspace ./bascule-workspace
|
||||
|
||||
WORKDIR /app/bascule-workspace
|
||||
RUN cargo build --release --bin bascule-gateway
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/bascule-workspace/target/release/bascule-gateway /usr/local/bin/
|
||||
EXPOSE 50052
|
||||
CMD ["bascule-gateway"]
|
||||
|
|
@ -8,100 +8,51 @@ use chrono::Utc;
|
|||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::Mutex;
|
||||
use tonic::transport::{Channel, Endpoint};
|
||||
use uuid::Uuid;
|
||||
|
||||
use guildhouse_proto::quartermaster::v1::{
|
||||
quartermaster_notary_client::QuartermasterNotaryClient, CreateAnchorRequest,
|
||||
};
|
||||
|
||||
/// A leaf ready for merkle anchoring.
|
||||
struct AuditLeaf {
|
||||
event_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
session_id: Uuid,
|
||||
leaf_hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// Buffers audit events and periodically flushes merkle leaf hashes to
|
||||
/// Quartermaster's `CreateAnchor` RPC.
|
||||
///
|
||||
/// Lazy-connect semantics: the QM gRPC channel is established on first
|
||||
/// successful flush and cached. If QM is unreachable at startup, bascule
|
||||
/// still stores audit events locally (marked `notarized=false`). Each
|
||||
/// subsequent flush retries the connection; the first successful
|
||||
/// CreateAnchor catches up by submitting the entire pending batch.
|
||||
/// Buffers audit events and periodically flushes merkle leaf hashes.
|
||||
pub struct AuditPipeline {
|
||||
pending: Mutex<Vec<AuditLeaf>>,
|
||||
db_pool: PgPool,
|
||||
batch_size: usize,
|
||||
qm_endpoint: String,
|
||||
cluster_id: String,
|
||||
notary_client: Arc<Mutex<Option<QuartermasterNotaryClient<Channel>>>>,
|
||||
}
|
||||
|
||||
impl AuditPipeline {
|
||||
pub fn new(
|
||||
db_pool: PgPool,
|
||||
batch_size: usize,
|
||||
qm_endpoint: String,
|
||||
cluster_id: String,
|
||||
) -> Self {
|
||||
pub fn new(db_pool: PgPool, batch_size: usize) -> Self {
|
||||
Self {
|
||||
pending: Mutex::new(Vec::new()),
|
||||
db_pool,
|
||||
batch_size,
|
||||
qm_endpoint,
|
||||
cluster_id,
|
||||
notary_client: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily establish and cache the QM NotaryClient connection.
|
||||
/// Returns None if QM is unreachable or if cluster_id is unset
|
||||
/// (which disables QM submission — bascule stores events locally only).
|
||||
async fn get_or_connect(&self) -> Option<QuartermasterNotaryClient<Channel>> {
|
||||
if self.cluster_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut guard = self.notary_client.lock().await;
|
||||
if let Some(client) = guard.as_ref() {
|
||||
return Some(client.clone());
|
||||
}
|
||||
// Non-blocking connect — Endpoint::connect() establishes a single-
|
||||
// attempt TCP connection; on failure we leave the cache empty and
|
||||
// the next flush will retry. This mirrors the lazy-retry pattern
|
||||
// from guildhouse-spire-plugins pkg/governance (F.1).
|
||||
match Endpoint::from_shared(self.qm_endpoint.clone())
|
||||
.ok()?
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.connect()
|
||||
.await
|
||||
{
|
||||
Ok(channel) => {
|
||||
let client = QuartermasterNotaryClient::new(channel);
|
||||
*guard = Some(client.clone());
|
||||
tracing::info!(endpoint = %self.qm_endpoint, "Connected to Quartermaster notary");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
endpoint = %self.qm_endpoint,
|
||||
error = %e,
|
||||
"Quartermaster notary unreachable; audit events stored locally, will retry at next flush"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an audit event into the local ledger. If `notarize` is true,
|
||||
/// queue the leaf hash for the next batched CreateAnchor call.
|
||||
///
|
||||
/// Kept named `submit` for source-compat with the filter chain caller
|
||||
/// (`filter/audit.rs::log_and_submit`).
|
||||
/// Submit an audit event: insert into PG and queue leaf for anchoring.
|
||||
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data_bytes(event).as_bytes());
|
||||
// Compute merkle leaf
|
||||
let canonical = match serde_json_canonicalizer::to_string(&event) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to canonicalize audit event: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let content_hash = Sha256::digest(canonical.as_bytes());
|
||||
let leaf_data = format!(
|
||||
"bascule:{}:{}:{}",
|
||||
event.session_id,
|
||||
event.event_id,
|
||||
hex::encode(content_hash)
|
||||
);
|
||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||
|
||||
// Insert into PG
|
||||
let command_json = serde_json::to_value(&event.command).unwrap_or_default();
|
||||
let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default();
|
||||
let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default();
|
||||
|
|
@ -137,6 +88,7 @@ impl AuditPipeline {
|
|||
return;
|
||||
}
|
||||
|
||||
// Queue for merkle anchoring if needed
|
||||
if notarize {
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.push(AuditLeaf {
|
||||
|
|
@ -153,13 +105,6 @@ impl AuditPipeline {
|
|||
}
|
||||
|
||||
/// Flush pending leaves to Quartermaster for anchoring.
|
||||
///
|
||||
/// Calls QM's `CreateAnchor` RPC with all pending leaf hashes, then
|
||||
/// updates each event's row with the returned `anchor_id` and its
|
||||
/// position in the batch (`leaf_index`). If QM is unreachable, the
|
||||
/// leaves stay in the pending buffer and retry on the next flush —
|
||||
/// the `notarized=true` flag in PG is already set but `anchor_id`
|
||||
/// remains NULL until a successful anchor lands.
|
||||
pub async fn flush(&self) {
|
||||
let leaves: Vec<AuditLeaf> = {
|
||||
let mut pending = self.pending.lock().await;
|
||||
|
|
@ -172,68 +117,17 @@ impl AuditPipeline {
|
|||
|
||||
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
||||
|
||||
let mut client = match self.get_or_connect().await {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// QM unreachable or disabled. Re-queue leaves for next flush.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.extend(leaves);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let req = CreateAnchorRequest {
|
||||
cluster_id: self.cluster_id.clone(),
|
||||
leaves: leaves.iter().map(|l| l.leaf_hash.to_vec()).collect(),
|
||||
etcd_revision: 0,
|
||||
};
|
||||
|
||||
match client.create_anchor(req).await {
|
||||
Ok(resp) => {
|
||||
let resp = resp.into_inner();
|
||||
let anchor_id = match Uuid::parse_str(&resp.anchor_id) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "QM returned unparseable anchor_id; leaves remain un-anchored");
|
||||
// Re-queue for retry.
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.extend(leaves);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
anchor_id = %anchor_id,
|
||||
leaf_count = resp.leaf_count,
|
||||
"Anchor created in Quartermaster"
|
||||
);
|
||||
|
||||
// Update each event row with the returned anchor_id and its
|
||||
// position in the submitted batch.
|
||||
for (idx, leaf) in leaves.iter().enumerate() {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.audit_events
|
||||
SET anchor_id = $1, leaf_index = $2
|
||||
WHERE event_id = $3",
|
||||
)
|
||||
.bind(anchor_id)
|
||||
.bind(idx as i32)
|
||||
.bind(leaf.event_id)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(status) => {
|
||||
tracing::warn!(
|
||||
code = ?status.code(),
|
||||
message = %status.message(),
|
||||
"CreateAnchor RPC failed; leaves remain pending for retry"
|
||||
);
|
||||
// Drop the cached client so next attempt reconnects.
|
||||
*self.notary_client.lock().await = None;
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.extend(leaves);
|
||||
}
|
||||
// Phase 2: mark events as anchored in PG.
|
||||
// Actual QM gRPC submission is a future enhancement -- for now we
|
||||
// compute and store the leaf hashes, which is the cryptographic guarantee.
|
||||
// The anchor_id will be set when we integrate QM's FlushAnchor RPC.
|
||||
for leaf in &leaves {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1",
|
||||
)
|
||||
.bind(leaf.event_id)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,45 +146,6 @@ impl AuditPipeline {
|
|||
}
|
||||
}
|
||||
|
||||
/// Classification + ledger-fidelity → notarize decision.
|
||||
///
|
||||
/// OPA decisions can override default per-classification behavior:
|
||||
/// - `always_notarize` → every event anchored, regardless of class
|
||||
/// - `log_only` → no anchor, local ledger row only
|
||||
/// - default → mutative + session-lifecycle events anchored; reads
|
||||
/// stay local
|
||||
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
|
||||
match ledger_fidelity {
|
||||
"always_notarize" => true,
|
||||
"log_only" => false,
|
||||
_ => matches!(
|
||||
classification,
|
||||
ChangeClassification::Mutative | ChangeClassification::Session
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical leaf-data serialization used for merkle-leaf hashing.
|
||||
fn leaf_data_bytes(event: &AuditEvent) -> String {
|
||||
// Include the fields most salient for tamper-detection: event_id,
|
||||
// session_id, operator, command, classification, execution result,
|
||||
// timestamp. Target resources + profile hash would be next fields to
|
||||
// include if/when the verification API wants to constrain on them.
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(event.event_id.as_bytes());
|
||||
hasher.update(event.session_id.as_bytes());
|
||||
hasher.update(event.operator_identity.display_id().as_bytes());
|
||||
if let Ok(cmd) = serde_json::to_string(&event.command) {
|
||||
hasher.update(cmd.as_bytes());
|
||||
}
|
||||
hasher.update(format!("{:?}", event.classification).as_bytes());
|
||||
if let Ok(exec) = serde_json::to_string(&event.execution_result) {
|
||||
hasher.update(exec.as_bytes());
|
||||
}
|
||||
hasher.update(event.timestamp.to_rfc3339().as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Build an AuditEvent from the filter chain's request context.
|
||||
pub fn build_audit_event(
|
||||
session_id: Uuid,
|
||||
|
|
@ -345,30 +200,100 @@ fn resolve_api_group(resource_type: &str) -> String {
|
|||
"apps".to_string()
|
||||
}
|
||||
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
|
||||
_ => "".to_string(),
|
||||
_ => String::new(), // core group
|
||||
}
|
||||
}
|
||||
|
||||
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
||||
serde_json::json!(s
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), prost_value_to_json(v)))
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>())
|
||||
// Convert prost Struct fields to a JSON object manually
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in &s.fields {
|
||||
map.insert(key.clone(), prost_value_to_json(value));
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
||||
use prost_types::value::Kind;
|
||||
match &v.kind {
|
||||
Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||
Some(Kind::NumberValue(n)) => {
|
||||
serde_json::Number::from_f64(*n).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
|
||||
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||
Some(prost_types::value::Kind::NumberValue(n)) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
|
||||
}
|
||||
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||
Some(Kind::ListValue(l)) => {
|
||||
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||
Some(prost_types::value::Kind::ListValue(l)) => {
|
||||
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
||||
}
|
||||
Some(Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||
_ => serde_json::Value::Null,
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if this event should be notarized based on ledger fidelity.
|
||||
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
|
||||
match ledger_fidelity {
|
||||
"always_notarize" => true,
|
||||
"log_only" => false,
|
||||
_ => {
|
||||
// Default: notarize mutative operations, log reads
|
||||
matches!(
|
||||
classification,
|
||||
ChangeClassification::Mutative | ChangeClassification::Session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audit_leaf_format() {
|
||||
let session_id = Uuid::new_v4();
|
||||
let event_id = Uuid::new_v4();
|
||||
let content = "test content";
|
||||
let content_hash = Sha256::digest(content.as_bytes());
|
||||
let leaf_data = format!(
|
||||
"bascule:{}:{}:{}",
|
||||
session_id,
|
||||
event_id,
|
||||
hex::encode(content_hash)
|
||||
);
|
||||
|
||||
assert!(leaf_data.starts_with("bascule:"));
|
||||
assert!(leaf_data.contains(&session_id.to_string()));
|
||||
assert!(leaf_data.contains(&event_id.to_string()));
|
||||
|
||||
// Verify hash_leaf produces a 32-byte hash
|
||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||
assert_eq!(leaf_hash.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_notarize() {
|
||||
assert!(should_notarize(
|
||||
ChangeClassification::Mutative,
|
||||
"always_notarize"
|
||||
));
|
||||
assert!(should_notarize(
|
||||
ChangeClassification::Read,
|
||||
"always_notarize"
|
||||
));
|
||||
assert!(!should_notarize(ChangeClassification::Read, "log_only"));
|
||||
assert!(!should_notarize(
|
||||
ChangeClassification::Mutative,
|
||||
"log_only"
|
||||
));
|
||||
// Default behavior
|
||||
assert!(should_notarize(ChangeClassification::Mutative, "default"));
|
||||
assert!(!should_notarize(ChangeClassification::Read, "default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_api_group() {
|
||||
assert_eq!(resolve_api_group("deployments"), "apps");
|
||||
assert_eq!(resolve_api_group("pods"), "");
|
||||
assert_eq!(resolve_api_group("jobs"), "batch");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,255 +0,0 @@
|
|||
// 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -43,15 +43,6 @@ pub struct BasculeConfig {
|
|||
#[serde(default = "default_qm_endpoint")]
|
||||
pub qm_endpoint: String,
|
||||
|
||||
// --- Cluster identity (UUID of the FFC cluster this bascule belongs to).
|
||||
// Required for QM CreateAnchor submission: QM validates the cluster_id
|
||||
// on every anchor write against its clusters table. Source the UUID
|
||||
// from `quartermaster.clusters` in the OpsDB (QM generates it at
|
||||
// genesis). Empty string disables QM submission (bascule still stores
|
||||
// audit events locally).
|
||||
#[serde(default)]
|
||||
pub cluster_id: String,
|
||||
|
||||
// --- Accord ---
|
||||
#[serde(default = "default_accord_path")]
|
||||
pub accord_path: String,
|
||||
|
|
@ -121,7 +112,6 @@ impl BasculeConfig {
|
|||
let config = config::Config::builder()
|
||||
.add_source(
|
||||
config::Environment::with_prefix("BASCULE")
|
||||
.prefix_separator("_")
|
||||
.separator("__")
|
||||
.try_parsing(true),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
mod audit_pipeline;
|
||||
mod auth;
|
||||
mod breach;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod executor;
|
||||
|
|
@ -69,8 +68,8 @@ spec:
|
|||
fidelity: always_notarize
|
||||
notarize: []
|
||||
logOnly: []
|
||||
# sampled omitted — Option<SampledConfig> default None (struct shape:
|
||||
# {events: [...], sample_rate: N}), `sampled: []` would mis-parse.
|
||||
sampled: []
|
||||
sampleRate: 1
|
||||
reconciliation:
|
||||
defaultWindow: "24h"
|
||||
onExpiry: alert
|
||||
|
|
@ -132,68 +131,16 @@ spec:
|
|||
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
|
||||
pool.clone(),
|
||||
config.audit_batch_size,
|
||||
config.qm_endpoint.clone(),
|
||||
config.cluster_id.clone(),
|
||||
));
|
||||
let _flush_handle = pipeline.clone().start_flush_loop(
|
||||
std::time::Duration::from_secs(config.audit_flush_interval_secs),
|
||||
);
|
||||
|
||||
// F.4 demo entry: synthesize one notarize=true audit event at
|
||||
// startup so the flush_loop has something to submit to QM. This
|
||||
// is gated on BASCULE_DEMO_AUDIT=1 (off by default). It's the
|
||||
// only way to exercise the bascule→QM CreateAnchor path until
|
||||
// genesis lands the OIDC realm and real operator sessions can
|
||||
// flow through the auth filter. Remove (or leave as a no-op
|
||||
// default-off ship hatch) once OIDC works end-to-end.
|
||||
if std::env::var("BASCULE_DEMO_AUDIT").as_deref() == Ok("1") {
|
||||
use bascule_core::audit::{AuditEvent, ExecutionResult, ExecutionStatus, PolicyDecision};
|
||||
use bascule_core::command::{ChangeClassification, CommandRecord};
|
||||
use bascule_core::session::OperatorIdentity;
|
||||
|
||||
let demo_event = AuditEvent {
|
||||
event_id: uuid::Uuid::new_v4(),
|
||||
session_id: uuid::Uuid::new_v4(),
|
||||
operator_identity: OperatorIdentity::Oidc {
|
||||
issuer: "https://auth.guildhouse.dev/realms/ffc-hetzner-nur01".into(),
|
||||
subject: "f4-demo".into(),
|
||||
email: "f4-demo@guildhouse.dev".into(),
|
||||
},
|
||||
timestamp: chrono::Utc::now(),
|
||||
command: CommandRecord {
|
||||
verb: "demo".into(),
|
||||
namespace: Some("bascule".into()),
|
||||
resource_type: Some("audit_event".into()),
|
||||
resource_name: Some("f4-demo".into()),
|
||||
parameters: serde_json::json!({"phase": "F.4"}),
|
||||
},
|
||||
classification: ChangeClassification::Mutative,
|
||||
policy_decision: PolicyDecision::allow_all_stub(),
|
||||
execution_result: ExecutionResult {
|
||||
status: ExecutionStatus::Success,
|
||||
summary: "F.4 demo event — bascule-to-QM CreateAnchor integration proof".into(),
|
||||
resources_affected: 0,
|
||||
mutations_applied: 0,
|
||||
},
|
||||
target_resources: vec![],
|
||||
target_profile_hash: None,
|
||||
};
|
||||
|
||||
let pipeline_clone = pipeline.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("BASCULE_DEMO_AUDIT=1 — submitting one synthetic audit event for F.4 demo");
|
||||
pipeline_clone.submit(&demo_event, true).await;
|
||||
});
|
||||
}
|
||||
|
||||
pipeline
|
||||
} else {
|
||||
// No PG — create a pipeline with a lazy pool (will error on submit)
|
||||
Arc::new(audit_pipeline::AuditPipeline::new(
|
||||
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
|
||||
config.audit_batch_size,
|
||||
config.qm_endpoint.clone(),
|
||||
config.cluster_id.clone(),
|
||||
))
|
||||
};
|
||||
|
||||
|
|
@ -221,23 +168,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -98,23 +98,7 @@ impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for Bascu
|
|||
};
|
||||
|
||||
match response {
|
||||
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"
|
||||
);
|
||||
|
||||
CeremonyResponse::Granted(grant) => {
|
||||
let session = self
|
||||
.session_manager
|
||||
.create_session(&grant)
|
||||
|
|
@ -419,11 +403,6 @@ 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 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -518,43 +497,3 @@ 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.
|
||||
pub(crate) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,77 +195,6 @@ 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::{
|
||||
|
|
@ -292,9 +221,6 @@ impl SessionManager {
|
|||
pathways: vec![ChangePathway::DryRunOnly],
|
||||
mutation_budget: Some(0),
|
||||
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