Compare commits

..

10 commits

Author SHA256 Message Date
6c30ae3181 bascule-gateway: BASCULE_DEMO_AUDIT=1 startup synthesizer
Adds an env-gated startup hook that submits one synthetic AuditEvent
(notarize=true) to the AuditPipeline. The flush_loop then submits the
leaf to QM via CreateAnchor on its next cycle, demonstrating the
bascule→QM integration end-to-end without requiring real OIDC sessions
(genesis hasn't lifted the realm yet).

Default off — only triggers if BASCULE_DEMO_AUDIT=1 in pod env. Leaves
no permanent test surface in normal deployments. Slated for removal
once OIDC sessions can drive the path through the auth filter chain;
keeping it default-off makes that removal a no-op for production.

Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-25 06:05:22 -04:00
eab96ef3d4 bascule-gateway: fix env-prefix separator + embedded accord parse
Two bugs surfaced when bascule-gateway pods first reached Running and
attempted config load (F.4 deployment):

1. Env-var override didn't take effect for any BASCULE_* variable.
   config::Environment::with_prefix("BASCULE") without an explicit
   prefix_separator strips the literal "BASCULE" with no separator,
   so BASCULE_ACCORD_PATH became "_ACCORD_PATH" (leading underscore)
   which doesn't match the field "accord_path". Result: every env
   override silently fell back to the default in config.rs, and the
   pod read /accord/accord.yaml instead of /etc/bascule/accord.yaml
   from the configured volume. Adds .prefix_separator("_") to match
   QM's pattern in services/quartermaster/src/config.rs:150.

2. Embedded fallback accord YAML had `sampled: []` and a stray
   `sampleRate: 1`, but the schema has
   `sampled: Option<SampledConfig {events, sample_rate}>` — empty
   list mis-parses as struct. Result: when accord file lookup failed,
   the .expect("empty accord must parse") panicked, crashing the
   bascule-gateway container. Now omitted (Option default None).

Both fixes verified against accord-core's schema in
services/accord-core/src/schema.rs.

Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-25 05:00:10 -04:00
2cfc0b4d5e packaging(bascule-gateway): production Dockerfile
Multi-stage rust:bookworm → debian:bookworm-slim build, modeled on
guildhouse/services/Dockerfile (F.2). Build context is the
substrate-project repo root because bascule-gateway's Cargo.toml has
cross-workspace path deps reaching:

  - ../../substrate/crates/governance-types (and substrate-rt
    transitively, which inherits edition from substrate's workspace
    root — substrate must be COPYed as a whole for the inheritance
    chain to load)
  - ../../guildhouse/services/{accord-core, accord-opa, qm-core,
    guildhouse-proto}
  - ../../guildhouse/sdk/{guildhouse-mq, guildhouse-tower} via
    transitive deps

Image output: git.guildhouse.dev/tking/bascule-gateway:v0.1.0.

Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-25 04:57:05 -04:00
3526b6975f bascule-gateway: implement CreateAnchor submission to Quartermaster
Wires the AuditPipeline's flush() path to QM's QuartermasterNotary
gRPC service. Previously flush() only updated local notarized=true
flags; now it batches pending leaf hashes into a CreateAnchorRequest
and persists the returned anchor_id + leaf_index back on each event
row.

Lazy-retry semantics match guildhouse-spire-plugins pkg/governance
(F.1): the gRPC channel is established on first successful flush and
cached in Arc<Mutex<Option<QuartermasterNotaryClient<Channel>>>>. If
QM is unreachable, bascule logs a warning, re-queues the leaves into
the pending buffer, and retries on the next flush interval. Local
audit rows are still written with notarized=true; only anchor_id
stays NULL until an anchor successfully lands. This is the same
pattern that unblocks the bascule-deploys-before-QM ordering problem
without crashing bascule.

Schema: bascule.audit_events already had anchor_id uuid + leaf_index
integer columns (migrations.rs, pre-existing). This commit populates
them for the first time.

Config:
- New `cluster_id` field on BasculeConfig, sourced from
  BASCULE_CLUSTER_ID env. Empty string disables QM submission (local
  storage only). In F.4, bascule gets the UUID from QM's clusters
  table (generated at QM genesis).
- Existing `qm_endpoint` field now actually used (was scaffolded in
  pre-F.4 code but never read).

Backwards-compat:
- submit(&self, event: &AuditEvent, notarize: bool) signature preserved.
- should_notarize(classification, fidelity) public fn preserved.
- Internal leaf_data hashing simplified to an event-field digest
  (event_id + session_id + operator + command + classification +
  exec_result + timestamp); bypasses serde_json_canonicalizer
  dependency that the prior version required. Verify path still
  works against QM's merkle tree because QM hashes whatever bytes
  bascule submits — QM doesn't re-compute; it trusts the leaf
  payload bascule submitted is the leaf.

Signed-off-by: Tyler J King <tking@guildhouse.dev>
2026-04-24 15:40:30 -04:00
Tyler J King
9c492d739a docs: add ARCHITECTURE.md, CHANGELOG, fix Cargo metadata
ARCHITECTURE.md explains the governed shell stack, Keylime integration
model, ShellClass derivation, and implementation status for reviewer
orientation.

CHANGELOG documents v0.1.0-rc.1 deliverables.

Cargo.toml metadata (license, repository) added to bascule-core,
bascule-agent, bascule-gateway.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 15:37:27 -04:00
Tyler J King
aa447f151e feat(bascule-core): add DelegationScope for Infrastructure shell pattern
DelegationScope is orthogonal to ShellClass — an Application session
can have delegation authority to orchestrate System operations on
remote targets (the Infrastructure shell pattern for Ansible/Terraform).

TargetSelector supports: None, Hosts (explicit list), LabelSelector
(deferred to K8s API), TrustDomain (all hosts). Default: denied
(fail-closed).

DelegationDecision: Permitted, Denied (with reason), Deferred (for
async label resolution).

Added delegation field to SessionScope with #[serde(default)] for
backward-compatible deserialization.

7 unit tests for delegation scope checking.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 15:16:24 -04:00
Tyler J King
ece4e2349f feat(gateway): session downgrade on posture breach
Add breach evaluator that compares posture changes against active
sessions and applies BreachResponse policy:
- LogOnly/AlertDelegates: log, no session enforcement
- ReducePosture: downgrade System -> Application, session continues
- SuspendTrust: terminate session immediately
- RevokeAccord: terminate session, Accord dead

Posture change detection via 30s polling loop on posture-current
ConfigMap (matching existing reaper interval pattern).
No mid-session upgrade — downgrade only, upgrade requires new ceremony.

9 unit tests for breach evaluation covering all BreachResponse variants.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 15:16:11 -04:00
Tyler J King
1a54cc3877 feat(bascule-gateway): derive ShellClass at ceremony grant from posture
Read the cluster's operational posture level from the posture-current
ConfigMap at ceremony grant time. Derive ShellClass via
derive_shell_class() and stamp into the granted SessionScope.

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

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 10:37:30 -04:00
Tyler J King
e28be3335d feat(bascule-core): add ShellClass enum with posture-based derivation
Introduce ShellClass (Application | System) as a session-scoped
classification derived from PostureLevel at ceremony grant time.

- ShellClass::Application: default, software operations only
- ShellClass::System: host operations, requires Normal (5) posture
- derive_shell_class(): pure function, configurable threshold
- satisfies(): hierarchical check (System satisfies Application)
- No mid-session upgrade by design (immutable in SessionScope)

Added shell_class and posture_level_at_establishment to SessionScope
with #[serde(default)] for backward-compatible deserialization.

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 10:36:45 -04:00
Tyler J King
47a5484614 feat(bascule-agent): replace soft-mode attestation with ConfigMap posture reader
Replace hardcoded posture return in AttestationHandler (Shellstream
namespace 0x0005) with PostureReader that reads the posture-current
ConfigMap written by the substrate-operator's posture evaluator.

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

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

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

Signed-off-by: Tyler King <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
2026-04-15 10:17:00 -04:00
21 changed files with 1672 additions and 136 deletions

92
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,92 @@
# Bascule — Governed Shell Access Control
Bascule is an SSH-over-HTTPS proxy with identity-aware sessions and
ceremony-gated access control. It terminates operator identity (SSH certs,
OIDC tokens, Entra), evaluates Accord policy, classifies sessions by the
host's hardware attestation posture, and emits Chronicle audit events for
every governed operation.
## Component Map
```
Operator
bascule-gateway (cluster-side gRPC)
├─ OIDC auth → OperatorIdentity
├─ Ceremony engine (self-grant / single-approval / break-glass)
├─ Posture-current ConfigMap → ShellClass derivation
├─ OPA policy evaluation (via accord-opa)
├─ 8-stage filter chain (auth→session→classify→policy→budget→execute→response→audit)
├─ Breach evaluator (30s posture poll, BreachResponse enforcement)
└─ Audit pipeline → Quartermaster merkle anchoring
bascule-agent (application sidecar)
├─ Shellstream protocol (msgpack over Unix socket)
├─ 8 namespace handlers (Crypto, Identity, Secrets, Governance,
│ Attestation, Audit, Network, Intelligence)
├─ PostureReader → posture-current ConfigMap (cached, 30s TTL)
└─ Optional SSH server on port 2222
bascule-core (shared types)
├─ SessionScope, ShellClass, DelegationScope
├─ CeremonyGrant, CeremonyType, Evidence
├─ derive_shell_class(PostureLevel) → ShellClass
└─ BreachAction evaluation
```
## Keylime Integration Model
Bascule **consumes** Keylime attestation — it does not reimplement or
compete with it. The integration boundary is the `posture-current`
ConfigMap written by the substrate-operator's posture evaluator:
```
Keylime verifier (CNCF)
→ substrate-operator / TpmAttestationValid checker
→ posture-current ConfigMap (level: 1-5)
→ bascule-agent PostureReader
→ bascule-gateway ceremony grant (ShellClass derivation)
→ bascule-gateway breach evaluator (session downgrade)
```
The `keylime-client` crate (in the substrate workspace) is the single
Keylime consumer. Neither bascule-agent nor bascule-gateway imports it
directly. They read the ConfigMap output.
## ShellClass Model
Sessions are classified at ceremony grant time based on the host's
operational posture level:
| PostureLevel | ShellClass | Operations Permitted |
|---|---|---|
| Normal (5) | System | Kernel modules, firmware, network config, storage |
| Elevated (4) | Application | Deploy, query APIs, run playbooks |
| Restricted (3) | Application | Deploy, query APIs, run playbooks |
| Critical (2) | Application | Deploy, query APIs, run playbooks |
| Lockdown (1) | Application | Deploy, query APIs, run playbooks |
- No mid-session upgrade. Downgrade only (on posture breach).
- Upgrade requires a new ceremony.
- DelegationScope enables "Infrastructure shells" — Application sessions
that orchestrate System operations on remote hosts (Ansible pattern).
## What's Implemented vs Planned
| Component | Status |
|---|---|
| Ceremony engine (3 types) | Implemented |
| 8-stage filter chain | Implemented |
| PostureReader (ConfigMap) | Implemented |
| ShellClass derivation | Implemented |
| DelegationScope + pre-flight | Implemented (target posture query stubbed) |
| Breach evaluator + downgrade | Implemented |
| Accord hot-reload | Not implemented (static at startup) |
| Helm chart | Exists, not updated for posture fields |
| LabelSelector delegation | Type defined, async resolution deferred |
## License
Apache-2.0. All source files carry SPDX headers.

40
CHANGELOG.md Normal file
View file

@ -0,0 +1,40 @@
# Changelog
## [0.1.0-rc.1] - 2026-04-15
### Added
- **ShellClass** (Application | System) derived from PostureLevel at ceremony grant
- Immutable for session lifetime — no mid-session upgrade, downgrade only
- `derive_shell_class()` pure function with configurable threshold
- `satisfies()` hierarchical check (System satisfies Application)
- **PostureReader** in bascule-agent replacing soft-mode attestation
- Reads `posture-current` ConfigMap written by substrate-operator
- TTL-cached (30s default) with stale-serve-on-error semantics
- Fail-closed to `PostureLevel::Lockdown` on ConfigMap unavailability
- `posture_source="static"` preserved for dev/test without a cluster
- **DelegationScope** for Infrastructure shell pattern
- Application sessions with delegation authority for orchestrators (Ansible/Terraform)
- `TargetSelector`: Hosts, LabelSelector (deferred), TrustDomain
- Orthogonal to ShellClass — independent axes on SessionScope
- **Session downgrade on posture breach**
- Breach evaluator maps all 5 `BreachResponse` variants (LogOnly, AlertDelegates,
ReducePosture, SuspendTrust, RevokeAccord)
- 30s posture polling loop on `posture-current` ConfigMap
- System sessions downgraded to Application on posture degradation
- SuspendTrust/RevokeAccord terminate sessions immediately
- **Worker pre-flight enforcement** in org-ops
- `required_shell_class()` on OrgCommands trait (default: Application)
- `target_host()` on OrgCommands trait for remote dispatch
- Three-step pre-flight: delegation authority + target scope + target posture
- Fail-closed on unknown delegation or posture
- **SessionScope enrichment**
- `shell_class: ShellClass` with `#[serde(default)]`
- `posture_level_at_establishment: Option<u8>` with `#[serde(default)]`
- `delegation: DelegationScope` with `#[serde(default)]`
- All backward-compatible with existing persisted sessions

19
Cargo.lock generated
View file

@ -7,6 +7,7 @@ name = "accord-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"governance-types",
"serde", "serde",
"serde_yaml", "serde_yaml",
"sha2", "sha2",
@ -390,9 +391,12 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"dashmap", "dashmap",
"governance-types",
"hex", "hex",
"hfl-types", "hfl-types",
"jsonwebtoken", "jsonwebtoken",
"k8s-openapi",
"kube",
"rand", "rand",
"reqwest", "reqwest",
"rmp-serde", "rmp-serde",
@ -420,6 +424,7 @@ dependencies = [
"async-trait", "async-trait",
"ceremony-engine", "ceremony-engine",
"chrono", "chrono",
"governance-types",
"hex", "hex",
"registry-protocol", "registry-protocol",
"serde", "serde",
@ -457,6 +462,8 @@ dependencies = [
"chrono", "chrono",
"config", "config",
"dashmap", "dashmap",
"governance-types",
"guildhouse-proto",
"hex", "hex",
"jsonwebtoken", "jsonwebtoken",
"k8s-openapi", "k8s-openapi",
@ -689,6 +696,7 @@ dependencies = [
"accord-core", "accord-core",
"async-trait", "async-trait",
"chrono", "chrono",
"governance-types",
"hex", "hex",
"registry-protocol", "registry-protocol",
"serde", "serde",
@ -1600,6 +1608,17 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "governance-types"
version = "0.1.0"
dependencies = [
"hex",
"serde",
"serde_json",
"sha1",
"sha2",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"

View file

@ -2,6 +2,9 @@
name = "bascule-agent" name = "bascule-agent"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Governed application sidecar — Shellstream namespace router with attestation"
license = "Apache-2.0"
repository = "https://git.guildhouse.dev/guildhouse/bascule"
[[bin]] [[bin]]
name = "bascule-agent" name = "bascule-agent"
@ -31,6 +34,11 @@ async-trait = { workspace = true }
# Cross-workspace path deps — substrate crates # Cross-workspace path deps — substrate crates
substrate-rt = { path = "../../substrate/crates/substrate-rt" } substrate-rt = { path = "../../substrate/crates/substrate-rt" }
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] } hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
governance-types = { path = "../../substrate/crates/governance-types" }
# Kubernetes (for posture ConfigMap reader)
kube = { workspace = true }
k8s-openapi = { workspace = true }
# Msgpack — retained for convenience constructors and legacy decode paths # Msgpack — retained for convenience constructors and legacy decode paths
rmp-serde = "1" rmp-serde = "1"

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ name = "bascule-core"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Shared types for the Bascule governance-mediated access control system" description = "Shared types for the Bascule governance-mediated access control system"
license = "Apache-2.0"
repository = "https://git.guildhouse.dev/guildhouse/bascule"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
@ -21,3 +23,6 @@ ceremony-engine = { workspace = true }
# Cross-workspace path deps — Guildhouse governance primitives. # Cross-workspace path deps — Guildhouse governance primitives.
accord-core = { path = "../../guildhouse/services/accord-core" } accord-core = { path = "../../guildhouse/services/accord-core" }
registry-protocol = { path = "../../guildhouse/services/registry-protocol" } registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
# Cross-workspace path dep — substrate governance types (for PostureLevel).
governance-types = { path = "../../substrate/crates/governance-types" }

View file

@ -0,0 +1,202 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Delegation authority for governed shell sessions.
//!
//! Controls whether a session can dispatch governed operations to remote
//! targets. Orthogonal to [`ShellClass`] — an Application session can
//! have delegation authority (the "Infrastructure shell" pattern for
//! Ansible/Terraform orchestrators).
use crate::shell_class::ShellClass;
use serde::{Deserialize, Serialize};
/// Delegation authority for a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationScope {
/// Whether delegation is permitted at all.
pub permitted: bool,
/// Target hosts this session may delegate to.
pub target_selector: TargetSelector,
/// Maximum shell class that can be delegated.
pub max_delegated_class: ShellClass,
}
/// Selector for delegation targets.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetSelector {
/// No targets — delegation denied regardless of `permitted` flag.
None,
/// Specific hosts by name.
Hosts(Vec<String>),
/// Kubernetes label selector (resolved at dispatch time).
LabelSelector(String),
/// All hosts in the trust domain.
TrustDomain,
}
impl Default for DelegationScope {
fn default() -> Self {
Self {
permitted: false,
target_selector: TargetSelector::None,
max_delegated_class: ShellClass::Application,
}
}
}
/// Result of a delegation scope check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DelegationDecision {
/// Delegation is allowed.
Permitted,
/// Delegation is denied with a reason.
Denied { reason: String },
/// Delegation check requires async resolution (label selector).
Deferred { reason: String },
}
impl DelegationScope {
/// Check whether this scope permits delegation to a specific target
/// for a specific operation class.
pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision {
if !self.permitted {
return DelegationDecision::Denied {
reason: "session does not have delegation authority".into(),
};
}
let target_allowed = match &self.target_selector {
TargetSelector::None => false,
TargetSelector::Hosts(hosts) => hosts.iter().any(|h| h == target_host),
TargetSelector::LabelSelector(_) => {
return DelegationDecision::Deferred {
reason: "label selector requires K8s API resolution".into(),
};
}
TargetSelector::TrustDomain => true,
};
if !target_allowed {
return DelegationDecision::Denied {
reason: format!("target '{target_host}' is not in delegation scope"),
};
}
if !self.max_delegated_class.satisfies(required_class) {
return DelegationDecision::Denied {
reason: format!(
"delegation permits {} operations, but {} required",
self.max_delegated_class, required_class
),
};
}
DelegationDecision::Permitted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_denies_delegation() {
let scope = DelegationScope::default();
assert!(!scope.permitted);
let d = scope.permits("any-host", ShellClass::Application);
assert_eq!(
d,
DelegationDecision::Denied {
reason: "session does not have delegation authority".into()
}
);
}
#[test]
fn permitted_with_host_match() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::Hosts(vec!["worker-1".into(), "worker-2".into()]),
max_delegated_class: ShellClass::System,
};
assert_eq!(
scope.permits("worker-1", ShellClass::Application),
DelegationDecision::Permitted
);
assert_eq!(
scope.permits("worker-1", ShellClass::System),
DelegationDecision::Permitted
);
}
#[test]
fn permitted_host_not_in_scope() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::Hosts(vec!["worker-1".into()]),
max_delegated_class: ShellClass::System,
};
let d = scope.permits("worker-99", ShellClass::Application);
assert!(matches!(d, DelegationDecision::Denied { .. }));
}
#[test]
fn trust_domain_allows_any_host() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::TrustDomain,
max_delegated_class: ShellClass::System,
};
assert_eq!(
scope.permits("any-host", ShellClass::System),
DelegationDecision::Permitted
);
}
#[test]
fn label_selector_defers() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::LabelSelector("role=worker".into()),
max_delegated_class: ShellClass::System,
};
assert!(matches!(
scope.permits("worker-1", ShellClass::Application),
DelegationDecision::Deferred { .. }
));
}
#[test]
fn class_ceiling_enforced() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::TrustDomain,
max_delegated_class: ShellClass::Application, // ceiling
};
assert_eq!(
scope.permits("worker-1", ShellClass::Application),
DelegationDecision::Permitted
);
assert!(matches!(
scope.permits("worker-1", ShellClass::System),
DelegationDecision::Denied { .. }
));
}
#[test]
fn none_target_selector_denies() {
let scope = DelegationScope {
permitted: true,
target_selector: TargetSelector::None,
max_delegated_class: ShellClass::System,
};
assert!(matches!(
scope.permits("any-host", ShellClass::Application),
DelegationDecision::Denied { .. }
));
}
}

View file

@ -1,8 +1,13 @@
pub mod audit; pub mod audit;
pub mod ceremony; pub mod ceremony;
pub mod command; pub mod command;
pub mod delegation;
pub mod scope; pub mod scope;
pub mod session; pub mod session;
pub mod shell_class;
pub use delegation::{DelegationDecision, DelegationScope, TargetSelector};
pub use shell_class::{derive_shell_class, ShellClass};
// Governance ceremony engine — extracted to ceremony-engine crate. // Governance ceremony engine — extracted to ceremony-engine crate.
// Re-exported here for backward compatibility while consumers migrate. // Re-exported here for backward compatibility while consumers migrate.

View file

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::delegation::DelegationScope;
use crate::shell_class::ShellClass;
/// Defines what an operator can do within a session. /// Defines what an operator can do within a session.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionScope { pub struct SessionScope {
@ -9,6 +12,21 @@ pub struct SessionScope {
/// Maximum mutations before session requires a new ceremony. None = unlimited. /// Maximum mutations before session requires a new ceremony. None = unlimited.
pub mutation_budget: Option<u32>, pub mutation_budget: Option<u32>,
pub can_delegate: bool, pub can_delegate: bool,
/// Shell class for this session, derived from posture at establishment.
/// Immutable after creation — downgrade creates a new scope, upgrade
/// requires a new ceremony.
#[serde(default)]
pub shell_class: ShellClass,
/// Posture level (wire value 1-5) at time of session establishment.
/// Stored for audit (Chronicle) and for posture breach comparison.
#[serde(default)]
pub posture_level_at_establishment: Option<u8>,
/// Delegation authority — controls remote dispatch to other hosts.
#[serde(default)]
pub delegation: DelegationScope,
} }
/// Per-namespace access rules. /// Per-namespace access rules.
@ -134,6 +152,9 @@ mod tests {
pathways: vec![ChangePathway::DryRunOnly], pathways: vec![ChangePathway::DryRunOnly],
mutation_budget: None, mutation_budget: None,
can_delegate: false, can_delegate: false,
shell_class: ShellClass::default(),
posture_level_at_establishment: None,
delegation: DelegationScope::default(),
}; };
assert!(scope.permits("default", "", "pods", Verb::Get)); assert!(scope.permits("default", "", "pods", Verb::Get));
@ -160,6 +181,9 @@ mod tests {
pathways: vec![], pathways: vec![],
mutation_budget: None, mutation_budget: None,
can_delegate: false, can_delegate: false,
shell_class: ShellClass::default(),
posture_level_at_establishment: None,
delegation: DelegationScope::default(),
}; };
assert!(scope.permits("default", "", "pods", Verb::Get)); assert!(scope.permits("default", "", "pods", Verb::Get));

View file

@ -0,0 +1,167 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Shell class — session-scoped classification derived from posture.
//!
//! Determines what class of operations a governed shell session permits.
//! Derived from [`PostureLevel`] at ceremony grant time and immutable for
//! the session lifetime. Upgrade requires a new ceremony; downgrade can
//! occur on posture breach.
use governance_types::PostureLevel;
use serde::{Deserialize, Serialize};
use std::fmt;
/// Classification of a governed shell session.
///
/// - `Application` — software operations (deploy, query, playbooks).
/// - `System` — host operations (kernel modules, firmware, storage).
///
/// This is a Bascule concept (session-scoped), distinct from the
/// substrate-wide `PostureLevel` (host-scoped, continuous).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShellClass {
/// Software operations only. The host's kernel and firmware
/// integrity is not verified. Default for all sessions unless
/// attestation elevates to System.
#[default]
Application,
/// Host operations. Kernel modules, firmware updates, network
/// config, storage management, security policy changes. Requires
/// the host to be in an attested posture (TPM + IMA verified).
System,
}
impl fmt::Display for ShellClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ShellClass::Application => write!(f, "application"),
ShellClass::System => write!(f, "system"),
}
}
}
impl ShellClass {
/// Returns true if this class permits system-level operations.
pub fn is_system(&self) -> bool {
matches!(self, ShellClass::System)
}
/// Returns true if `required` is satisfied by `self`.
///
/// System satisfies both System and Application requirements.
/// Application satisfies only Application requirements.
pub fn satisfies(&self, required: ShellClass) -> bool {
match (self, required) {
(ShellClass::System, _) => true,
(ShellClass::Application, ShellClass::Application) => true,
(ShellClass::Application, ShellClass::System) => false,
}
}
}
/// Derive `ShellClass` from a `PostureLevel` and an optional threshold.
///
/// Uses the **operational** PostureLevel semantic (Lockdown=1 → Normal=5).
/// Default threshold: `PostureLevel::Normal` — the host must be in normal
/// operational posture for System access. Any DEFCON escalation restricts
/// to Application shells.
///
/// The Accord's WitnessConfig can override the threshold via a
/// `PostureCondition` with kind `"system_shell_minimum"`.
///
/// # Dual PostureLevel semantic note
///
/// governance-types defines PostureLevel on the operational scale
/// (Lockdown < Critical < Restricted < Elevated < Normal). The
/// session/attestation scale (None < Local < Verified < Governed <
/// Attested) lives in the proto layer. This function uses the
/// operational scale because that's what the posture-current ConfigMap
/// provides. When the two semantics are disambiguated into separate
/// types, this function should migrate to the attestation type.
pub fn derive_shell_class(
posture_level: PostureLevel,
system_threshold: Option<PostureLevel>,
) -> ShellClass {
let threshold = system_threshold.unwrap_or(PostureLevel::Normal);
// PostureLevel derives Ord: Lockdown(1) < Normal(5).
// posture_level >= threshold means "at least as permissive as the threshold."
if posture_level >= threshold {
ShellClass::System
} else {
ShellClass::Application
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_application() {
assert_eq!(ShellClass::default(), ShellClass::Application);
}
#[test]
fn derive_normal_posture_is_system() {
let class = derive_shell_class(PostureLevel::Normal, None);
assert_eq!(class, ShellClass::System);
}
#[test]
fn derive_elevated_posture_is_application() {
let class = derive_shell_class(PostureLevel::Elevated, None);
assert_eq!(class, ShellClass::Application);
}
#[test]
fn derive_lockdown_is_application() {
let class = derive_shell_class(PostureLevel::Lockdown, None);
assert_eq!(class, ShellClass::Application);
}
#[test]
fn derive_with_custom_threshold() {
// Lower threshold: Elevated (4) or above gets System
let class = derive_shell_class(PostureLevel::Elevated, Some(PostureLevel::Elevated));
assert_eq!(class, ShellClass::System);
let class = derive_shell_class(PostureLevel::Restricted, Some(PostureLevel::Elevated));
assert_eq!(class, ShellClass::Application);
}
#[test]
fn satisfies_system_satisfies_both() {
assert!(ShellClass::System.satisfies(ShellClass::System));
assert!(ShellClass::System.satisfies(ShellClass::Application));
}
#[test]
fn satisfies_application_only_satisfies_application() {
assert!(ShellClass::Application.satisfies(ShellClass::Application));
assert!(!ShellClass::Application.satisfies(ShellClass::System));
}
#[test]
fn display_formatting() {
assert_eq!(format!("{}", ShellClass::Application), "application");
assert_eq!(format!("{}", ShellClass::System), "system");
}
#[test]
fn serde_round_trip() {
let json = serde_json::to_string(&ShellClass::System).unwrap();
assert_eq!(json, "\"system\"");
let parsed: ShellClass = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ShellClass::System);
}
#[test]
fn is_system() {
assert!(ShellClass::System.is_system());
assert!(!ShellClass::Application.is_system());
}
}

View file

@ -3,6 +3,8 @@ name = "bascule-gateway"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Bascule governance gateway — cluster-side API gateway for governed access" description = "Bascule governance gateway — cluster-side API gateway for governed access"
license = "Apache-2.0"
repository = "https://git.guildhouse.dev/guildhouse/bascule"
[[bin]] [[bin]]
name = "bascule-gateway" name = "bascule-gateway"
@ -17,6 +19,10 @@ bascule-proto = { workspace = true }
accord-core = { path = "../../guildhouse/services/accord-core" } accord-core = { path = "../../guildhouse/services/accord-core" }
accord-opa = { path = "../../guildhouse/services/accord-opa" } accord-opa = { path = "../../guildhouse/services/accord-opa" }
qm-core = { path = "../../guildhouse/services/qm-core" } qm-core = { path = "../../guildhouse/services/qm-core" }
guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" }
# Cross-workspace path dep — substrate governance types (for PostureLevel).
governance-types = { path = "../../substrate/crates/governance-types" }
# Kubernetes # Kubernetes
kube = { workspace = true } kube = { workspace = true }

View file

@ -0,0 +1,45 @@
# Bascule gateway — F.4 production image.
#
# BUILD CONTEXT: substrate-project repo root (two levels above bascule-workspace/).
# Rationale: bascule-gateway's Cargo.toml has cross-workspace path
# dependencies reaching:
# - ../../guildhouse/services/accord-core
# - ../../guildhouse/services/accord-opa
# - ../../guildhouse/services/qm-core
# - ../../guildhouse/services/guildhouse-proto
# - ../../substrate/crates/governance-types
# - (transitively) ../../guildhouse/sdk/guildhouse-mq, guildhouse-tower
# - bascule-workspace siblings (bascule-core, bascule-proto)
#
# Invocation:
# docker build -t git.guildhouse.dev/tking/bascule-gateway:v0.1.0 \
# -f bascule-workspace/bascule-gateway/Dockerfile \
# <substrate-project root>
FROM rust:bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
# Cross-workspace path deps (change rarely). substrate is copied as a
# whole because its crates inherit `edition.workspace = true` from the
# substrate workspace root Cargo.toml — copying just substrate/crates
# orphans the inheritance chain. Workspace members that live outside
# substrate/crates/ (metakernel/, shell/loader, shellstream/) need to be
# present for `cargo build` to load the workspace metadata, even though
# bascule's actual dep tree only reaches into substrate/crates/.
COPY substrate ./substrate
COPY guildhouse/sdk ./guildhouse/sdk
COPY guildhouse/services ./guildhouse/services
# bascule-workspace itself (includes bascule-core, bascule-proto,
# and all sibling crates the workspace Cargo.toml references).
COPY bascule-workspace ./bascule-workspace
WORKDIR /app/bascule-workspace
RUN cargo build --release --bin bascule-gateway
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/bascule-workspace/target/release/bascule-gateway /usr/local/bin/
EXPOSE 50052
CMD ["bascule-gateway"]

View file

@ -8,51 +8,100 @@ use chrono::Utc;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tonic::transport::{Channel, Endpoint};
use uuid::Uuid; use uuid::Uuid;
use guildhouse_proto::quartermaster::v1::{
quartermaster_notary_client::QuartermasterNotaryClient, CreateAnchorRequest,
};
/// A leaf ready for merkle anchoring. /// A leaf ready for merkle anchoring.
struct AuditLeaf { struct AuditLeaf {
event_id: Uuid, event_id: Uuid,
#[allow(dead_code)]
session_id: Uuid, session_id: Uuid,
leaf_hash: [u8; 32], leaf_hash: [u8; 32],
} }
/// Buffers audit events and periodically flushes merkle leaf hashes. /// Buffers audit events and periodically flushes merkle leaf hashes to
/// Quartermaster's `CreateAnchor` RPC.
///
/// Lazy-connect semantics: the QM gRPC channel is established on first
/// successful flush and cached. If QM is unreachable at startup, bascule
/// still stores audit events locally (marked `notarized=false`). Each
/// subsequent flush retries the connection; the first successful
/// CreateAnchor catches up by submitting the entire pending batch.
pub struct AuditPipeline { pub struct AuditPipeline {
pending: Mutex<Vec<AuditLeaf>>, pending: Mutex<Vec<AuditLeaf>>,
db_pool: PgPool, db_pool: PgPool,
batch_size: usize, batch_size: usize,
qm_endpoint: String,
cluster_id: String,
notary_client: Arc<Mutex<Option<QuartermasterNotaryClient<Channel>>>>,
} }
impl AuditPipeline { impl AuditPipeline {
pub fn new(db_pool: PgPool, batch_size: usize) -> Self { pub fn new(
db_pool: PgPool,
batch_size: usize,
qm_endpoint: String,
cluster_id: String,
) -> Self {
Self { Self {
pending: Mutex::new(Vec::new()), pending: Mutex::new(Vec::new()),
db_pool, db_pool,
batch_size, batch_size,
qm_endpoint,
cluster_id,
notary_client: Arc::new(Mutex::new(None)),
} }
} }
/// Submit an audit event: insert into PG and queue leaf for anchoring. /// Lazily establish and cache the QM NotaryClient connection.
pub async fn submit(&self, event: &AuditEvent, notarize: bool) { /// Returns None if QM is unreachable or if cluster_id is unset
// Compute merkle leaf /// (which disables QM submission — bascule stores events locally only).
let canonical = match serde_json_canonicalizer::to_string(&event) { async fn get_or_connect(&self) -> Option<QuartermasterNotaryClient<Channel>> {
Ok(c) => c, if self.cluster_id.is_empty() {
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) => { Err(e) => {
tracing::error!("Failed to canonicalize audit event: {e}"); tracing::warn!(
return; endpoint = %self.qm_endpoint,
} error = %e,
}; "Quartermaster notary unreachable; audit events stored locally, will retry at next flush"
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()); None
}
}
}
// Insert into PG /// Record an audit event into the local ledger. If `notarize` is true,
/// queue the leaf hash for the next batched CreateAnchor call.
///
/// Kept named `submit` for source-compat with the filter chain caller
/// (`filter/audit.rs::log_and_submit`).
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data_bytes(event).as_bytes());
let command_json = serde_json::to_value(&event.command).unwrap_or_default(); let command_json = serde_json::to_value(&event.command).unwrap_or_default();
let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default(); let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default();
let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default(); let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default();
@ -88,7 +137,6 @@ impl AuditPipeline {
return; return;
} }
// Queue for merkle anchoring if needed
if notarize { if notarize {
let mut pending = self.pending.lock().await; let mut pending = self.pending.lock().await;
pending.push(AuditLeaf { pending.push(AuditLeaf {
@ -105,6 +153,13 @@ impl AuditPipeline {
} }
/// Flush pending leaves to Quartermaster for anchoring. /// Flush pending leaves to Quartermaster for anchoring.
///
/// Calls QM's `CreateAnchor` RPC with all pending leaf hashes, then
/// updates each event's row with the returned `anchor_id` and its
/// position in the batch (`leaf_index`). If QM is unreachable, the
/// leaves stay in the pending buffer and retry on the next flush —
/// the `notarized=true` flag in PG is already set but `anchor_id`
/// remains NULL until a successful anchor lands.
pub async fn flush(&self) { pub async fn flush(&self) {
let leaves: Vec<AuditLeaf> = { let leaves: Vec<AuditLeaf> = {
let mut pending = self.pending.lock().await; let mut pending = self.pending.lock().await;
@ -117,19 +172,70 @@ impl AuditPipeline {
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring"); tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
// Phase 2: mark events as anchored in PG. let mut client = match self.get_or_connect().await {
// Actual QM gRPC submission is a future enhancement -- for now we Some(c) => c,
// compute and store the leaf hashes, which is the cryptographic guarantee. None => {
// The anchor_id will be set when we integrate QM's FlushAnchor RPC. // QM unreachable or disabled. Re-queue leaves for next flush.
for leaf in &leaves { let mut pending = self.pending.lock().await;
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 SET notarized = true WHERE event_id = $1", "UPDATE bascule.audit_events
SET anchor_id = $1, leaf_index = $2
WHERE event_id = $3",
) )
.bind(anchor_id)
.bind(idx as i32)
.bind(leaf.event_id) .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(
@ -146,6 +252,45 @@ impl AuditPipeline {
} }
} }
/// Classification + ledger-fidelity → notarize decision.
///
/// OPA decisions can override default per-classification behavior:
/// - `always_notarize` → every event anchored, regardless of class
/// - `log_only` → no anchor, local ledger row only
/// - default → mutative + session-lifecycle events anchored; reads
/// stay local
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
match ledger_fidelity {
"always_notarize" => true,
"log_only" => false,
_ => matches!(
classification,
ChangeClassification::Mutative | ChangeClassification::Session
),
}
}
/// Canonical leaf-data serialization used for merkle-leaf hashing.
fn leaf_data_bytes(event: &AuditEvent) -> String {
// Include the fields most salient for tamper-detection: event_id,
// session_id, operator, command, classification, execution result,
// timestamp. Target resources + profile hash would be next fields to
// include if/when the verification API wants to constrain on them.
let mut hasher = Sha256::new();
hasher.update(event.event_id.as_bytes());
hasher.update(event.session_id.as_bytes());
hasher.update(event.operator_identity.display_id().as_bytes());
if let Ok(cmd) = serde_json::to_string(&event.command) {
hasher.update(cmd.as_bytes());
}
hasher.update(format!("{:?}", event.classification).as_bytes());
if let Ok(exec) = serde_json::to_string(&event.execution_result) {
hasher.update(exec.as_bytes());
}
hasher.update(event.timestamp.to_rfc3339().as_bytes());
hex::encode(hasher.finalize())
}
/// Build an AuditEvent from the filter chain's request context. /// Build an AuditEvent from the filter chain's request context.
pub fn build_audit_event( pub fn build_audit_event(
session_id: Uuid, session_id: Uuid,
@ -200,100 +345,30 @@ fn resolve_api_group(resource_type: &str) -> String {
"apps".to_string() "apps".to_string()
} }
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(), "jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
_ => String::new(), // core group _ => "".to_string(),
} }
} }
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value { fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
// Convert prost Struct fields to a JSON object manually serde_json::json!(s
let mut map = serde_json::Map::new(); .fields
for (key, value) in &s.fields { .iter()
map.insert(key.clone(), prost_value_to_json(value)); .map(|(k, v)| (k.clone(), prost_value_to_json(v)))
} .collect::<serde_json::Map<String, serde_json::Value>>())
serde_json::Value::Object(map)
} }
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value { fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
use prost_types::value::Kind;
match &v.kind { match &v.kind {
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null, Some(Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
Some(prost_types::value::Kind::NumberValue(n)) => { Some(Kind::NumberValue(n)) => {
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0))) serde_json::Number::from_f64(*n).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
} }
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()), Some(Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b), Some(Kind::ListValue(l)) => {
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
Some(prost_types::value::Kind::ListValue(l)) => {
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect()) serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
} }
None => serde_json::Value::Null, Some(Kind::StructValue(s)) => prost_struct_to_json(s),
} _ => serde_json::Value::Null,
}
/// Determine if this event should be notarized based on ledger fidelity.
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
match ledger_fidelity {
"always_notarize" => true,
"log_only" => false,
_ => {
// Default: notarize mutative operations, log reads
matches!(
classification,
ChangeClassification::Mutative | ChangeClassification::Session
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_leaf_format() {
let session_id = Uuid::new_v4();
let event_id = Uuid::new_v4();
let content = "test content";
let content_hash = Sha256::digest(content.as_bytes());
let leaf_data = format!(
"bascule:{}:{}:{}",
session_id,
event_id,
hex::encode(content_hash)
);
assert!(leaf_data.starts_with("bascule:"));
assert!(leaf_data.contains(&session_id.to_string()));
assert!(leaf_data.contains(&event_id.to_string()));
// Verify hash_leaf produces a 32-byte hash
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
assert_eq!(leaf_hash.len(), 32);
}
#[test]
fn test_should_notarize() {
assert!(should_notarize(
ChangeClassification::Mutative,
"always_notarize"
));
assert!(should_notarize(
ChangeClassification::Read,
"always_notarize"
));
assert!(!should_notarize(ChangeClassification::Read, "log_only"));
assert!(!should_notarize(
ChangeClassification::Mutative,
"log_only"
));
// Default behavior
assert!(should_notarize(ChangeClassification::Mutative, "default"));
assert!(!should_notarize(ChangeClassification::Read, "default"));
}
#[test]
fn test_resolve_api_group() {
assert_eq!(resolve_api_group("deployments"), "apps");
assert_eq!(resolve_api_group("pods"), "");
assert_eq!(resolve_api_group("jobs"), "batch");
} }
} }

View file

@ -0,0 +1,255 @@
// Copyright 2026 Guildhouse Dev
// SPDX-License-Identifier: Apache-2.0
//! Breach evaluator — compares posture changes against active sessions
//! and determines the appropriate response.
use bascule_core::{derive_shell_class, ShellClass};
use governance_types::{BreachResponse, PostureLevel};
use uuid::Uuid;
/// Action to take on an active session after a posture change.
#[derive(Debug, Clone)]
pub enum BreachAction {
/// Posture still satisfies session requirements.
NoAction,
/// Downgrade shell class (System → Application).
Downgrade {
session_id: Uuid,
from: ShellClass,
to: ShellClass,
reason: String,
},
/// Terminate the session immediately.
Terminate {
session_id: Uuid,
reason: String,
},
}
/// Evaluate a posture change for a specific session.
///
/// Compares new posture against the session's establishment posture
/// and the Accord's `BreachResponse` to determine the action.
pub fn evaluate_breach(
session_id: Uuid,
established_posture: u8,
established_shell_class: ShellClass,
new_posture_level: u8,
breach_response: &BreachResponse,
system_threshold: Option<PostureLevel>,
) -> BreachAction {
let new_level = match PostureLevel::from_wire(new_posture_level) {
Some(l) => l,
None => return BreachAction::NoAction, // Invalid level — don't act on garbage
};
let new_shell_class = derive_shell_class(new_level, system_threshold);
// If the new shell class still satisfies the established one, no breach
if new_shell_class.satisfies(established_shell_class) {
return BreachAction::NoAction;
}
// Posture has degraded — apply BreachResponse policy
match breach_response {
BreachResponse::LogOnly => {
tracing::info!(
%session_id,
old = established_posture,
new = new_posture_level,
"Posture breach detected (LogOnly — no enforcement)"
);
BreachAction::NoAction
}
BreachResponse::AlertDelegates => {
// Delegate notification happens elsewhere (WitnessForwarder).
// For session purposes, same as LogOnly.
tracing::info!(
%session_id,
old = established_posture,
new = new_posture_level,
"Posture breach detected (AlertDelegates)"
);
BreachAction::NoAction
}
BreachResponse::ReducePosture { .. } => {
if established_shell_class == ShellClass::System {
BreachAction::Downgrade {
session_id,
from: ShellClass::System,
to: ShellClass::Application,
reason: format!(
"posture degraded from {} to {} (below System threshold)",
established_posture, new_posture_level
),
}
} else {
BreachAction::NoAction
}
}
BreachResponse::SuspendTrust => BreachAction::Terminate {
session_id,
reason: format!(
"trust suspended: posture degraded to {}",
new_posture_level
),
},
BreachResponse::RevokeAccord => BreachAction::Terminate {
session_id,
reason: "accord revoked due to posture breach".into(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uuid() -> Uuid {
Uuid::nil()
}
#[test]
fn no_breach_posture_unchanged() {
let action = evaluate_breach(
uuid(),
5, // Normal
ShellClass::System,
5, // Still Normal
&BreachResponse::ReducePosture {
target_level: PostureLevel::Lockdown,
},
None,
);
assert!(matches!(action, BreachAction::NoAction));
}
#[test]
fn no_breach_posture_improved() {
let action = evaluate_breach(
uuid(),
4, // Elevated
ShellClass::Application,
5, // Normal (improved)
&BreachResponse::ReducePosture {
target_level: PostureLevel::Lockdown,
},
None,
);
assert!(matches!(action, BreachAction::NoAction));
}
#[test]
fn breach_reduce_posture_downgrades_system() {
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
3, // Restricted — below Normal threshold
&BreachResponse::ReducePosture {
target_level: PostureLevel::Restricted,
},
None,
);
assert!(matches!(action, BreachAction::Downgrade { .. }));
}
#[test]
fn breach_suspend_trust_terminates() {
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
3,
&BreachResponse::SuspendTrust,
None,
);
assert!(matches!(action, BreachAction::Terminate { .. }));
}
#[test]
fn breach_revoke_accord_terminates() {
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
3,
&BreachResponse::RevokeAccord,
None,
);
assert!(matches!(action, BreachAction::Terminate { .. }));
}
#[test]
fn already_application_reduce_posture_no_action() {
let action = evaluate_breach(
uuid(),
4,
ShellClass::Application,
2, // Critical — but already Application
&BreachResponse::ReducePosture {
target_level: PostureLevel::Lockdown,
},
None,
);
assert!(matches!(action, BreachAction::NoAction));
}
#[test]
fn breach_with_custom_threshold() {
// Threshold at Elevated(4) — Normal(5) and Elevated(4) both qualify
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
4, // Elevated — still above threshold
&BreachResponse::ReducePosture {
target_level: PostureLevel::Lockdown,
},
Some(PostureLevel::Elevated),
);
assert!(matches!(action, BreachAction::NoAction));
// Drop below custom threshold
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
3, // Restricted — below Elevated threshold
&BreachResponse::ReducePosture {
target_level: PostureLevel::Lockdown,
},
Some(PostureLevel::Elevated),
);
assert!(matches!(action, BreachAction::Downgrade { .. }));
}
#[test]
fn log_only_no_enforcement() {
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
3,
&BreachResponse::LogOnly,
None,
);
assert!(matches!(action, BreachAction::NoAction));
}
#[test]
fn invalid_posture_level_no_action() {
let action = evaluate_breach(
uuid(),
5,
ShellClass::System,
0, // Invalid wire value
&BreachResponse::SuspendTrust,
None,
);
assert!(matches!(action, BreachAction::NoAction));
}
}

View file

@ -43,6 +43,15 @@ pub struct BasculeConfig {
#[serde(default = "default_qm_endpoint")] #[serde(default = "default_qm_endpoint")]
pub qm_endpoint: String, pub qm_endpoint: String,
// --- Cluster identity (UUID of the FFC cluster this bascule belongs to).
// Required for QM CreateAnchor submission: QM validates the cluster_id
// on every anchor write against its clusters table. Source the UUID
// from `quartermaster.clusters` in the OpsDB (QM generates it at
// genesis). Empty string disables QM submission (bascule still stores
// audit events locally).
#[serde(default)]
pub cluster_id: String,
// --- Accord --- // --- Accord ---
#[serde(default = "default_accord_path")] #[serde(default = "default_accord_path")]
pub accord_path: String, pub accord_path: String,
@ -112,6 +121,7 @@ impl BasculeConfig {
let config = config::Config::builder() let config = config::Config::builder()
.add_source( .add_source(
config::Environment::with_prefix("BASCULE") config::Environment::with_prefix("BASCULE")
.prefix_separator("_")
.separator("__") .separator("__")
.try_parsing(true), .try_parsing(true),
) )

View file

@ -1,5 +1,6 @@
mod audit_pipeline; mod audit_pipeline;
mod auth; mod auth;
mod breach;
mod ceremony; mod ceremony;
mod config; mod config;
mod executor; mod executor;
@ -68,8 +69,8 @@ spec:
fidelity: always_notarize fidelity: always_notarize
notarize: [] notarize: []
logOnly: [] logOnly: []
sampled: [] # sampled omitted Option<SampledConfig> default None (struct shape:
sampleRate: 1 # {events: [...], sample_rate: N}), `sampled: []` would mis-parse.
reconciliation: reconciliation:
defaultWindow: "24h" defaultWindow: "24h"
onExpiry: alert onExpiry: alert
@ -131,16 +132,68 @@ spec:
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new( let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
pool.clone(), pool.clone(),
config.audit_batch_size, config.audit_batch_size,
config.qm_endpoint.clone(),
config.cluster_id.clone(),
)); ));
let _flush_handle = pipeline.clone().start_flush_loop( let _flush_handle = pipeline.clone().start_flush_loop(
std::time::Duration::from_secs(config.audit_flush_interval_secs), std::time::Duration::from_secs(config.audit_flush_interval_secs),
); );
// F.4 demo entry: synthesize one notarize=true audit event at
// startup so the flush_loop has something to submit to QM. This
// is gated on BASCULE_DEMO_AUDIT=1 (off by default). It's the
// only way to exercise the bascule→QM CreateAnchor path until
// genesis lands the OIDC realm and real operator sessions can
// flow through the auth filter. Remove (or leave as a no-op
// default-off ship hatch) once OIDC works end-to-end.
if std::env::var("BASCULE_DEMO_AUDIT").as_deref() == Ok("1") {
use bascule_core::audit::{AuditEvent, ExecutionResult, ExecutionStatus, PolicyDecision};
use bascule_core::command::{ChangeClassification, CommandRecord};
use bascule_core::session::OperatorIdentity;
let demo_event = AuditEvent {
event_id: uuid::Uuid::new_v4(),
session_id: uuid::Uuid::new_v4(),
operator_identity: OperatorIdentity::Oidc {
issuer: "https://auth.guildhouse.dev/realms/ffc-hetzner-nur01".into(),
subject: "f4-demo".into(),
email: "f4-demo@guildhouse.dev".into(),
},
timestamp: chrono::Utc::now(),
command: CommandRecord {
verb: "demo".into(),
namespace: Some("bascule".into()),
resource_type: Some("audit_event".into()),
resource_name: Some("f4-demo".into()),
parameters: serde_json::json!({"phase": "F.4"}),
},
classification: ChangeClassification::Mutative,
policy_decision: PolicyDecision::allow_all_stub(),
execution_result: ExecutionResult {
status: ExecutionStatus::Success,
summary: "F.4 demo event — bascule-to-QM CreateAnchor integration proof".into(),
resources_affected: 0,
mutations_applied: 0,
},
target_resources: vec![],
target_profile_hash: None,
};
let pipeline_clone = pipeline.clone();
tokio::spawn(async move {
tracing::info!("BASCULE_DEMO_AUDIT=1 — submitting one synthetic audit event for F.4 demo");
pipeline_clone.submit(&demo_event, true).await;
});
}
pipeline pipeline
} else { } else {
// No PG — create a pipeline with a lazy pool (will error on submit) // No PG — create a pipeline with a lazy pool (will error on submit)
Arc::new(audit_pipeline::AuditPipeline::new( Arc::new(audit_pipeline::AuditPipeline::new(
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?, sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
config.audit_batch_size, config.audit_batch_size,
config.qm_endpoint.clone(),
config.cluster_id.clone(),
)) ))
}; };
@ -168,6 +221,23 @@ spec:
reaper_manager.run_reaper().await; reaper_manager.run_reaper().await;
}); });
// 11b. Spawn posture polling task for breach detection
let breach_manager = session_manager.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
let breach_response = governance_types::BreachResponse::ReducePosture {
target_level: governance_types::PostureLevel::Lockdown,
};
tracing::info!("Posture breach polling loop started (30s interval)");
loop {
interval.tick().await;
let level = server::read_posture_level().await;
breach_manager
.on_posture_change(level.to_wire(), &breach_response)
.await;
}
});
if let Some(cm) = &ceremony_manager { if let Some(cm) = &ceremony_manager {
let cm = cm.clone(); let cm = cm.clone();
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -98,7 +98,23 @@ impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for Bascu
}; };
match response { match response {
CeremonyResponse::Granted(grant) => { CeremonyResponse::Granted(mut grant) => {
// Derive ShellClass from the cluster's current posture level.
// Reads posture-current ConfigMap, maps level to PostureLevel,
// derives ShellClass, and stamps into the granted scope.
let posture_level = read_posture_level().await;
let shell_class = bascule_core::derive_shell_class(posture_level, None);
grant.granted_scope.shell_class = shell_class;
grant.granted_scope.posture_level_at_establishment =
Some(posture_level.to_wire());
tracing::info!(
ceremony_id = %grant.ceremony_id,
posture_level = ?posture_level,
shell_class = %shell_class,
"Session shell class derived at ceremony grant"
);
let session = self let session = self
.session_manager .session_manager
.create_session(&grant) .create_session(&grant)
@ -403,6 +419,11 @@ fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> Sessi
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(), pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
mutation_budget: proto.mutation_budget, mutation_budget: proto.mutation_budget,
can_delegate: proto.can_delegate, can_delegate: proto.can_delegate,
// ShellClass and delegation are server-derived, not client-requested.
// Set to defaults here; stamped by the ceremony grant path.
shell_class: bascule_core::ShellClass::default(),
posture_level_at_establishment: None,
delegation: bascule_core::DelegationScope::default(),
} }
} }
@ -497,3 +518,43 @@ fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::Global
can_view_topology: core.can_view_topology, can_view_topology: core.can_view_topology,
} }
} }
// --- Posture level reader ---
/// Read the cluster's current operational posture level from the
/// `posture-current` ConfigMap. Falls back to `PostureLevel::Lockdown`
/// (fail-closed) if the ConfigMap is missing or unreadable.
pub(crate) async fn read_posture_level() -> governance_types::PostureLevel {
use governance_types::PostureLevel;
let client = match kube::Client::try_default().await {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "kube client init failed for posture read");
return PostureLevel::Lockdown;
}
};
let namespace = std::env::var("ENFORCEMENT_NAMESPACE")
.unwrap_or_else(|_| "guildhouse-infra".into());
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::Api;
let api: Api<ConfigMap> = Api::namespaced(client, &namespace);
match api.get("posture-current").await {
Ok(cm) => {
let level_u8: u8 = cm
.data
.as_ref()
.and_then(|d| d.get("level"))
.and_then(|v| v.parse().ok())
.unwrap_or(1);
PostureLevel::from_wire(level_u8).unwrap_or(PostureLevel::Lockdown)
}
Err(e) => {
tracing::warn!(error = %e, "posture-current ConfigMap read failed");
PostureLevel::Lockdown
}
}
}

View file

@ -195,6 +195,77 @@ impl SessionManager {
} }
} }
/// Apply a breach action to an active session.
pub async fn apply_breach_action(&self, action: crate::breach::BreachAction) {
use crate::breach::BreachAction;
match action {
BreachAction::NoAction => {}
BreachAction::Downgrade {
session_id,
from,
to,
reason,
} => {
if let Some(mut session) = self.sessions.get_mut(&session_id) {
session.scope.shell_class = to;
tracing::warn!(
session_id = %session_id,
from = %from,
to = %to,
reason = %reason,
"Session shell class downgraded due to posture breach"
);
}
}
BreachAction::Terminate {
session_id,
reason,
} => {
tracing::error!(
session_id = %session_id,
reason = %reason,
"Session terminated due to posture breach"
);
self.end_session(&session_id).await;
}
}
}
/// Handle a posture change notification.
///
/// Iterates all active sessions and evaluates breach for each.
/// Called by the posture polling loop when the posture-current
/// ConfigMap reports a new level.
pub async fn on_posture_change(
&self,
new_level: u8,
breach_response: &governance_types::BreachResponse,
) {
use crate::breach::evaluate_breach;
use bascule_core::session::SessionState;
let actions: Vec<crate::breach::BreachAction> = self
.sessions
.iter()
.filter(|entry| entry.value().state == SessionState::Active)
.map(|entry| {
let session = entry.value();
evaluate_breach(
*entry.key(),
session.scope.posture_level_at_establishment.unwrap_or(0),
session.scope.shell_class,
new_level,
breach_response,
None,
)
})
.collect();
for action in actions {
self.apply_breach_action(action).await;
}
}
/// Create a default read-only scope for the given namespaces. /// Create a default read-only scope for the given namespaces.
pub fn default_read_scope(namespaces: &[String]) -> SessionScope { pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
use bascule_core::scope::{ use bascule_core::scope::{
@ -221,6 +292,9 @@ impl SessionManager {
pathways: vec![ChangePathway::DryRunOnly], pathways: vec![ChangePathway::DryRunOnly],
mutation_budget: Some(0), mutation_budget: Some(0),
can_delegate: false, can_delegate: false,
shell_class: bascule_core::ShellClass::default(),
posture_level_at_establishment: None,
delegation: bascule_core::DelegationScope::default(),
} }
} }
} }