Compare commits
10 commits
witness-de
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| 6c30ae3181 | |||
| eab96ef3d4 | |||
| 2cfc0b4d5e | |||
| 3526b6975f | |||
|
|
9c492d739a | ||
|
|
aa447f151e | ||
|
|
ece4e2349f | ||
|
|
1a54cc3877 | ||
|
|
e28be3335d | ||
|
|
47a5484614 |
21 changed files with 1672 additions and 136 deletions
92
ARCHITECTURE.md
Normal file
92
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 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
Normal file
40
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# 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,6 +7,7 @@ name = "accord-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"governance-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -390,9 +391,12 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"governance-types",
|
||||||
"hex",
|
"hex",
|
||||||
"hfl-types",
|
"hfl-types",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"k8s-openapi",
|
||||||
|
"kube",
|
||||||
"rand",
|
"rand",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
|
|
@ -420,6 +424,7 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"ceremony-engine",
|
"ceremony-engine",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"governance-types",
|
||||||
"hex",
|
"hex",
|
||||||
"registry-protocol",
|
"registry-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -457,6 +462,8 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"governance-types",
|
||||||
|
"guildhouse-proto",
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
|
|
@ -689,6 +696,7 @@ dependencies = [
|
||||||
"accord-core",
|
"accord-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"governance-types",
|
||||||
"hex",
|
"hex",
|
||||||
"registry-protocol",
|
"registry-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1600,6 +1608,17 @@ dependencies = [
|
||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "governance-types"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
name = "bascule-agent"
|
name = "bascule-agent"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
description = "Governed application sidecar — Shellstream namespace router with attestation"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "bascule-agent"
|
name = "bascule-agent"
|
||||||
|
|
@ -31,6 +34,11 @@ async-trait = { workspace = true }
|
||||||
# Cross-workspace path deps — substrate crates
|
# Cross-workspace path deps — substrate crates
|
||||||
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
|
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
|
||||||
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
|
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
|
# Msgpack — retained for convenience constructors and legacy decode paths
|
||||||
rmp-serde = "1"
|
rmp-serde = "1"
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,29 @@ pub struct SecretsNamespaceConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
pub struct AttestationNamespaceConfig {
|
pub struct AttestationNamespaceConfig {
|
||||||
|
/// Source of posture data: `"config"` (ConfigMap) or `"static"` (hardcoded).
|
||||||
#[serde(default = "default_posture_source")]
|
#[serde(default = "default_posture_source")]
|
||||||
pub posture_source: String,
|
pub posture_source: String,
|
||||||
|
|
||||||
|
/// Static posture level name when `posture_source = "static"`.
|
||||||
#[serde(default = "default_posture_level")]
|
#[serde(default = "default_posture_level")]
|
||||||
pub default_posture: String,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
@ -197,7 +215,19 @@ fn default_posture_source() -> String {
|
||||||
"config".into()
|
"config".into()
|
||||||
}
|
}
|
||||||
fn default_posture_level() -> String {
|
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 {
|
fn default_scope() -> String {
|
||||||
"operate".into()
|
"operate".into()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ mod command_filter;
|
||||||
mod config;
|
mod config;
|
||||||
mod governance_server;
|
mod governance_server;
|
||||||
mod namespace;
|
mod namespace;
|
||||||
|
mod posture_reader;
|
||||||
mod session_store;
|
mod session_store;
|
||||||
mod shellstream;
|
mod shellstream;
|
||||||
mod ssh_server;
|
mod ssh_server;
|
||||||
|
|
@ -90,12 +91,33 @@ async fn main() -> anyhow::Result<()> {
|
||||||
Namespace::Governance,
|
Namespace::Governance,
|
||||||
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
|
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
|
||||||
);
|
);
|
||||||
router.register(
|
let attestation_handler = if dev_mode
|
||||||
Namespace::Attestation,
|
|| config.agent.namespaces.attestation.posture_source == "static"
|
||||||
Arc::new(namespace::attestation::AttestationHandler::new(
|
{
|
||||||
|
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.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(
|
router.register(
|
||||||
Namespace::Audit,
|
Namespace::Audit,
|
||||||
Arc::new(namespace::audit::AuditHandler::new()),
|
Arc::new(namespace::audit::AuditHandler::new()),
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,52 @@
|
||||||
|
// Copyright 2026 Guildhouse Dev
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation.
|
//! 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 async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
use crate::shellstream::{attestation, ShellstreamResponse};
|
|
||||||
use super::NamespaceHandler;
|
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 {
|
pub struct AttestationHandler {
|
||||||
default_posture: String,
|
source: PostureSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AttestationHandler {
|
impl AttestationHandler {
|
||||||
pub fn new(default_posture: String) -> Self {
|
/// Create a handler with static posture level (for dev/test mode).
|
||||||
Self { default_posture }
|
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 {
|
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 {
|
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!({
|
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||||
"level": self.default_posture,
|
"level": level_name,
|
||||||
"source": "config",
|
"level_wire": level_wire,
|
||||||
|
"source": source,
|
||||||
"timestamp": Utc::now().to_rfc3339(),
|
"timestamp": Utc::now().to_rfc3339(),
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -49,26 +103,115 @@ impl AttestationHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
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 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!({
|
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||||
"bundle_id": bundle_id,
|
"bundle_id": bundle_id,
|
||||||
"session_id": hex::encode(session_id),
|
"session_id": hex::encode(session_id),
|
||||||
"posture": self.default_posture,
|
"posture": level_name,
|
||||||
"issued_at": Utc::now().to_rfc3339(),
|
"issued_at": Utc::now().to_rfc3339(),
|
||||||
"soft_mode": true,
|
"soft_mode": soft_mode,
|
||||||
|
"source": source,
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ShellstreamResponse::ok(*session_id, 0, response)
|
ShellstreamResponse::ok(*session_id, 0, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
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!({
|
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||||
"valid": true,
|
"valid": true,
|
||||||
"soft_mode": true,
|
"soft_mode": soft_mode,
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
ShellstreamResponse::ok(*session_id, 0, response)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
183
bascule-agent/src/posture_reader.rs
Normal file
183
bascule-agent/src/posture_reader.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ name = "bascule-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Shared types for the Bascule governance-mediated access control system"
|
description = "Shared types for the Bascule governance-mediated access control system"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
@ -21,3 +23,6 @@ ceremony-engine = { workspace = true }
|
||||||
# Cross-workspace path deps — Guildhouse governance primitives.
|
# Cross-workspace path deps — Guildhouse governance primitives.
|
||||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||||
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
||||||
|
|
||||||
|
# Cross-workspace path dep — substrate governance types (for PostureLevel).
|
||||||
|
governance-types = { path = "../../substrate/crates/governance-types" }
|
||||||
|
|
|
||||||
202
bascule-core/src/delegation.rs
Normal file
202
bascule-core/src/delegation.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
// 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,8 +1,13 @@
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod ceremony;
|
pub mod ceremony;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod delegation;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
pub mod session;
|
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.
|
// Governance ceremony engine — extracted to ceremony-engine crate.
|
||||||
// Re-exported here for backward compatibility while consumers migrate.
|
// Re-exported here for backward compatibility while consumers migrate.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::delegation::DelegationScope;
|
||||||
|
use crate::shell_class::ShellClass;
|
||||||
|
|
||||||
/// Defines what an operator can do within a session.
|
/// Defines what an operator can do within a session.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SessionScope {
|
pub struct SessionScope {
|
||||||
|
|
@ -9,6 +12,21 @@ pub struct SessionScope {
|
||||||
/// Maximum mutations before session requires a new ceremony. None = unlimited.
|
/// Maximum mutations before session requires a new ceremony. None = unlimited.
|
||||||
pub mutation_budget: Option<u32>,
|
pub mutation_budget: Option<u32>,
|
||||||
pub can_delegate: bool,
|
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.
|
/// Per-namespace access rules.
|
||||||
|
|
@ -134,6 +152,9 @@ mod tests {
|
||||||
pathways: vec![ChangePathway::DryRunOnly],
|
pathways: vec![ChangePathway::DryRunOnly],
|
||||||
mutation_budget: None,
|
mutation_budget: None,
|
||||||
can_delegate: false,
|
can_delegate: false,
|
||||||
|
shell_class: ShellClass::default(),
|
||||||
|
posture_level_at_establishment: None,
|
||||||
|
delegation: DelegationScope::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||||
|
|
@ -160,6 +181,9 @@ mod tests {
|
||||||
pathways: vec![],
|
pathways: vec![],
|
||||||
mutation_budget: None,
|
mutation_budget: None,
|
||||||
can_delegate: false,
|
can_delegate: false,
|
||||||
|
shell_class: ShellClass::default(),
|
||||||
|
posture_level_at_establishment: None,
|
||||||
|
delegation: DelegationScope::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||||
|
|
|
||||||
167
bascule-core/src/shell_class.rs
Normal file
167
bascule-core/src/shell_class.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
// 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,6 +3,8 @@ name = "bascule-gateway"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Bascule governance gateway — cluster-side API gateway for governed access"
|
description = "Bascule governance gateway — cluster-side API gateway for governed access"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
repository = "https://git.guildhouse.dev/guildhouse/bascule"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "bascule-gateway"
|
name = "bascule-gateway"
|
||||||
|
|
@ -17,6 +19,10 @@ bascule-proto = { workspace = true }
|
||||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||||
accord-opa = { path = "../../guildhouse/services/accord-opa" }
|
accord-opa = { path = "../../guildhouse/services/accord-opa" }
|
||||||
qm-core = { path = "../../guildhouse/services/qm-core" }
|
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
|
# Kubernetes
|
||||||
kube = { workspace = true }
|
kube = { workspace = true }
|
||||||
|
|
|
||||||
45
bascule-gateway/Dockerfile
Normal file
45
bascule-gateway/Dockerfile
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 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,51 +8,100 @@ use chrono::Utc;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tonic::transport::{Channel, Endpoint};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use guildhouse_proto::quartermaster::v1::{
|
||||||
|
quartermaster_notary_client::QuartermasterNotaryClient, CreateAnchorRequest,
|
||||||
|
};
|
||||||
|
|
||||||
/// A leaf ready for merkle anchoring.
|
/// A leaf ready for merkle anchoring.
|
||||||
struct AuditLeaf {
|
struct AuditLeaf {
|
||||||
event_id: Uuid,
|
event_id: Uuid,
|
||||||
|
#[allow(dead_code)]
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
leaf_hash: [u8; 32],
|
leaf_hash: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buffers audit events and periodically flushes merkle leaf hashes.
|
/// 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.
|
||||||
pub struct AuditPipeline {
|
pub struct AuditPipeline {
|
||||||
pending: Mutex<Vec<AuditLeaf>>,
|
pending: Mutex<Vec<AuditLeaf>>,
|
||||||
db_pool: PgPool,
|
db_pool: PgPool,
|
||||||
batch_size: usize,
|
batch_size: usize,
|
||||||
|
qm_endpoint: String,
|
||||||
|
cluster_id: String,
|
||||||
|
notary_client: Arc<Mutex<Option<QuartermasterNotaryClient<Channel>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuditPipeline {
|
impl AuditPipeline {
|
||||||
pub fn new(db_pool: PgPool, batch_size: usize) -> Self {
|
pub fn new(
|
||||||
|
db_pool: PgPool,
|
||||||
|
batch_size: usize,
|
||||||
|
qm_endpoint: String,
|
||||||
|
cluster_id: String,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pending: Mutex::new(Vec::new()),
|
pending: Mutex::new(Vec::new()),
|
||||||
db_pool,
|
db_pool,
|
||||||
batch_size,
|
batch_size,
|
||||||
|
qm_endpoint,
|
||||||
|
cluster_id,
|
||||||
|
notary_client: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit an audit event: insert into PG and queue leaf for anchoring.
|
/// Lazily establish and cache the QM NotaryClient connection.
|
||||||
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
/// Returns None if QM is unreachable or if cluster_id is unset
|
||||||
// Compute merkle leaf
|
/// (which disables QM submission — bascule stores events locally only).
|
||||||
let canonical = match serde_json_canonicalizer::to_string(&event) {
|
async fn get_or_connect(&self) -> Option<QuartermasterNotaryClient<Channel>> {
|
||||||
Ok(c) => c,
|
if self.cluster_id.is_empty() {
|
||||||
Err(e) => {
|
return None;
|
||||||
tracing::error!("Failed to canonicalize audit event: {e}");
|
}
|
||||||
return;
|
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) => {
|
||||||
let content_hash = Sha256::digest(canonical.as_bytes());
|
tracing::warn!(
|
||||||
let leaf_data = format!(
|
endpoint = %self.qm_endpoint,
|
||||||
"bascule:{}:{}:{}",
|
error = %e,
|
||||||
event.session_id,
|
"Quartermaster notary unreachable; audit events stored locally, will retry at next flush"
|
||||||
event.event_id,
|
);
|
||||||
hex::encode(content_hash)
|
None
|
||||||
);
|
}
|
||||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Insert into PG
|
/// 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`).
|
||||||
|
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
||||||
|
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data_bytes(event).as_bytes());
|
||||||
let command_json = serde_json::to_value(&event.command).unwrap_or_default();
|
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 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();
|
let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default();
|
||||||
|
|
@ -88,7 +137,6 @@ impl AuditPipeline {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue for merkle anchoring if needed
|
|
||||||
if notarize {
|
if notarize {
|
||||||
let mut pending = self.pending.lock().await;
|
let mut pending = self.pending.lock().await;
|
||||||
pending.push(AuditLeaf {
|
pending.push(AuditLeaf {
|
||||||
|
|
@ -105,6 +153,13 @@ impl AuditPipeline {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush pending leaves to Quartermaster for anchoring.
|
/// 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) {
|
pub async fn flush(&self) {
|
||||||
let leaves: Vec<AuditLeaf> = {
|
let leaves: Vec<AuditLeaf> = {
|
||||||
let mut pending = self.pending.lock().await;
|
let mut pending = self.pending.lock().await;
|
||||||
|
|
@ -117,17 +172,68 @@ impl AuditPipeline {
|
||||||
|
|
||||||
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
||||||
|
|
||||||
// Phase 2: mark events as anchored in PG.
|
let mut client = match self.get_or_connect().await {
|
||||||
// Actual QM gRPC submission is a future enhancement -- for now we
|
Some(c) => c,
|
||||||
// compute and store the leaf hashes, which is the cryptographic guarantee.
|
None => {
|
||||||
// The anchor_id will be set when we integrate QM's FlushAnchor RPC.
|
// QM unreachable or disabled. Re-queue leaves for next flush.
|
||||||
for leaf in &leaves {
|
let mut pending = self.pending.lock().await;
|
||||||
let _ = sqlx::query(
|
pending.extend(leaves);
|
||||||
"UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1",
|
return;
|
||||||
)
|
}
|
||||||
.bind(leaf.event_id)
|
};
|
||||||
.execute(&self.db_pool)
|
|
||||||
.await;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,6 +252,45 @@ 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.
|
/// Build an AuditEvent from the filter chain's request context.
|
||||||
pub fn build_audit_event(
|
pub fn build_audit_event(
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
|
|
@ -200,100 +345,30 @@ fn resolve_api_group(resource_type: &str) -> String {
|
||||||
"apps".to_string()
|
"apps".to_string()
|
||||||
}
|
}
|
||||||
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
|
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
|
||||||
_ => String::new(), // core group
|
_ => "".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
||||||
// Convert prost Struct fields to a JSON object manually
|
serde_json::json!(s
|
||||||
let mut map = serde_json::Map::new();
|
.fields
|
||||||
for (key, value) in &s.fields {
|
.iter()
|
||||||
map.insert(key.clone(), prost_value_to_json(value));
|
.map(|(k, v)| (k.clone(), prost_value_to_json(v)))
|
||||||
}
|
.collect::<serde_json::Map<String, serde_json::Value>>())
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
||||||
|
use prost_types::value::Kind;
|
||||||
match &v.kind {
|
match &v.kind {
|
||||||
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
|
Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||||
Some(prost_types::value::Kind::NumberValue(n)) => {
|
Some(Kind::NumberValue(n)) => {
|
||||||
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
|
serde_json::Number::from_f64(*n).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||||
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
Some(Kind::ListValue(l)) => {
|
||||||
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())
|
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
||||||
}
|
}
|
||||||
None => serde_json::Value::Null,
|
Some(Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||||
}
|
_ => 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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
255
bascule-gateway/src/breach.rs
Normal file
255
bascule-gateway/src/breach.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
// Copyright 2026 Guildhouse Dev
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
//! Breach evaluator — compares posture changes against active sessions
|
||||||
|
//! and determines the appropriate response.
|
||||||
|
|
||||||
|
use bascule_core::{derive_shell_class, ShellClass};
|
||||||
|
use governance_types::{BreachResponse, PostureLevel};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action to take on an active session after a posture change.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum BreachAction {
|
||||||
|
/// Posture still satisfies session requirements.
|
||||||
|
NoAction,
|
||||||
|
|
||||||
|
/// Downgrade shell class (System → Application).
|
||||||
|
Downgrade {
|
||||||
|
session_id: Uuid,
|
||||||
|
from: ShellClass,
|
||||||
|
to: ShellClass,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Terminate the session immediately.
|
||||||
|
Terminate {
|
||||||
|
session_id: Uuid,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a posture change for a specific session.
|
||||||
|
///
|
||||||
|
/// Compares new posture against the session's establishment posture
|
||||||
|
/// and the Accord's `BreachResponse` to determine the action.
|
||||||
|
pub fn evaluate_breach(
|
||||||
|
session_id: Uuid,
|
||||||
|
established_posture: u8,
|
||||||
|
established_shell_class: ShellClass,
|
||||||
|
new_posture_level: u8,
|
||||||
|
breach_response: &BreachResponse,
|
||||||
|
system_threshold: Option<PostureLevel>,
|
||||||
|
) -> BreachAction {
|
||||||
|
let new_level = match PostureLevel::from_wire(new_posture_level) {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return BreachAction::NoAction, // Invalid level — don't act on garbage
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_shell_class = derive_shell_class(new_level, system_threshold);
|
||||||
|
|
||||||
|
// If the new shell class still satisfies the established one, no breach
|
||||||
|
if new_shell_class.satisfies(established_shell_class) {
|
||||||
|
return BreachAction::NoAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posture has degraded — apply BreachResponse policy
|
||||||
|
match breach_response {
|
||||||
|
BreachResponse::LogOnly => {
|
||||||
|
tracing::info!(
|
||||||
|
%session_id,
|
||||||
|
old = established_posture,
|
||||||
|
new = new_posture_level,
|
||||||
|
"Posture breach detected (LogOnly — no enforcement)"
|
||||||
|
);
|
||||||
|
BreachAction::NoAction
|
||||||
|
}
|
||||||
|
BreachResponse::AlertDelegates => {
|
||||||
|
// Delegate notification happens elsewhere (WitnessForwarder).
|
||||||
|
// For session purposes, same as LogOnly.
|
||||||
|
tracing::info!(
|
||||||
|
%session_id,
|
||||||
|
old = established_posture,
|
||||||
|
new = new_posture_level,
|
||||||
|
"Posture breach detected (AlertDelegates)"
|
||||||
|
);
|
||||||
|
BreachAction::NoAction
|
||||||
|
}
|
||||||
|
BreachResponse::ReducePosture { .. } => {
|
||||||
|
if established_shell_class == ShellClass::System {
|
||||||
|
BreachAction::Downgrade {
|
||||||
|
session_id,
|
||||||
|
from: ShellClass::System,
|
||||||
|
to: ShellClass::Application,
|
||||||
|
reason: format!(
|
||||||
|
"posture degraded from {} to {} (below System threshold)",
|
||||||
|
established_posture, new_posture_level
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BreachAction::NoAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BreachResponse::SuspendTrust => BreachAction::Terminate {
|
||||||
|
session_id,
|
||||||
|
reason: format!(
|
||||||
|
"trust suspended: posture degraded to {}",
|
||||||
|
new_posture_level
|
||||||
|
),
|
||||||
|
},
|
||||||
|
BreachResponse::RevokeAccord => BreachAction::Terminate {
|
||||||
|
session_id,
|
||||||
|
reason: "accord revoked due to posture breach".into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn uuid() -> Uuid {
|
||||||
|
Uuid::nil()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_breach_posture_unchanged() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5, // Normal
|
||||||
|
ShellClass::System,
|
||||||
|
5, // Still Normal
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Lockdown,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_breach_posture_improved() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
4, // Elevated
|
||||||
|
ShellClass::Application,
|
||||||
|
5, // Normal (improved)
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Lockdown,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breach_reduce_posture_downgrades_system() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
3, // Restricted — below Normal threshold
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Restricted,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::Downgrade { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breach_suspend_trust_terminates() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
3,
|
||||||
|
&BreachResponse::SuspendTrust,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::Terminate { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breach_revoke_accord_terminates() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
3,
|
||||||
|
&BreachResponse::RevokeAccord,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::Terminate { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn already_application_reduce_posture_no_action() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
4,
|
||||||
|
ShellClass::Application,
|
||||||
|
2, // Critical — but already Application
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Lockdown,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn breach_with_custom_threshold() {
|
||||||
|
// Threshold at Elevated(4) — Normal(5) and Elevated(4) both qualify
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
4, // Elevated — still above threshold
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Lockdown,
|
||||||
|
},
|
||||||
|
Some(PostureLevel::Elevated),
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
|
||||||
|
// Drop below custom threshold
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
3, // Restricted — below Elevated threshold
|
||||||
|
&BreachResponse::ReducePosture {
|
||||||
|
target_level: PostureLevel::Lockdown,
|
||||||
|
},
|
||||||
|
Some(PostureLevel::Elevated),
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::Downgrade { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_only_no_enforcement() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
3,
|
||||||
|
&BreachResponse::LogOnly,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_posture_level_no_action() {
|
||||||
|
let action = evaluate_breach(
|
||||||
|
uuid(),
|
||||||
|
5,
|
||||||
|
ShellClass::System,
|
||||||
|
0, // Invalid wire value
|
||||||
|
&BreachResponse::SuspendTrust,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(matches!(action, BreachAction::NoAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,15 @@ pub struct BasculeConfig {
|
||||||
#[serde(default = "default_qm_endpoint")]
|
#[serde(default = "default_qm_endpoint")]
|
||||||
pub qm_endpoint: String,
|
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 ---
|
// --- Accord ---
|
||||||
#[serde(default = "default_accord_path")]
|
#[serde(default = "default_accord_path")]
|
||||||
pub accord_path: String,
|
pub accord_path: String,
|
||||||
|
|
@ -112,6 +121,7 @@ impl BasculeConfig {
|
||||||
let config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(
|
.add_source(
|
||||||
config::Environment::with_prefix("BASCULE")
|
config::Environment::with_prefix("BASCULE")
|
||||||
|
.prefix_separator("_")
|
||||||
.separator("__")
|
.separator("__")
|
||||||
.try_parsing(true),
|
.try_parsing(true),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod audit_pipeline;
|
mod audit_pipeline;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod breach;
|
||||||
mod ceremony;
|
mod ceremony;
|
||||||
mod config;
|
mod config;
|
||||||
mod executor;
|
mod executor;
|
||||||
|
|
@ -68,8 +69,8 @@ spec:
|
||||||
fidelity: always_notarize
|
fidelity: always_notarize
|
||||||
notarize: []
|
notarize: []
|
||||||
logOnly: []
|
logOnly: []
|
||||||
sampled: []
|
# sampled omitted — Option<SampledConfig> default None (struct shape:
|
||||||
sampleRate: 1
|
# {events: [...], sample_rate: N}), `sampled: []` would mis-parse.
|
||||||
reconciliation:
|
reconciliation:
|
||||||
defaultWindow: "24h"
|
defaultWindow: "24h"
|
||||||
onExpiry: alert
|
onExpiry: alert
|
||||||
|
|
@ -131,16 +132,68 @@ spec:
|
||||||
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
|
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
config.audit_batch_size,
|
config.audit_batch_size,
|
||||||
|
config.qm_endpoint.clone(),
|
||||||
|
config.cluster_id.clone(),
|
||||||
));
|
));
|
||||||
let _flush_handle = pipeline.clone().start_flush_loop(
|
let _flush_handle = pipeline.clone().start_flush_loop(
|
||||||
std::time::Duration::from_secs(config.audit_flush_interval_secs),
|
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
|
pipeline
|
||||||
} else {
|
} else {
|
||||||
// No PG — create a pipeline with a lazy pool (will error on submit)
|
// No PG — create a pipeline with a lazy pool (will error on submit)
|
||||||
Arc::new(audit_pipeline::AuditPipeline::new(
|
Arc::new(audit_pipeline::AuditPipeline::new(
|
||||||
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
|
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
|
||||||
config.audit_batch_size,
|
config.audit_batch_size,
|
||||||
|
config.qm_endpoint.clone(),
|
||||||
|
config.cluster_id.clone(),
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -168,6 +221,23 @@ spec:
|
||||||
reaper_manager.run_reaper().await;
|
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 {
|
if let Some(cm) = &ceremony_manager {
|
||||||
let cm = cm.clone();
|
let cm = cm.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,23 @@ impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for Bascu
|
||||||
};
|
};
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
CeremonyResponse::Granted(grant) => {
|
CeremonyResponse::Granted(mut grant) => {
|
||||||
|
// Derive ShellClass from the cluster's current posture level.
|
||||||
|
// Reads posture-current ConfigMap, maps level to PostureLevel,
|
||||||
|
// derives ShellClass, and stamps into the granted scope.
|
||||||
|
let posture_level = read_posture_level().await;
|
||||||
|
let shell_class = bascule_core::derive_shell_class(posture_level, None);
|
||||||
|
grant.granted_scope.shell_class = shell_class;
|
||||||
|
grant.granted_scope.posture_level_at_establishment =
|
||||||
|
Some(posture_level.to_wire());
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
ceremony_id = %grant.ceremony_id,
|
||||||
|
posture_level = ?posture_level,
|
||||||
|
shell_class = %shell_class,
|
||||||
|
"Session shell class derived at ceremony grant"
|
||||||
|
);
|
||||||
|
|
||||||
let session = self
|
let session = self
|
||||||
.session_manager
|
.session_manager
|
||||||
.create_session(&grant)
|
.create_session(&grant)
|
||||||
|
|
@ -403,6 +419,11 @@ fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> Sessi
|
||||||
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
|
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
|
||||||
mutation_budget: proto.mutation_budget,
|
mutation_budget: proto.mutation_budget,
|
||||||
can_delegate: proto.can_delegate,
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,3 +518,43 @@ fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::Global
|
||||||
can_view_topology: core.can_view_topology,
|
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,6 +195,77 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a breach action to an active session.
|
||||||
|
pub async fn apply_breach_action(&self, action: crate::breach::BreachAction) {
|
||||||
|
use crate::breach::BreachAction;
|
||||||
|
match action {
|
||||||
|
BreachAction::NoAction => {}
|
||||||
|
BreachAction::Downgrade {
|
||||||
|
session_id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
if let Some(mut session) = self.sessions.get_mut(&session_id) {
|
||||||
|
session.scope.shell_class = to;
|
||||||
|
tracing::warn!(
|
||||||
|
session_id = %session_id,
|
||||||
|
from = %from,
|
||||||
|
to = %to,
|
||||||
|
reason = %reason,
|
||||||
|
"Session shell class downgraded due to posture breach"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BreachAction::Terminate {
|
||||||
|
session_id,
|
||||||
|
reason,
|
||||||
|
} => {
|
||||||
|
tracing::error!(
|
||||||
|
session_id = %session_id,
|
||||||
|
reason = %reason,
|
||||||
|
"Session terminated due to posture breach"
|
||||||
|
);
|
||||||
|
self.end_session(&session_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a posture change notification.
|
||||||
|
///
|
||||||
|
/// Iterates all active sessions and evaluates breach for each.
|
||||||
|
/// Called by the posture polling loop when the posture-current
|
||||||
|
/// ConfigMap reports a new level.
|
||||||
|
pub async fn on_posture_change(
|
||||||
|
&self,
|
||||||
|
new_level: u8,
|
||||||
|
breach_response: &governance_types::BreachResponse,
|
||||||
|
) {
|
||||||
|
use crate::breach::evaluate_breach;
|
||||||
|
use bascule_core::session::SessionState;
|
||||||
|
|
||||||
|
let actions: Vec<crate::breach::BreachAction> = self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.value().state == SessionState::Active)
|
||||||
|
.map(|entry| {
|
||||||
|
let session = entry.value();
|
||||||
|
evaluate_breach(
|
||||||
|
*entry.key(),
|
||||||
|
session.scope.posture_level_at_establishment.unwrap_or(0),
|
||||||
|
session.scope.shell_class,
|
||||||
|
new_level,
|
||||||
|
breach_response,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
self.apply_breach_action(action).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a default read-only scope for the given namespaces.
|
/// Create a default read-only scope for the given namespaces.
|
||||||
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
|
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
|
||||||
use bascule_core::scope::{
|
use bascule_core::scope::{
|
||||||
|
|
@ -221,6 +292,9 @@ impl SessionManager {
|
||||||
pathways: vec![ChangePathway::DryRunOnly],
|
pathways: vec![ChangePathway::DryRunOnly],
|
||||||
mutation_budget: Some(0),
|
mutation_budget: Some(0),
|
||||||
can_delegate: false,
|
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