Compare commits
No commits in common. "main" and "cid-reconciliation-phase2" have entirely different histories.
main
...
cid-reconc
23 changed files with 153 additions and 1725 deletions
|
|
@ -1,92 +0,0 @@
|
||||||
# Bascule — Governed Shell Access Control
|
|
||||||
|
|
||||||
Bascule is an SSH-over-HTTPS proxy with identity-aware sessions and
|
|
||||||
ceremony-gated access control. It terminates operator identity (SSH certs,
|
|
||||||
OIDC tokens, Entra), evaluates Accord policy, classifies sessions by the
|
|
||||||
host's hardware attestation posture, and emits Chronicle audit events for
|
|
||||||
every governed operation.
|
|
||||||
|
|
||||||
## Component Map
|
|
||||||
|
|
||||||
```
|
|
||||||
Operator
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
bascule-gateway (cluster-side gRPC)
|
|
||||||
├─ OIDC auth → OperatorIdentity
|
|
||||||
├─ Ceremony engine (self-grant / single-approval / break-glass)
|
|
||||||
├─ Posture-current ConfigMap → ShellClass derivation
|
|
||||||
├─ OPA policy evaluation (via accord-opa)
|
|
||||||
├─ 8-stage filter chain (auth→session→classify→policy→budget→execute→response→audit)
|
|
||||||
├─ Breach evaluator (30s posture poll, BreachResponse enforcement)
|
|
||||||
└─ Audit pipeline → Quartermaster merkle anchoring
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
bascule-agent (application sidecar)
|
|
||||||
├─ Shellstream protocol (msgpack over Unix socket)
|
|
||||||
├─ 8 namespace handlers (Crypto, Identity, Secrets, Governance,
|
|
||||||
│ Attestation, Audit, Network, Intelligence)
|
|
||||||
├─ PostureReader → posture-current ConfigMap (cached, 30s TTL)
|
|
||||||
└─ Optional SSH server on port 2222
|
|
||||||
|
|
||||||
bascule-core (shared types)
|
|
||||||
├─ SessionScope, ShellClass, DelegationScope
|
|
||||||
├─ CeremonyGrant, CeremonyType, Evidence
|
|
||||||
├─ derive_shell_class(PostureLevel) → ShellClass
|
|
||||||
└─ BreachAction evaluation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keylime Integration Model
|
|
||||||
|
|
||||||
Bascule **consumes** Keylime attestation — it does not reimplement or
|
|
||||||
compete with it. The integration boundary is the `posture-current`
|
|
||||||
ConfigMap written by the substrate-operator's posture evaluator:
|
|
||||||
|
|
||||||
```
|
|
||||||
Keylime verifier (CNCF)
|
|
||||||
→ substrate-operator / TpmAttestationValid checker
|
|
||||||
→ posture-current ConfigMap (level: 1-5)
|
|
||||||
→ bascule-agent PostureReader
|
|
||||||
→ bascule-gateway ceremony grant (ShellClass derivation)
|
|
||||||
→ bascule-gateway breach evaluator (session downgrade)
|
|
||||||
```
|
|
||||||
|
|
||||||
The `keylime-client` crate (in the substrate workspace) is the single
|
|
||||||
Keylime consumer. Neither bascule-agent nor bascule-gateway imports it
|
|
||||||
directly. They read the ConfigMap output.
|
|
||||||
|
|
||||||
## ShellClass Model
|
|
||||||
|
|
||||||
Sessions are classified at ceremony grant time based on the host's
|
|
||||||
operational posture level:
|
|
||||||
|
|
||||||
| PostureLevel | ShellClass | Operations Permitted |
|
|
||||||
|---|---|---|
|
|
||||||
| Normal (5) | System | Kernel modules, firmware, network config, storage |
|
|
||||||
| Elevated (4) | Application | Deploy, query APIs, run playbooks |
|
|
||||||
| Restricted (3) | Application | Deploy, query APIs, run playbooks |
|
|
||||||
| Critical (2) | Application | Deploy, query APIs, run playbooks |
|
|
||||||
| Lockdown (1) | Application | Deploy, query APIs, run playbooks |
|
|
||||||
|
|
||||||
- No mid-session upgrade. Downgrade only (on posture breach).
|
|
||||||
- Upgrade requires a new ceremony.
|
|
||||||
- DelegationScope enables "Infrastructure shells" — Application sessions
|
|
||||||
that orchestrate System operations on remote hosts (Ansible pattern).
|
|
||||||
|
|
||||||
## What's Implemented vs Planned
|
|
||||||
|
|
||||||
| Component | Status |
|
|
||||||
|---|---|
|
|
||||||
| Ceremony engine (3 types) | Implemented |
|
|
||||||
| 8-stage filter chain | Implemented |
|
|
||||||
| PostureReader (ConfigMap) | Implemented |
|
|
||||||
| ShellClass derivation | Implemented |
|
|
||||||
| DelegationScope + pre-flight | Implemented (target posture query stubbed) |
|
|
||||||
| Breach evaluator + downgrade | Implemented |
|
|
||||||
| Accord hot-reload | Not implemented (static at startup) |
|
|
||||||
| Helm chart | Exists, not updated for posture fields |
|
|
||||||
| LabelSelector delegation | Type defined, async resolution deferred |
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache-2.0. All source files carry SPDX headers.
|
|
||||||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,40 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## [0.1.0-rc.1] - 2026-04-15
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **ShellClass** (Application | System) derived from PostureLevel at ceremony grant
|
|
||||||
- Immutable for session lifetime — no mid-session upgrade, downgrade only
|
|
||||||
- `derive_shell_class()` pure function with configurable threshold
|
|
||||||
- `satisfies()` hierarchical check (System satisfies Application)
|
|
||||||
|
|
||||||
- **PostureReader** in bascule-agent replacing soft-mode attestation
|
|
||||||
- Reads `posture-current` ConfigMap written by substrate-operator
|
|
||||||
- TTL-cached (30s default) with stale-serve-on-error semantics
|
|
||||||
- Fail-closed to `PostureLevel::Lockdown` on ConfigMap unavailability
|
|
||||||
- `posture_source="static"` preserved for dev/test without a cluster
|
|
||||||
|
|
||||||
- **DelegationScope** for Infrastructure shell pattern
|
|
||||||
- Application sessions with delegation authority for orchestrators (Ansible/Terraform)
|
|
||||||
- `TargetSelector`: Hosts, LabelSelector (deferred), TrustDomain
|
|
||||||
- Orthogonal to ShellClass — independent axes on SessionScope
|
|
||||||
|
|
||||||
- **Session downgrade on posture breach**
|
|
||||||
- Breach evaluator maps all 5 `BreachResponse` variants (LogOnly, AlertDelegates,
|
|
||||||
ReducePosture, SuspendTrust, RevokeAccord)
|
|
||||||
- 30s posture polling loop on `posture-current` ConfigMap
|
|
||||||
- System sessions downgraded to Application on posture degradation
|
|
||||||
- SuspendTrust/RevokeAccord terminate sessions immediately
|
|
||||||
|
|
||||||
- **Worker pre-flight enforcement** in org-ops
|
|
||||||
- `required_shell_class()` on OrgCommands trait (default: Application)
|
|
||||||
- `target_host()` on OrgCommands trait for remote dispatch
|
|
||||||
- Three-step pre-flight: delegation authority + target scope + target posture
|
|
||||||
- Fail-closed on unknown delegation or posture
|
|
||||||
|
|
||||||
- **SessionScope enrichment**
|
|
||||||
- `shell_class: ShellClass` with `#[serde(default)]`
|
|
||||||
- `posture_level_at_establishment: Option<u8>` with `#[serde(default)]`
|
|
||||||
- `delegation: DelegationScope` with `#[serde(default)]`
|
|
||||||
- All backward-compatible with existing persisted sessions
|
|
||||||
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -7,7 +7,6 @@ name = "accord-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"governance-types",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -391,12 +390,9 @@ 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",
|
||||||
|
|
@ -424,7 +420,6 @@ dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"ceremony-engine",
|
"ceremony-engine",
|
||||||
"chrono",
|
"chrono",
|
||||||
"governance-types",
|
|
||||||
"hex",
|
"hex",
|
||||||
"registry-protocol",
|
"registry-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -462,8 +457,6 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"governance-types",
|
|
||||||
"guildhouse-proto",
|
|
||||||
"hex",
|
"hex",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
|
|
@ -696,7 +689,6 @@ dependencies = [
|
||||||
"accord-core",
|
"accord-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"governance-types",
|
|
||||||
"hex",
|
"hex",
|
||||||
"registry-protocol",
|
"registry-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1608,17 +1600,6 @@ 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,9 +2,6 @@
|
||||||
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"
|
||||||
|
|
@ -34,11 +31,6 @@ 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,29 +131,11 @@ 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)]
|
||||||
|
|
@ -215,19 +197,7 @@ fn default_posture_source() -> String {
|
||||||
"config".into()
|
"config".into()
|
||||||
}
|
}
|
||||||
fn default_posture_level() -> String {
|
fn default_posture_level() -> String {
|
||||||
"normal".into()
|
"standard".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,7 +19,6 @@ 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;
|
||||||
|
|
@ -91,33 +90,12 @@ 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)),
|
||||||
);
|
);
|
||||||
let attestation_handler = if dev_mode
|
router.register(
|
||||||
|| config.agent.namespaces.attestation.posture_source == "static"
|
Namespace::Attestation,
|
||||||
{
|
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,52 +1,18 @@
|
||||||
// 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 super::NamespaceHandler;
|
|
||||||
use crate::posture_reader::PostureReader;
|
|
||||||
use crate::shellstream::{attestation, ShellstreamResponse};
|
use crate::shellstream::{attestation, ShellstreamResponse};
|
||||||
|
use super::NamespaceHandler;
|
||||||
/// 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 {
|
||||||
source: PostureSource,
|
default_posture: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AttestationHandler {
|
impl AttestationHandler {
|
||||||
/// Create a handler with static posture level (for dev/test mode).
|
pub fn new(default_posture: String) -> Self {
|
||||||
pub fn new_static(level_name: String, level_wire: u8) -> Self {
|
Self { default_posture }
|
||||||
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 },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,30 +38,10 @@ 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": level_name,
|
"level": self.default_posture,
|
||||||
"level_wire": level_wire,
|
"source": "config",
|
||||||
"source": source,
|
|
||||||
"timestamp": Utc::now().to_rfc3339(),
|
"timestamp": Utc::now().to_rfc3339(),
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -103,115 +49,26 @@ impl AttestationHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||||
let (level_name, _, source) = self.get_posture_info().await;
|
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
|
||||||
let bundle_id = uuid::Uuid::new_v4().to_string();
|
let 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": level_name,
|
"posture": self.default_posture,
|
||||||
"issued_at": Utc::now().to_rfc3339(),
|
"issued_at": Utc::now().to_rfc3339(),
|
||||||
"soft_mode": soft_mode,
|
"soft_mode": true,
|
||||||
"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 {
|
||||||
// Verify remains soft-mode until real TPM binding is implemented
|
// Soft mode: always returns valid (no TPM to verify against)
|
||||||
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": soft_mode,
|
"soft_mode": true,
|
||||||
}))
|
}))
|
||||||
.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
// Copyright 2026 Guildhouse Dev
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
//! Reads posture state from the `posture-current` ConfigMap.
|
|
||||||
//!
|
|
||||||
//! The ConfigMap is written by the posture evaluator in substrate-operator
|
|
||||||
//! and contains the cluster's operational posture level. This module does
|
|
||||||
//! NOT depend on `keylime-client` — it reads the ConfigMap JSON directly
|
|
||||||
//! to keep the dependency graph clean.
|
|
||||||
|
|
||||||
use governance_types::PostureLevel;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tracing::{debug, warn};
|
|
||||||
|
|
||||||
/// Reads posture level from the `posture-current` ConfigMap.
|
|
||||||
///
|
|
||||||
/// Caches the last successful read for a configurable TTL to avoid
|
|
||||||
/// hammering the Kubernetes API server on every Shellstream POSTURE
|
|
||||||
/// request.
|
|
||||||
pub struct PostureReader {
|
|
||||||
client: tokio::sync::OnceCell<kube::Client>,
|
|
||||||
namespace: String,
|
|
||||||
configmap_name: String,
|
|
||||||
cache: RwLock<Option<CachedPosture>>,
|
|
||||||
cache_ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CachedPosture {
|
|
||||||
level: PostureLevel,
|
|
||||||
fetched_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostureReader {
|
|
||||||
pub fn new(namespace: String, configmap_name: String, cache_ttl_secs: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
client: tokio::sync::OnceCell::new(),
|
|
||||||
namespace,
|
|
||||||
configmap_name,
|
|
||||||
cache: RwLock::new(None),
|
|
||||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current posture level.
|
|
||||||
///
|
|
||||||
/// Returns `(PostureLevel, source_label)` where source_label is one of:
|
|
||||||
/// - `"configmap"` — fresh read from Kubernetes
|
|
||||||
/// - `"configmap-cached"` — served from cache within TTL
|
|
||||||
/// - `"configmap-stale"` — cache expired but fresh read failed
|
|
||||||
/// - `"fallback"` — no data available, returning Lockdown (fail-closed)
|
|
||||||
pub async fn get_posture(&self) -> (PostureLevel, &'static str) {
|
|
||||||
// Check cache first
|
|
||||||
{
|
|
||||||
let cache = self.cache.read().await;
|
|
||||||
if let Some(c) = cache.as_ref() {
|
|
||||||
if c.fetched_at.elapsed() < self.cache_ttl {
|
|
||||||
return (c.level, "configmap-cached");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss or expired — read from ConfigMap
|
|
||||||
match self.read_from_configmap().await {
|
|
||||||
Ok(level) => {
|
|
||||||
debug!(
|
|
||||||
level = ?level,
|
|
||||||
configmap = %self.configmap_name,
|
|
||||||
"posture read from ConfigMap"
|
|
||||||
);
|
|
||||||
*self.cache.write().await = Some(CachedPosture {
|
|
||||||
level,
|
|
||||||
fetched_at: Instant::now(),
|
|
||||||
});
|
|
||||||
(level, "configmap")
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
error = %e,
|
|
||||||
configmap = %self.configmap_name,
|
|
||||||
namespace = %self.namespace,
|
|
||||||
"posture ConfigMap read failed"
|
|
||||||
);
|
|
||||||
// Serve stale cache if available, otherwise fail-closed
|
|
||||||
let cache = self.cache.read().await;
|
|
||||||
match cache.as_ref() {
|
|
||||||
Some(c) => (c.level, "configmap-stale"),
|
|
||||||
None => (PostureLevel::Lockdown, "fallback"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_from_configmap(&self) -> Result<PostureLevel, String> {
|
|
||||||
let client = self
|
|
||||||
.client
|
|
||||||
.get_or_try_init(|| async {
|
|
||||||
kube::Client::try_default()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("kube client init: {e}"))
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
use k8s_openapi::api::core::v1::ConfigMap;
|
|
||||||
use kube::api::Api;
|
|
||||||
|
|
||||||
let api: Api<ConfigMap> = Api::namespaced(client.clone(), &self.namespace);
|
|
||||||
let cm = api
|
|
||||||
.get(&self.configmap_name)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("get '{}': {e}", self.configmap_name))?;
|
|
||||||
|
|
||||||
let level_str = cm
|
|
||||||
.data
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|d| d.get("level"))
|
|
||||||
.ok_or_else(|| "ConfigMap missing 'level' key".to_string())?;
|
|
||||||
|
|
||||||
let level_u8: u8 = level_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid level value: '{level_str}'"))?;
|
|
||||||
|
|
||||||
PostureLevel::from_wire(level_u8)
|
|
||||||
.ok_or_else(|| format!("level {level_u8} out of range 1-5"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn posture_level_wire_mapping() {
|
|
||||||
// Verify the mapping we rely on from governance-types
|
|
||||||
assert_eq!(PostureLevel::from_wire(1), Some(PostureLevel::Lockdown));
|
|
||||||
assert_eq!(PostureLevel::from_wire(5), Some(PostureLevel::Normal));
|
|
||||||
assert_eq!(PostureLevel::from_wire(0), None);
|
|
||||||
assert_eq!(PostureLevel::from_wire(6), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn posture_level_serde_names() {
|
|
||||||
// Verify the snake_case names we use in Shellstream responses
|
|
||||||
let json = serde_json::to_value(PostureLevel::Normal).unwrap();
|
|
||||||
assert_eq!(json, "normal");
|
|
||||||
|
|
||||||
let json = serde_json::to_value(PostureLevel::Lockdown).unwrap();
|
|
||||||
assert_eq!(json, "lockdown");
|
|
||||||
|
|
||||||
let json = serde_json::to_value(PostureLevel::Restricted).unwrap();
|
|
||||||
assert_eq!(json, "restricted");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reader_returns_fallback_without_cluster() {
|
|
||||||
let reader = PostureReader::new(
|
|
||||||
"test-ns".into(),
|
|
||||||
"posture-current".into(),
|
|
||||||
30,
|
|
||||||
);
|
|
||||||
// No kube cluster available — should fall back to Lockdown
|
|
||||||
let (level, source) = reader.get_posture().await;
|
|
||||||
assert_eq!(level, PostureLevel::Lockdown);
|
|
||||||
assert_eq!(source, "fallback");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reader_caches_fallback_result() {
|
|
||||||
// Verify that multiple calls don't spam warnings
|
|
||||||
let reader = PostureReader::new(
|
|
||||||
"test-ns".into(),
|
|
||||||
"posture-current".into(),
|
|
||||||
30,
|
|
||||||
);
|
|
||||||
let (l1, s1) = reader.get_posture().await;
|
|
||||||
// The fallback isn't cached (it's returned inline), but the kube
|
|
||||||
// client OnceCell retries on failure, so this will attempt again
|
|
||||||
let (l2, _s2) = reader.get_posture().await;
|
|
||||||
assert_eq!(l1, l2);
|
|
||||||
assert_eq!(l1, PostureLevel::Lockdown);
|
|
||||||
assert_eq!(s1, "fallback");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,6 @@ name = "bascule-core"
|
||||||
version = "0.1.0"
|
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 }
|
||||||
|
|
@ -23,6 +21,3 @@ 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" }
|
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
// Copyright 2026 Guildhouse Dev
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
//! Delegation authority for governed shell sessions.
|
|
||||||
//!
|
|
||||||
//! Controls whether a session can dispatch governed operations to remote
|
|
||||||
//! targets. Orthogonal to [`ShellClass`] — an Application session can
|
|
||||||
//! have delegation authority (the "Infrastructure shell" pattern for
|
|
||||||
//! Ansible/Terraform orchestrators).
|
|
||||||
|
|
||||||
use crate::shell_class::ShellClass;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Delegation authority for a session.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DelegationScope {
|
|
||||||
/// Whether delegation is permitted at all.
|
|
||||||
pub permitted: bool,
|
|
||||||
|
|
||||||
/// Target hosts this session may delegate to.
|
|
||||||
pub target_selector: TargetSelector,
|
|
||||||
|
|
||||||
/// Maximum shell class that can be delegated.
|
|
||||||
pub max_delegated_class: ShellClass,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Selector for delegation targets.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TargetSelector {
|
|
||||||
/// No targets — delegation denied regardless of `permitted` flag.
|
|
||||||
None,
|
|
||||||
/// Specific hosts by name.
|
|
||||||
Hosts(Vec<String>),
|
|
||||||
/// Kubernetes label selector (resolved at dispatch time).
|
|
||||||
LabelSelector(String),
|
|
||||||
/// All hosts in the trust domain.
|
|
||||||
TrustDomain,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DelegationScope {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
permitted: false,
|
|
||||||
target_selector: TargetSelector::None,
|
|
||||||
max_delegated_class: ShellClass::Application,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a delegation scope check.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum DelegationDecision {
|
|
||||||
/// Delegation is allowed.
|
|
||||||
Permitted,
|
|
||||||
/// Delegation is denied with a reason.
|
|
||||||
Denied { reason: String },
|
|
||||||
/// Delegation check requires async resolution (label selector).
|
|
||||||
Deferred { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DelegationScope {
|
|
||||||
/// Check whether this scope permits delegation to a specific target
|
|
||||||
/// for a specific operation class.
|
|
||||||
pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision {
|
|
||||||
if !self.permitted {
|
|
||||||
return DelegationDecision::Denied {
|
|
||||||
reason: "session does not have delegation authority".into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let target_allowed = match &self.target_selector {
|
|
||||||
TargetSelector::None => false,
|
|
||||||
TargetSelector::Hosts(hosts) => hosts.iter().any(|h| h == target_host),
|
|
||||||
TargetSelector::LabelSelector(_) => {
|
|
||||||
return DelegationDecision::Deferred {
|
|
||||||
reason: "label selector requires K8s API resolution".into(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
TargetSelector::TrustDomain => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !target_allowed {
|
|
||||||
return DelegationDecision::Denied {
|
|
||||||
reason: format!("target '{target_host}' is not in delegation scope"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.max_delegated_class.satisfies(required_class) {
|
|
||||||
return DelegationDecision::Denied {
|
|
||||||
reason: format!(
|
|
||||||
"delegation permits {} operations, but {} required",
|
|
||||||
self.max_delegated_class, required_class
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
DelegationDecision::Permitted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_denies_delegation() {
|
|
||||||
let scope = DelegationScope::default();
|
|
||||||
assert!(!scope.permitted);
|
|
||||||
let d = scope.permits("any-host", ShellClass::Application);
|
|
||||||
assert_eq!(
|
|
||||||
d,
|
|
||||||
DelegationDecision::Denied {
|
|
||||||
reason: "session does not have delegation authority".into()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn permitted_with_host_match() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into(), "worker-2".into()]),
|
|
||||||
max_delegated_class: ShellClass::System,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
scope.permits("worker-1", ShellClass::Application),
|
|
||||||
DelegationDecision::Permitted
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
scope.permits("worker-1", ShellClass::System),
|
|
||||||
DelegationDecision::Permitted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn permitted_host_not_in_scope() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into()]),
|
|
||||||
max_delegated_class: ShellClass::System,
|
|
||||||
};
|
|
||||||
let d = scope.permits("worker-99", ShellClass::Application);
|
|
||||||
assert!(matches!(d, DelegationDecision::Denied { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trust_domain_allows_any_host() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::TrustDomain,
|
|
||||||
max_delegated_class: ShellClass::System,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
scope.permits("any-host", ShellClass::System),
|
|
||||||
DelegationDecision::Permitted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn label_selector_defers() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::LabelSelector("role=worker".into()),
|
|
||||||
max_delegated_class: ShellClass::System,
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
scope.permits("worker-1", ShellClass::Application),
|
|
||||||
DelegationDecision::Deferred { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn class_ceiling_enforced() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::TrustDomain,
|
|
||||||
max_delegated_class: ShellClass::Application, // ceiling
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
scope.permits("worker-1", ShellClass::Application),
|
|
||||||
DelegationDecision::Permitted
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
scope.permits("worker-1", ShellClass::System),
|
|
||||||
DelegationDecision::Denied { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn none_target_selector_denies() {
|
|
||||||
let scope = DelegationScope {
|
|
||||||
permitted: true,
|
|
||||||
target_selector: TargetSelector::None,
|
|
||||||
max_delegated_class: ShellClass::System,
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
scope.permits("any-host", ShellClass::Application),
|
|
||||||
DelegationDecision::Denied { .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
pub mod audit;
|
pub mod 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,8 +1,5 @@
|
||||||
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 {
|
||||||
|
|
@ -12,21 +9,6 @@ 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.
|
||||||
|
|
@ -152,9 +134,6 @@ 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));
|
||||||
|
|
@ -181,9 +160,6 @@ 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));
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
// Copyright 2026 Guildhouse Dev
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
//! Shell class — session-scoped classification derived from posture.
|
|
||||||
//!
|
|
||||||
//! Determines what class of operations a governed shell session permits.
|
|
||||||
//! Derived from [`PostureLevel`] at ceremony grant time and immutable for
|
|
||||||
//! the session lifetime. Upgrade requires a new ceremony; downgrade can
|
|
||||||
//! occur on posture breach.
|
|
||||||
|
|
||||||
use governance_types::PostureLevel;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Classification of a governed shell session.
|
|
||||||
///
|
|
||||||
/// - `Application` — software operations (deploy, query, playbooks).
|
|
||||||
/// - `System` — host operations (kernel modules, firmware, storage).
|
|
||||||
///
|
|
||||||
/// This is a Bascule concept (session-scoped), distinct from the
|
|
||||||
/// substrate-wide `PostureLevel` (host-scoped, continuous).
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ShellClass {
|
|
||||||
/// Software operations only. The host's kernel and firmware
|
|
||||||
/// integrity is not verified. Default for all sessions unless
|
|
||||||
/// attestation elevates to System.
|
|
||||||
#[default]
|
|
||||||
Application,
|
|
||||||
|
|
||||||
/// Host operations. Kernel modules, firmware updates, network
|
|
||||||
/// config, storage management, security policy changes. Requires
|
|
||||||
/// the host to be in an attested posture (TPM + IMA verified).
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ShellClass {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
ShellClass::Application => write!(f, "application"),
|
|
||||||
ShellClass::System => write!(f, "system"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShellClass {
|
|
||||||
/// Returns true if this class permits system-level operations.
|
|
||||||
pub fn is_system(&self) -> bool {
|
|
||||||
matches!(self, ShellClass::System)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if `required` is satisfied by `self`.
|
|
||||||
///
|
|
||||||
/// System satisfies both System and Application requirements.
|
|
||||||
/// Application satisfies only Application requirements.
|
|
||||||
pub fn satisfies(&self, required: ShellClass) -> bool {
|
|
||||||
match (self, required) {
|
|
||||||
(ShellClass::System, _) => true,
|
|
||||||
(ShellClass::Application, ShellClass::Application) => true,
|
|
||||||
(ShellClass::Application, ShellClass::System) => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive `ShellClass` from a `PostureLevel` and an optional threshold.
|
|
||||||
///
|
|
||||||
/// Uses the **operational** PostureLevel semantic (Lockdown=1 → Normal=5).
|
|
||||||
/// Default threshold: `PostureLevel::Normal` — the host must be in normal
|
|
||||||
/// operational posture for System access. Any DEFCON escalation restricts
|
|
||||||
/// to Application shells.
|
|
||||||
///
|
|
||||||
/// The Accord's WitnessConfig can override the threshold via a
|
|
||||||
/// `PostureCondition` with kind `"system_shell_minimum"`.
|
|
||||||
///
|
|
||||||
/// # Dual PostureLevel semantic note
|
|
||||||
///
|
|
||||||
/// governance-types defines PostureLevel on the operational scale
|
|
||||||
/// (Lockdown < Critical < Restricted < Elevated < Normal). The
|
|
||||||
/// session/attestation scale (None < Local < Verified < Governed <
|
|
||||||
/// Attested) lives in the proto layer. This function uses the
|
|
||||||
/// operational scale because that's what the posture-current ConfigMap
|
|
||||||
/// provides. When the two semantics are disambiguated into separate
|
|
||||||
/// types, this function should migrate to the attestation type.
|
|
||||||
pub fn derive_shell_class(
|
|
||||||
posture_level: PostureLevel,
|
|
||||||
system_threshold: Option<PostureLevel>,
|
|
||||||
) -> ShellClass {
|
|
||||||
let threshold = system_threshold.unwrap_or(PostureLevel::Normal);
|
|
||||||
|
|
||||||
// PostureLevel derives Ord: Lockdown(1) < Normal(5).
|
|
||||||
// posture_level >= threshold means "at least as permissive as the threshold."
|
|
||||||
if posture_level >= threshold {
|
|
||||||
ShellClass::System
|
|
||||||
} else {
|
|
||||||
ShellClass::Application
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_is_application() {
|
|
||||||
assert_eq!(ShellClass::default(), ShellClass::Application);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_normal_posture_is_system() {
|
|
||||||
let class = derive_shell_class(PostureLevel::Normal, None);
|
|
||||||
assert_eq!(class, ShellClass::System);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_elevated_posture_is_application() {
|
|
||||||
let class = derive_shell_class(PostureLevel::Elevated, None);
|
|
||||||
assert_eq!(class, ShellClass::Application);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_lockdown_is_application() {
|
|
||||||
let class = derive_shell_class(PostureLevel::Lockdown, None);
|
|
||||||
assert_eq!(class, ShellClass::Application);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_with_custom_threshold() {
|
|
||||||
// Lower threshold: Elevated (4) or above gets System
|
|
||||||
let class = derive_shell_class(PostureLevel::Elevated, Some(PostureLevel::Elevated));
|
|
||||||
assert_eq!(class, ShellClass::System);
|
|
||||||
|
|
||||||
let class = derive_shell_class(PostureLevel::Restricted, Some(PostureLevel::Elevated));
|
|
||||||
assert_eq!(class, ShellClass::Application);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn satisfies_system_satisfies_both() {
|
|
||||||
assert!(ShellClass::System.satisfies(ShellClass::System));
|
|
||||||
assert!(ShellClass::System.satisfies(ShellClass::Application));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn satisfies_application_only_satisfies_application() {
|
|
||||||
assert!(ShellClass::Application.satisfies(ShellClass::Application));
|
|
||||||
assert!(!ShellClass::Application.satisfies(ShellClass::System));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn display_formatting() {
|
|
||||||
assert_eq!(format!("{}", ShellClass::Application), "application");
|
|
||||||
assert_eq!(format!("{}", ShellClass::System), "system");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn serde_round_trip() {
|
|
||||||
let json = serde_json::to_string(&ShellClass::System).unwrap();
|
|
||||||
assert_eq!(json, "\"system\"");
|
|
||||||
let parsed: ShellClass = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(parsed, ShellClass::System);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_system() {
|
|
||||||
assert!(ShellClass::System.is_system());
|
|
||||||
assert!(!ShellClass::Application.is_system());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,6 @@ name = "bascule-gateway"
|
||||||
version = "0.1.0"
|
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"
|
||||||
|
|
@ -19,10 +17,6 @@ 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 }
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# Bascule gateway — F.4 production image.
|
|
||||||
#
|
|
||||||
# BUILD CONTEXT: substrate-project repo root (two levels above bascule-workspace/).
|
|
||||||
# Rationale: bascule-gateway's Cargo.toml has cross-workspace path
|
|
||||||
# dependencies reaching:
|
|
||||||
# - ../../guildhouse/services/accord-core
|
|
||||||
# - ../../guildhouse/services/accord-opa
|
|
||||||
# - ../../guildhouse/services/qm-core
|
|
||||||
# - ../../guildhouse/services/guildhouse-proto
|
|
||||||
# - ../../substrate/crates/governance-types
|
|
||||||
# - (transitively) ../../guildhouse/sdk/guildhouse-mq, guildhouse-tower
|
|
||||||
# - bascule-workspace siblings (bascule-core, bascule-proto)
|
|
||||||
#
|
|
||||||
# Invocation:
|
|
||||||
# docker build -t git.guildhouse.dev/tking/bascule-gateway:v0.1.0 \
|
|
||||||
# -f bascule-workspace/bascule-gateway/Dockerfile \
|
|
||||||
# <substrate-project root>
|
|
||||||
|
|
||||||
FROM rust:bookworm AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Cross-workspace path deps (change rarely). substrate is copied as a
|
|
||||||
# whole because its crates inherit `edition.workspace = true` from the
|
|
||||||
# substrate workspace root Cargo.toml — copying just substrate/crates
|
|
||||||
# orphans the inheritance chain. Workspace members that live outside
|
|
||||||
# substrate/crates/ (metakernel/, shell/loader, shellstream/) need to be
|
|
||||||
# present for `cargo build` to load the workspace metadata, even though
|
|
||||||
# bascule's actual dep tree only reaches into substrate/crates/.
|
|
||||||
COPY substrate ./substrate
|
|
||||||
COPY guildhouse/sdk ./guildhouse/sdk
|
|
||||||
COPY guildhouse/services ./guildhouse/services
|
|
||||||
|
|
||||||
# bascule-workspace itself (includes bascule-core, bascule-proto,
|
|
||||||
# and all sibling crates the workspace Cargo.toml references).
|
|
||||||
COPY bascule-workspace ./bascule-workspace
|
|
||||||
|
|
||||||
WORKDIR /app/bascule-workspace
|
|
||||||
RUN cargo build --release --bin bascule-gateway
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS runtime
|
|
||||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=builder /app/bascule-workspace/target/release/bascule-gateway /usr/local/bin/
|
|
||||||
EXPOSE 50052
|
|
||||||
CMD ["bascule-gateway"]
|
|
||||||
|
|
@ -8,100 +8,51 @@ use chrono::Utc;
|
||||||
use sha2::{Digest, Sha256};
|
use 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 to
|
/// Buffers audit events and periodically flushes merkle leaf hashes.
|
||||||
/// 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(
|
pub fn new(db_pool: PgPool, batch_size: usize) -> Self {
|
||||||
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)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lazily establish and cache the QM NotaryClient connection.
|
/// Submit an audit event: insert into PG and queue leaf for anchoring.
|
||||||
/// Returns None if QM is unreachable or if cluster_id is unset
|
|
||||||
/// (which disables QM submission — bascule stores events locally only).
|
|
||||||
async fn get_or_connect(&self) -> Option<QuartermasterNotaryClient<Channel>> {
|
|
||||||
if self.cluster_id.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut guard = self.notary_client.lock().await;
|
|
||||||
if let Some(client) = guard.as_ref() {
|
|
||||||
return Some(client.clone());
|
|
||||||
}
|
|
||||||
// Non-blocking connect — Endpoint::connect() establishes a single-
|
|
||||||
// attempt TCP connection; on failure we leave the cache empty and
|
|
||||||
// the next flush will retry. This mirrors the lazy-retry pattern
|
|
||||||
// from guildhouse-spire-plugins pkg/governance (F.1).
|
|
||||||
match Endpoint::from_shared(self.qm_endpoint.clone())
|
|
||||||
.ok()?
|
|
||||||
.connect_timeout(Duration::from_secs(5))
|
|
||||||
.connect()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(channel) => {
|
|
||||||
let client = QuartermasterNotaryClient::new(channel);
|
|
||||||
*guard = Some(client.clone());
|
|
||||||
tracing::info!(endpoint = %self.qm_endpoint, "Connected to Quartermaster notary");
|
|
||||||
Some(client)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
endpoint = %self.qm_endpoint,
|
|
||||||
error = %e,
|
|
||||||
"Quartermaster notary unreachable; audit events stored locally, will retry at next flush"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record an audit event into the local ledger. If `notarize` is true,
|
|
||||||
/// queue the leaf hash for the next batched CreateAnchor call.
|
|
||||||
///
|
|
||||||
/// Kept named `submit` for source-compat with the filter chain caller
|
|
||||||
/// (`filter/audit.rs::log_and_submit`).
|
|
||||||
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
||||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data_bytes(event).as_bytes());
|
// Compute merkle leaf
|
||||||
|
let canonical = match serde_json_canonicalizer::to_string(&event) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to canonicalize audit event: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let content_hash = Sha256::digest(canonical.as_bytes());
|
||||||
|
let leaf_data = format!(
|
||||||
|
"bascule:{}:{}:{}",
|
||||||
|
event.session_id,
|
||||||
|
event.event_id,
|
||||||
|
hex::encode(content_hash)
|
||||||
|
);
|
||||||
|
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||||
|
|
||||||
|
// Insert into PG
|
||||||
let command_json = serde_json::to_value(&event.command).unwrap_or_default();
|
let 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();
|
||||||
|
|
@ -137,6 +88,7 @@ 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 {
|
||||||
|
|
@ -153,13 +105,6 @@ 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;
|
||||||
|
|
@ -172,70 +117,19 @@ impl AuditPipeline {
|
||||||
|
|
||||||
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
||||||
|
|
||||||
let mut client = match self.get_or_connect().await {
|
// Phase 2: mark events as anchored in PG.
|
||||||
Some(c) => c,
|
// Actual QM gRPC submission is a future enhancement -- for now we
|
||||||
None => {
|
// compute and store the leaf hashes, which is the cryptographic guarantee.
|
||||||
// QM unreachable or disabled. Re-queue leaves for next flush.
|
// The anchor_id will be set when we integrate QM's FlushAnchor RPC.
|
||||||
let mut pending = self.pending.lock().await;
|
for leaf in &leaves {
|
||||||
pending.extend(leaves);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let req = CreateAnchorRequest {
|
|
||||||
cluster_id: self.cluster_id.clone(),
|
|
||||||
leaves: leaves.iter().map(|l| l.leaf_hash.to_vec()).collect(),
|
|
||||||
etcd_revision: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
match client.create_anchor(req).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
let resp = resp.into_inner();
|
|
||||||
let anchor_id = match Uuid::parse_str(&resp.anchor_id) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, "QM returned unparseable anchor_id; leaves remain un-anchored");
|
|
||||||
// Re-queue for retry.
|
|
||||||
let mut pending = self.pending.lock().await;
|
|
||||||
pending.extend(leaves);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
anchor_id = %anchor_id,
|
|
||||||
leaf_count = resp.leaf_count,
|
|
||||||
"Anchor created in Quartermaster"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update each event row with the returned anchor_id and its
|
|
||||||
// position in the submitted batch.
|
|
||||||
for (idx, leaf) in leaves.iter().enumerate() {
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"UPDATE bascule.audit_events
|
"UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1",
|
||||||
SET anchor_id = $1, leaf_index = $2
|
|
||||||
WHERE event_id = $3",
|
|
||||||
)
|
)
|
||||||
.bind(anchor_id)
|
|
||||||
.bind(idx as i32)
|
|
||||||
.bind(leaf.event_id)
|
.bind(leaf.event_id)
|
||||||
.execute(&self.db_pool)
|
.execute(&self.db_pool)
|
||||||
.await;
|
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the background flush loop.
|
/// Start the background flush loop.
|
||||||
pub fn start_flush_loop(
|
pub fn start_flush_loop(
|
||||||
|
|
@ -252,45 +146,6 @@ impl AuditPipeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classification + ledger-fidelity → notarize decision.
|
|
||||||
///
|
|
||||||
/// OPA decisions can override default per-classification behavior:
|
|
||||||
/// - `always_notarize` → every event anchored, regardless of class
|
|
||||||
/// - `log_only` → no anchor, local ledger row only
|
|
||||||
/// - default → mutative + session-lifecycle events anchored; reads
|
|
||||||
/// stay local
|
|
||||||
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
|
|
||||||
match ledger_fidelity {
|
|
||||||
"always_notarize" => true,
|
|
||||||
"log_only" => false,
|
|
||||||
_ => matches!(
|
|
||||||
classification,
|
|
||||||
ChangeClassification::Mutative | ChangeClassification::Session
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical leaf-data serialization used for merkle-leaf hashing.
|
|
||||||
fn leaf_data_bytes(event: &AuditEvent) -> String {
|
|
||||||
// Include the fields most salient for tamper-detection: event_id,
|
|
||||||
// session_id, operator, command, classification, execution result,
|
|
||||||
// timestamp. Target resources + profile hash would be next fields to
|
|
||||||
// include if/when the verification API wants to constrain on them.
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(event.event_id.as_bytes());
|
|
||||||
hasher.update(event.session_id.as_bytes());
|
|
||||||
hasher.update(event.operator_identity.display_id().as_bytes());
|
|
||||||
if let Ok(cmd) = serde_json::to_string(&event.command) {
|
|
||||||
hasher.update(cmd.as_bytes());
|
|
||||||
}
|
|
||||||
hasher.update(format!("{:?}", event.classification).as_bytes());
|
|
||||||
if let Ok(exec) = serde_json::to_string(&event.execution_result) {
|
|
||||||
hasher.update(exec.as_bytes());
|
|
||||||
}
|
|
||||||
hasher.update(event.timestamp.to_rfc3339().as_bytes());
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an AuditEvent from the filter chain's request context.
|
/// 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,
|
||||||
|
|
@ -345,30 +200,100 @@ 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(),
|
||||||
_ => "".to_string(),
|
_ => String::new(), // core group
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
||||||
serde_json::json!(s
|
// Convert prost Struct fields to a JSON object manually
|
||||||
.fields
|
let mut map = serde_json::Map::new();
|
||||||
.iter()
|
for (key, value) in &s.fields {
|
||||||
.map(|(k, v)| (k.clone(), prost_value_to_json(v)))
|
map.insert(key.clone(), prost_value_to_json(value));
|
||||||
.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(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||||
Some(Kind::NumberValue(n)) => {
|
Some(prost_types::value::Kind::NumberValue(n)) => {
|
||||||
serde_json::Number::from_f64(*n).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
|
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
|
||||||
}
|
}
|
||||||
Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||||
Some(Kind::ListValue(l)) => {
|
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||||
|
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||||
|
Some(prost_types::value::Kind::ListValue(l)) => {
|
||||||
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
||||||
}
|
}
|
||||||
Some(Kind::StructValue(s)) => prost_struct_to_json(s),
|
None => serde_json::Value::Null,
|
||||||
_ => serde_json::Value::Null,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if this event should be notarized based on ledger fidelity.
|
||||||
|
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
|
||||||
|
match ledger_fidelity {
|
||||||
|
"always_notarize" => true,
|
||||||
|
"log_only" => false,
|
||||||
|
_ => {
|
||||||
|
// Default: notarize mutative operations, log reads
|
||||||
|
matches!(
|
||||||
|
classification,
|
||||||
|
ChangeClassification::Mutative | ChangeClassification::Session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audit_leaf_format() {
|
||||||
|
let session_id = Uuid::new_v4();
|
||||||
|
let event_id = Uuid::new_v4();
|
||||||
|
let content = "test content";
|
||||||
|
let content_hash = Sha256::digest(content.as_bytes());
|
||||||
|
let leaf_data = format!(
|
||||||
|
"bascule:{}:{}:{}",
|
||||||
|
session_id,
|
||||||
|
event_id,
|
||||||
|
hex::encode(content_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(leaf_data.starts_with("bascule:"));
|
||||||
|
assert!(leaf_data.contains(&session_id.to_string()));
|
||||||
|
assert!(leaf_data.contains(&event_id.to_string()));
|
||||||
|
|
||||||
|
// Verify hash_leaf produces a 32-byte hash
|
||||||
|
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||||
|
assert_eq!(leaf_hash.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_notarize() {
|
||||||
|
assert!(should_notarize(
|
||||||
|
ChangeClassification::Mutative,
|
||||||
|
"always_notarize"
|
||||||
|
));
|
||||||
|
assert!(should_notarize(
|
||||||
|
ChangeClassification::Read,
|
||||||
|
"always_notarize"
|
||||||
|
));
|
||||||
|
assert!(!should_notarize(ChangeClassification::Read, "log_only"));
|
||||||
|
assert!(!should_notarize(
|
||||||
|
ChangeClassification::Mutative,
|
||||||
|
"log_only"
|
||||||
|
));
|
||||||
|
// Default behavior
|
||||||
|
assert!(should_notarize(ChangeClassification::Mutative, "default"));
|
||||||
|
assert!(!should_notarize(ChangeClassification::Read, "default"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_api_group() {
|
||||||
|
assert_eq!(resolve_api_group("deployments"), "apps");
|
||||||
|
assert_eq!(resolve_api_group("pods"), "");
|
||||||
|
assert_eq!(resolve_api_group("jobs"), "batch");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
// Copyright 2026 Guildhouse Dev
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
//! Breach evaluator — compares posture changes against active sessions
|
|
||||||
//! and determines the appropriate response.
|
|
||||||
|
|
||||||
use bascule_core::{derive_shell_class, ShellClass};
|
|
||||||
use governance_types::{BreachResponse, PostureLevel};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Action to take on an active session after a posture change.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum BreachAction {
|
|
||||||
/// Posture still satisfies session requirements.
|
|
||||||
NoAction,
|
|
||||||
|
|
||||||
/// Downgrade shell class (System → Application).
|
|
||||||
Downgrade {
|
|
||||||
session_id: Uuid,
|
|
||||||
from: ShellClass,
|
|
||||||
to: ShellClass,
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Terminate the session immediately.
|
|
||||||
Terminate {
|
|
||||||
session_id: Uuid,
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluate a posture change for a specific session.
|
|
||||||
///
|
|
||||||
/// Compares new posture against the session's establishment posture
|
|
||||||
/// and the Accord's `BreachResponse` to determine the action.
|
|
||||||
pub fn evaluate_breach(
|
|
||||||
session_id: Uuid,
|
|
||||||
established_posture: u8,
|
|
||||||
established_shell_class: ShellClass,
|
|
||||||
new_posture_level: u8,
|
|
||||||
breach_response: &BreachResponse,
|
|
||||||
system_threshold: Option<PostureLevel>,
|
|
||||||
) -> BreachAction {
|
|
||||||
let new_level = match PostureLevel::from_wire(new_posture_level) {
|
|
||||||
Some(l) => l,
|
|
||||||
None => return BreachAction::NoAction, // Invalid level — don't act on garbage
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_shell_class = derive_shell_class(new_level, system_threshold);
|
|
||||||
|
|
||||||
// If the new shell class still satisfies the established one, no breach
|
|
||||||
if new_shell_class.satisfies(established_shell_class) {
|
|
||||||
return BreachAction::NoAction;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Posture has degraded — apply BreachResponse policy
|
|
||||||
match breach_response {
|
|
||||||
BreachResponse::LogOnly => {
|
|
||||||
tracing::info!(
|
|
||||||
%session_id,
|
|
||||||
old = established_posture,
|
|
||||||
new = new_posture_level,
|
|
||||||
"Posture breach detected (LogOnly — no enforcement)"
|
|
||||||
);
|
|
||||||
BreachAction::NoAction
|
|
||||||
}
|
|
||||||
BreachResponse::AlertDelegates => {
|
|
||||||
// Delegate notification happens elsewhere (WitnessForwarder).
|
|
||||||
// For session purposes, same as LogOnly.
|
|
||||||
tracing::info!(
|
|
||||||
%session_id,
|
|
||||||
old = established_posture,
|
|
||||||
new = new_posture_level,
|
|
||||||
"Posture breach detected (AlertDelegates)"
|
|
||||||
);
|
|
||||||
BreachAction::NoAction
|
|
||||||
}
|
|
||||||
BreachResponse::ReducePosture { .. } => {
|
|
||||||
if established_shell_class == ShellClass::System {
|
|
||||||
BreachAction::Downgrade {
|
|
||||||
session_id,
|
|
||||||
from: ShellClass::System,
|
|
||||||
to: ShellClass::Application,
|
|
||||||
reason: format!(
|
|
||||||
"posture degraded from {} to {} (below System threshold)",
|
|
||||||
established_posture, new_posture_level
|
|
||||||
),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
BreachAction::NoAction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BreachResponse::SuspendTrust => BreachAction::Terminate {
|
|
||||||
session_id,
|
|
||||||
reason: format!(
|
|
||||||
"trust suspended: posture degraded to {}",
|
|
||||||
new_posture_level
|
|
||||||
),
|
|
||||||
},
|
|
||||||
BreachResponse::RevokeAccord => BreachAction::Terminate {
|
|
||||||
session_id,
|
|
||||||
reason: "accord revoked due to posture breach".into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn uuid() -> Uuid {
|
|
||||||
Uuid::nil()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_breach_posture_unchanged() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5, // Normal
|
|
||||||
ShellClass::System,
|
|
||||||
5, // Still Normal
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Lockdown,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_breach_posture_improved() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
4, // Elevated
|
|
||||||
ShellClass::Application,
|
|
||||||
5, // Normal (improved)
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Lockdown,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn breach_reduce_posture_downgrades_system() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
3, // Restricted — below Normal threshold
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Restricted,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::Downgrade { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn breach_suspend_trust_terminates() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
3,
|
|
||||||
&BreachResponse::SuspendTrust,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::Terminate { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn breach_revoke_accord_terminates() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
3,
|
|
||||||
&BreachResponse::RevokeAccord,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::Terminate { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn already_application_reduce_posture_no_action() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
4,
|
|
||||||
ShellClass::Application,
|
|
||||||
2, // Critical — but already Application
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Lockdown,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn breach_with_custom_threshold() {
|
|
||||||
// Threshold at Elevated(4) — Normal(5) and Elevated(4) both qualify
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
4, // Elevated — still above threshold
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Lockdown,
|
|
||||||
},
|
|
||||||
Some(PostureLevel::Elevated),
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
|
|
||||||
// Drop below custom threshold
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
3, // Restricted — below Elevated threshold
|
|
||||||
&BreachResponse::ReducePosture {
|
|
||||||
target_level: PostureLevel::Lockdown,
|
|
||||||
},
|
|
||||||
Some(PostureLevel::Elevated),
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::Downgrade { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn log_only_no_enforcement() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
3,
|
|
||||||
&BreachResponse::LogOnly,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_posture_level_no_action() {
|
|
||||||
let action = evaluate_breach(
|
|
||||||
uuid(),
|
|
||||||
5,
|
|
||||||
ShellClass::System,
|
|
||||||
0, // Invalid wire value
|
|
||||||
&BreachResponse::SuspendTrust,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
assert!(matches!(action, BreachAction::NoAction));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -43,15 +43,6 @@ pub struct BasculeConfig {
|
||||||
#[serde(default = "default_qm_endpoint")]
|
#[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,
|
||||||
|
|
@ -121,7 +112,6 @@ 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,6 +1,5 @@
|
||||||
mod audit_pipeline;
|
mod audit_pipeline;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod breach;
|
|
||||||
mod ceremony;
|
mod ceremony;
|
||||||
mod config;
|
mod config;
|
||||||
mod executor;
|
mod executor;
|
||||||
|
|
@ -69,8 +68,8 @@ spec:
|
||||||
fidelity: always_notarize
|
fidelity: always_notarize
|
||||||
notarize: []
|
notarize: []
|
||||||
logOnly: []
|
logOnly: []
|
||||||
# sampled omitted — Option<SampledConfig> default None (struct shape:
|
sampled: []
|
||||||
# {events: [...], sample_rate: N}), `sampled: []` would mis-parse.
|
sampleRate: 1
|
||||||
reconciliation:
|
reconciliation:
|
||||||
defaultWindow: "24h"
|
defaultWindow: "24h"
|
||||||
onExpiry: alert
|
onExpiry: alert
|
||||||
|
|
@ -132,68 +131,16 @@ 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(),
|
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -221,23 +168,6 @@ 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,23 +98,7 @@ impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for Bascu
|
||||||
};
|
};
|
||||||
|
|
||||||
match response {
|
match response {
|
||||||
CeremonyResponse::Granted(mut grant) => {
|
CeremonyResponse::Granted(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)
|
||||||
|
|
@ -419,11 +403,6 @@ fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> Sessi
|
||||||
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
|
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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -518,43 +497,3 @@ 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,77 +195,6 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a breach action to an active session.
|
|
||||||
pub async fn apply_breach_action(&self, action: crate::breach::BreachAction) {
|
|
||||||
use crate::breach::BreachAction;
|
|
||||||
match action {
|
|
||||||
BreachAction::NoAction => {}
|
|
||||||
BreachAction::Downgrade {
|
|
||||||
session_id,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
if let Some(mut session) = self.sessions.get_mut(&session_id) {
|
|
||||||
session.scope.shell_class = to;
|
|
||||||
tracing::warn!(
|
|
||||||
session_id = %session_id,
|
|
||||||
from = %from,
|
|
||||||
to = %to,
|
|
||||||
reason = %reason,
|
|
||||||
"Session shell class downgraded due to posture breach"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BreachAction::Terminate {
|
|
||||||
session_id,
|
|
||||||
reason,
|
|
||||||
} => {
|
|
||||||
tracing::error!(
|
|
||||||
session_id = %session_id,
|
|
||||||
reason = %reason,
|
|
||||||
"Session terminated due to posture breach"
|
|
||||||
);
|
|
||||||
self.end_session(&session_id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a posture change notification.
|
|
||||||
///
|
|
||||||
/// Iterates all active sessions and evaluates breach for each.
|
|
||||||
/// Called by the posture polling loop when the posture-current
|
|
||||||
/// ConfigMap reports a new level.
|
|
||||||
pub async fn on_posture_change(
|
|
||||||
&self,
|
|
||||||
new_level: u8,
|
|
||||||
breach_response: &governance_types::BreachResponse,
|
|
||||||
) {
|
|
||||||
use crate::breach::evaluate_breach;
|
|
||||||
use bascule_core::session::SessionState;
|
|
||||||
|
|
||||||
let actions: Vec<crate::breach::BreachAction> = self
|
|
||||||
.sessions
|
|
||||||
.iter()
|
|
||||||
.filter(|entry| entry.value().state == SessionState::Active)
|
|
||||||
.map(|entry| {
|
|
||||||
let session = entry.value();
|
|
||||||
evaluate_breach(
|
|
||||||
*entry.key(),
|
|
||||||
session.scope.posture_level_at_establishment.unwrap_or(0),
|
|
||||||
session.scope.shell_class,
|
|
||||||
new_level,
|
|
||||||
breach_response,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for action in actions {
|
|
||||||
self.apply_breach_action(action).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a default read-only scope for the given namespaces.
|
/// 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::{
|
||||||
|
|
@ -292,9 +221,6 @@ 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ description = "Governed state machine for multi-party approval workflows"
|
||||||
# When ceremony-engine is published to crates.io,
|
# When ceremony-engine is published to crates.io,
|
||||||
# this becomes a version dependency.
|
# this becomes a version dependency.
|
||||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||||
governance-types = { path = "../../substrate/crates/governance-types" }
|
|
||||||
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
||||||
|
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
//! resolved ceremonies can be merkle-anchored in the notary chain,
|
//! resolved ceremonies can be merkle-anchored in the notary chain,
|
||||||
//! and [`MutationVerb`] on [`CeremonyVerb`] for SAT scope encoding.
|
//! and [`MutationVerb`] on [`CeremonyVerb`] for SAT scope encoding.
|
||||||
|
|
||||||
use governance_types::GovernanceEnvelope;
|
|
||||||
use registry_protocol::{RegistryArtifact, MutationVerb};
|
use registry_protocol::{RegistryArtifact, MutationVerb};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -56,63 +55,27 @@ impl RegistryArtifact for CeremonyResolution {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_bytes(&self) -> Vec<u8> {
|
fn canonical_bytes(&self) -> Vec<u8> {
|
||||||
// For git-originated ceremonies, construct a GovernanceEnvelope
|
// For git-originated ceremonies (PipelineMerge, SchematicPublish,
|
||||||
// and use its canonical_hash as the merkle leaf. This binds
|
// GitOpsSync), bind the git ref into the canonical form so the
|
||||||
// git ref + governance metadata into one auditable hash.
|
// Quartermaster merkle leaf transitively includes git's hash.
|
||||||
// Use resolved_at as a fixed timestamp so canonical_bytes is
|
// Format: proof_hash bytes || git ref bytes.
|
||||||
// deterministic for the same resolution (not time-dependent).
|
let mut bytes = self.proof_hash.as_bytes().to_vec();
|
||||||
let resolved_ns = self.resolved_at.timestamp_nanos_opt().unwrap_or(0) as u64;
|
|
||||||
|
|
||||||
match &self.subject {
|
match &self.subject {
|
||||||
CeremonySubject::PipelineMerge { commit_hash, .. } => {
|
CeremonySubject::PipelineMerge { commit_hash, .. } => {
|
||||||
let mut envelope = GovernanceEnvelope::for_commit(
|
bytes.extend_from_slice(commit_hash.as_bytes());
|
||||||
parse_sha1_hex(commit_hash),
|
|
||||||
None,
|
|
||||||
"",
|
|
||||||
[0; 32],
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
envelope.timestamp_ns = resolved_ns;
|
|
||||||
envelope.canonical_hash().to_vec()
|
|
||||||
}
|
}
|
||||||
CeremonySubject::SchematicPublish { tree_hash, .. } => {
|
CeremonySubject::SchematicPublish { tree_hash, .. } => {
|
||||||
let mut envelope = GovernanceEnvelope::for_blob(
|
bytes.extend_from_slice(tree_hash.as_bytes());
|
||||||
parse_sha1_hex(tree_hash),
|
|
||||||
"",
|
|
||||||
[0; 32],
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
envelope.timestamp_ns = resolved_ns;
|
|
||||||
envelope.canonical_hash().to_vec()
|
|
||||||
}
|
}
|
||||||
CeremonySubject::GitOpsSync { target_revision, .. } => {
|
CeremonySubject::GitOpsSync { target_revision, .. } => {
|
||||||
let mut envelope = GovernanceEnvelope::for_commit(
|
bytes.extend_from_slice(target_revision.as_bytes());
|
||||||
parse_sha1_hex(target_revision),
|
|
||||||
None,
|
|
||||||
"",
|
|
||||||
[0; 32],
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
envelope.timestamp_ns = resolved_ns;
|
|
||||||
envelope.canonical_hash().to_vec()
|
|
||||||
}
|
}
|
||||||
// Non-git ceremonies: proof_hash alone is the canonical form.
|
// Non-git ceremonies: proof_hash alone is the canonical form.
|
||||||
CeremonySubject::MutationIntent { .. }
|
CeremonySubject::MutationIntent { .. }
|
||||||
| CeremonySubject::Custom { .. } => {
|
| CeremonySubject::Custom { .. } => {}
|
||||||
self.proof_hash.as_bytes().to_vec()
|
|
||||||
}
|
}
|
||||||
|
bytes
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a hex SHA-1 string into [u8; 20]. Returns zeros on failure.
|
|
||||||
fn parse_sha1_hex(hex_str: &str) -> [u8; 20] {
|
|
||||||
let mut sha = [0u8; 20];
|
|
||||||
if let Ok(bytes) = hex::decode(hex_str.trim()) {
|
|
||||||
let len = bytes.len().min(20);
|
|
||||||
sha[..len].copy_from_slice(&bytes[..len]);
|
|
||||||
}
|
|
||||||
sha
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -181,12 +144,14 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_bytes_pipeline_merge_is_envelope_hash() {
|
fn canonical_bytes_pipeline_merge_includes_commit_hash() {
|
||||||
let res = sample_resolution(); // PipelineMerge with commit_hash "abc"
|
let res = sample_resolution(); // PipelineMerge with commit_hash "abc"
|
||||||
let bytes = res.canonical_bytes();
|
let bytes = res.canonical_bytes();
|
||||||
// PipelineMerge: canonical_bytes is the 32-byte canonical_hash
|
let proof_bytes = res.proof_hash.as_bytes();
|
||||||
// of a GovernanceEnvelope (not the old proof_hash || commit_hash).
|
// Must contain proof_hash + commit_hash
|
||||||
assert_eq!(bytes.len(), 32);
|
assert!(bytes.len() > proof_bytes.len());
|
||||||
|
assert_eq!(&bytes[..proof_bytes.len()], proof_bytes);
|
||||||
|
assert_eq!(&bytes[proof_bytes.len()..], b"abc");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue