Bascule shell runtime workspace — governed shell access layer for Substrate/Guildhouse FFC deployments. Crates: - bascule-agent: node agent with SSH server + command filtering - bascule-core: audit, grant engine, ceremony types, session - bascule-filter-core: log line filtering (stdio protocol) - bascule-gateway: OIDC auth, session management, SAT validation - bascule-node-agent: k8s DaemonSet agent (pod watcher, BPF manager) - bascule-proto: protobuf definitions - bascule-shell: governed SSH shell (commands, elevation, REPL) - bascule-tail: chronicle log tail + fanout - ceremony-engine: ceremony lifecycle (6 types + request/resolution) 172 tests passing. Implements SBS-SPEC-0001 shell model. Reference impl for SPEC-SHELLOPS-0001 Layer 1 (root shell).
214 lines
7 KiB
Rust
214 lines
7 KiB
Rust
//! Ceremony resolution proof.
|
|
//!
|
|
//! When a governance ceremony resolves (approved or denied), a
|
|
//! [`CeremonyResolution`] is produced as an immutable record suitable
|
|
//! for merkle anchoring. This is the artifact that gets notarized.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::request::{
|
|
CeremonyApproval, CeremonySubject, GovernanceCeremonyStatus,
|
|
};
|
|
|
|
/// An immutable resolution record for a governance ceremony.
|
|
///
|
|
/// Produced when the ceremony exits the `Pending` state. Contains
|
|
/// enough information to audit who approved/denied and what subject
|
|
/// was at stake, without reference to mutable ceremony state.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CeremonyResolution {
|
|
pub ceremony_id: String,
|
|
pub status: GovernanceCeremonyStatus,
|
|
pub subject: CeremonySubject,
|
|
pub approvals: Vec<CeremonyApproval>,
|
|
pub resolved_at: DateTime<Utc>,
|
|
pub proof_hash: String,
|
|
}
|
|
|
|
/// Canonical form for hashing (alphabetical field order via JCS).
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CanonicalResolution {
|
|
pub approvals: Vec<CanonicalApproval>,
|
|
pub ceremony_id: String,
|
|
pub resolved_at: DateTime<Utc>,
|
|
pub status: GovernanceCeremonyStatus,
|
|
pub subject: CeremonySubject,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct CanonicalApproval {
|
|
pub approver_identity: String,
|
|
pub approver_role: String,
|
|
pub decided_at: DateTime<Utc>,
|
|
pub decision: String,
|
|
}
|
|
|
|
impl CeremonyResolution {
|
|
/// Build a resolution from a resolved ceremony.
|
|
pub fn from_ceremony(
|
|
ceremony_id: &str,
|
|
status: GovernanceCeremonyStatus,
|
|
subject: &CeremonySubject,
|
|
approvals: &[CeremonyApproval],
|
|
) -> Self {
|
|
let now = Utc::now();
|
|
|
|
let canonical = CanonicalResolution {
|
|
approvals: approvals
|
|
.iter()
|
|
.map(|a| CanonicalApproval {
|
|
approver_identity: a.approver_identity.clone(),
|
|
approver_role: a.approver_role.clone(),
|
|
decided_at: a.decided_at,
|
|
decision: format!("{:?}", a.decision).to_lowercase(),
|
|
})
|
|
.collect(),
|
|
ceremony_id: ceremony_id.to_string(),
|
|
resolved_at: now,
|
|
status,
|
|
subject: subject.clone(),
|
|
};
|
|
|
|
let value = serde_json::to_value(&canonical)
|
|
.expect("CanonicalResolution is always JSON-serializable");
|
|
let mut buf = Vec::new();
|
|
serde_json_canonicalizer::to_writer(&value, &mut buf)
|
|
.expect("JCS canonicalization should not fail");
|
|
let hash = Sha256::digest(&buf);
|
|
|
|
CeremonyResolution {
|
|
ceremony_id: ceremony_id.to_string(),
|
|
status,
|
|
subject: subject.clone(),
|
|
approvals: approvals.to_vec(),
|
|
resolved_at: now,
|
|
proof_hash: hex::encode(hash),
|
|
}
|
|
}
|
|
|
|
/// Verify the proof hash matches the resolution content.
|
|
pub fn verify_proof(&self) -> bool {
|
|
let canonical = CanonicalResolution {
|
|
approvals: self
|
|
.approvals
|
|
.iter()
|
|
.map(|a| CanonicalApproval {
|
|
approver_identity: a.approver_identity.clone(),
|
|
approver_role: a.approver_role.clone(),
|
|
decided_at: a.decided_at,
|
|
decision: format!("{:?}", a.decision).to_lowercase(),
|
|
})
|
|
.collect(),
|
|
ceremony_id: self.ceremony_id.clone(),
|
|
resolved_at: self.resolved_at,
|
|
status: self.status,
|
|
subject: self.subject.clone(),
|
|
};
|
|
|
|
let value = serde_json::to_value(&canonical).unwrap_or_default();
|
|
let mut buf = Vec::new();
|
|
if serde_json_canonicalizer::to_writer(&value, &mut buf).is_err() {
|
|
return false;
|
|
}
|
|
let hash = Sha256::digest(&buf);
|
|
hex::encode(hash) == self.proof_hash
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::request::{ApprovalDecision, CeremonyApproval, CeremonySubject};
|
|
|
|
fn sample_approvals() -> Vec<CeremonyApproval> {
|
|
vec![CeremonyApproval {
|
|
approver_identity: "alice@ops".to_string(),
|
|
approver_role: "msp-ops".to_string(),
|
|
decision: ApprovalDecision::Approve,
|
|
comment: Some("LGTM".to_string()),
|
|
decided_at: chrono::DateTime::parse_from_rfc3339("2025-06-01T12:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc),
|
|
}]
|
|
}
|
|
|
|
fn sample_subject() -> CeremonySubject {
|
|
CeremonySubject::PipelineMerge {
|
|
run_id: "run-1".to_string(),
|
|
pipeline_name: "deploy".to_string(),
|
|
branch: "main".to_string(),
|
|
commit_hash: "abc123".to_string(),
|
|
remote_name: "origin".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_has_proof_hash() {
|
|
let res = CeremonyResolution::from_ceremony(
|
|
"cer-001",
|
|
GovernanceCeremonyStatus::Approved,
|
|
&sample_subject(),
|
|
&sample_approvals(),
|
|
);
|
|
assert!(!res.proof_hash.is_empty());
|
|
assert_eq!(res.proof_hash.len(), 64); // SHA-256 hex
|
|
}
|
|
|
|
#[test]
|
|
fn proof_hash_verifies() {
|
|
let res = CeremonyResolution::from_ceremony(
|
|
"cer-001",
|
|
GovernanceCeremonyStatus::Approved,
|
|
&sample_subject(),
|
|
&sample_approvals(),
|
|
);
|
|
assert!(res.verify_proof());
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_resolution_fails_verification() {
|
|
let mut res = CeremonyResolution::from_ceremony(
|
|
"cer-001",
|
|
GovernanceCeremonyStatus::Approved,
|
|
&sample_subject(),
|
|
&sample_approvals(),
|
|
);
|
|
res.ceremony_id = "cer-999".to_string(); // tamper
|
|
assert!(!res.verify_proof());
|
|
}
|
|
|
|
#[test]
|
|
fn denied_resolution() {
|
|
let denials = vec![CeremonyApproval {
|
|
approver_identity: "bob@ops".to_string(),
|
|
approver_role: "msp-ops".to_string(),
|
|
decision: ApprovalDecision::Deny,
|
|
comment: Some("unacceptable risk".to_string()),
|
|
decided_at: Utc::now(),
|
|
}];
|
|
let res = CeremonyResolution::from_ceremony(
|
|
"cer-002",
|
|
GovernanceCeremonyStatus::Denied,
|
|
&sample_subject(),
|
|
&denials,
|
|
);
|
|
assert_eq!(res.status, GovernanceCeremonyStatus::Denied);
|
|
assert!(res.verify_proof());
|
|
}
|
|
|
|
#[test]
|
|
fn serialization_round_trip() {
|
|
let res = CeremonyResolution::from_ceremony(
|
|
"cer-003",
|
|
GovernanceCeremonyStatus::Approved,
|
|
&sample_subject(),
|
|
&sample_approvals(),
|
|
);
|
|
let json = serde_json::to_string(&res).unwrap();
|
|
let parsed: CeremonyResolution = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.ceremony_id, res.ceremony_id);
|
|
assert_eq!(parsed.proof_hash, res.proof_hash);
|
|
}
|
|
}
|