initial: bascule v0.1.0
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).
This commit is contained in:
commit
b1865a0627
131 changed files with 23034 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/target/
|
||||
**/target/
|
||||
**/*.rs.bk
|
||||
.env
|
||||
*.swp
|
||||
*.swo
|
||||
5706
Cargo.lock
generated
Normal file
5706
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
86
Cargo.toml
Normal file
86
Cargo.toml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"bascule-proto",
|
||||
"bascule-filter-core",
|
||||
"bascule-node-agent",
|
||||
"bascule-shell",
|
||||
"bascule-tail",
|
||||
"bascule-agent",
|
||||
"bascule-core",
|
||||
"bascule-gateway",
|
||||
"ceremony-engine",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json_canonicalizer = "0.3"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# gRPC
|
||||
tonic = "0.12"
|
||||
tonic-build = "0.12"
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
|
||||
# Kubernetes
|
||||
kube = { version = "0.98", features = ["runtime", "derive"] }
|
||||
k8s-openapi = { version = "0.24", features = ["latest"] }
|
||||
schemars = "0.8"
|
||||
|
||||
# TLS
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
|
||||
# Crypto
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
|
||||
# Observability
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Common
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
dashmap = "6"
|
||||
regex = "1"
|
||||
which = "7"
|
||||
dirs = "6"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
jsonwebtoken = "9"
|
||||
|
||||
# SSH
|
||||
russh = "0.49"
|
||||
russh-keys = "0.49"
|
||||
ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"tls-native-tls",
|
||||
"postgres",
|
||||
] }
|
||||
|
||||
# HTTP
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
config = "0.14"
|
||||
tokio-stream = "0.1"
|
||||
tempfile = "3"
|
||||
|
||||
# Internal crate deps
|
||||
bascule-filter-core = { path = "./bascule-filter-core" }
|
||||
bascule-core = { path = "./bascule-core" }
|
||||
bascule-proto = { path = "./bascule-proto" }
|
||||
ceremony-engine = { path = "./ceremony-engine" }
|
||||
54
bascule-agent/Cargo.toml
Normal file
54
bascule-agent/Cargo.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
[package]
|
||||
name = "bascule-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sb"
|
||||
path = "src/bin/sb.rs"
|
||||
|
||||
[dependencies]
|
||||
bascule-core = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Cross-workspace path deps — substrate crates
|
||||
substrate-rt = { path = "../../substrate/crates/substrate-rt" }
|
||||
hfl-types = { path = "../../substrate/crates/hfl-types", features = ["serde", "agent-extensions"] }
|
||||
|
||||
# Msgpack — retained for convenience constructors and legacy decode paths
|
||||
rmp-serde = "1"
|
||||
rmpv = { version = "1", features = ["with-serde"] }
|
||||
|
||||
# Config file parsing
|
||||
toml = "0.8"
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
hex = { workspace = true }
|
||||
|
||||
# SSH server
|
||||
russh = { workspace = true }
|
||||
russh-keys = { workspace = true }
|
||||
ssh-key = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
33
bascule-agent/Dockerfile
Normal file
33
bascule-agent/Dockerfile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Multi-stage build for bascule-agent
|
||||
# Stage 1: Build
|
||||
FROM rust:latest AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cd services && cargo build --release -p bascule-agent
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create substrate user and directories
|
||||
RUN groupadd -r substrate && useradd -r -g substrate substrate && \
|
||||
mkdir -p /var/run/substrate /etc/substrate && \
|
||||
chown substrate:substrate /var/run/substrate
|
||||
|
||||
COPY --from=builder /build/services/target/release/bascule-agent /usr/local/bin/bascule-agent
|
||||
|
||||
# Default config
|
||||
COPY services/bascule-agent/tests/e2e-config.toml /etc/substrate/shell.toml
|
||||
|
||||
USER substrate
|
||||
|
||||
EXPOSE 2222
|
||||
|
||||
ENTRYPOINT ["bascule-agent"]
|
||||
CMD ["--config", "/etc/substrate/shell.toml"]
|
||||
93
bascule-agent/src/bin/sb.rs
Normal file
93
bascule-agent/src/bin/sb.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
//! sb — Substrate CLI
|
||||
//!
|
||||
//! Currently supports:
|
||||
//! sb shell [--host HOST] [--port PORT] [--user USER]
|
||||
//!
|
||||
//! Connects to the bascule-agent's governed SSH shell.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sb", about = "Substrate CLI", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: SubCmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SubCmd {
|
||||
/// Connect to the bascule-agent governed shell via SSH.
|
||||
Shell {
|
||||
/// SSH host to connect to.
|
||||
#[arg(long, default_value = "localhost")]
|
||||
host: String,
|
||||
|
||||
/// SSH port.
|
||||
#[arg(short, long, default_value = "2222")]
|
||||
port: u16,
|
||||
|
||||
/// SSH username.
|
||||
#[arg(short, long, default_value_t = whoami())]
|
||||
user: String,
|
||||
|
||||
/// SSH identity file (private key).
|
||||
#[arg(short, long)]
|
||||
identity: Option<String>,
|
||||
},
|
||||
|
||||
/// Show agent status via IPC socket.
|
||||
Status {
|
||||
/// Path to the agent Unix socket.
|
||||
#[arg(long, default_value = "/var/run/substrate/agent.sock")]
|
||||
socket: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn whoami() -> String {
|
||||
std::env::var("USER").unwrap_or_else(|_| "substrate".to_string())
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
SubCmd::Shell {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
identity,
|
||||
} => {
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.arg("-p").arg(port.to_string());
|
||||
cmd.arg("-o").arg("StrictHostKeyChecking=no");
|
||||
cmd.arg("-o").arg("UserKnownHostsFile=/dev/null");
|
||||
cmd.arg("-o").arg("LogLevel=ERROR");
|
||||
|
||||
if let Some(ref key) = identity {
|
||||
cmd.arg("-i").arg(key);
|
||||
}
|
||||
|
||||
cmd.arg(format!("{user}@{host}"));
|
||||
|
||||
eprintln!("Connecting to bascule-agent shell at {host}:{port}...");
|
||||
|
||||
let status = cmd.status()?;
|
||||
if !status.success() {
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
}
|
||||
|
||||
SubCmd::Status { socket } => {
|
||||
if std::path::Path::new(&socket).exists() {
|
||||
println!("Agent socket: {} (active)", socket);
|
||||
} else {
|
||||
println!("Agent socket: {} (not found)", socket);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
206
bascule-agent/src/command_filter.rs
Normal file
206
bascule-agent/src/command_filter.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
//! Command filter — restricts shell commands based on scope/trust tier.
|
||||
//!
|
||||
//! The filter defines which namespace commands are accessible at each
|
||||
//! trust tier. In soft/dev mode, all commands are allowed. In live mode,
|
||||
//! the filter enforces the trust tier's capability set.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Trust tier for an authenticated operator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TrustTier {
|
||||
Apprentice,
|
||||
Journeyman,
|
||||
Master,
|
||||
SiteOwner,
|
||||
}
|
||||
|
||||
impl TrustTier {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"apprentice" => Some(Self::Apprentice),
|
||||
"journeyman" => Some(Self::Journeyman),
|
||||
"master" => Some(Self::Master),
|
||||
"site_owner" | "siteowner" | "owner" => Some(Self::SiteOwner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Apprentice => "apprentice",
|
||||
Self::Journeyman => "journeyman",
|
||||
Self::Master => "master",
|
||||
Self::SiteOwner => "site_owner",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability flags.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Capability {
|
||||
Read,
|
||||
Propose,
|
||||
Mutate,
|
||||
}
|
||||
|
||||
/// Command filter that maps trust tiers to allowed commands.
|
||||
pub struct CommandFilter {
|
||||
dev_mode: bool,
|
||||
/// Commands allowed for each tier (namespace::function format).
|
||||
tier_allowlists: Vec<(TrustTier, HashSet<String>)>,
|
||||
}
|
||||
|
||||
impl CommandFilter {
|
||||
pub fn new(dev_mode: bool) -> Self {
|
||||
let mut filter = Self {
|
||||
dev_mode,
|
||||
tier_allowlists: Vec::new(),
|
||||
};
|
||||
|
||||
if !dev_mode {
|
||||
filter.build_default_allowlists();
|
||||
}
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
fn build_default_allowlists(&mut self) {
|
||||
// Apprentice: READ only
|
||||
let mut apprentice = HashSet::new();
|
||||
apprentice.insert("attestation::posture".into());
|
||||
apprentice.insert("audit::query".into());
|
||||
apprentice.insert("identity::authenticate".into());
|
||||
apprentice.insert("identity::resolve".into());
|
||||
apprentice.insert("help".into());
|
||||
apprentice.insert("status".into());
|
||||
apprentice.insert("whoami".into());
|
||||
|
||||
// Journeyman: READ + PROPOSE
|
||||
let mut journeyman = apprentice.clone();
|
||||
journeyman.insert("governance::gate".into());
|
||||
journeyman.insert("governance::propose".into());
|
||||
journeyman.insert("audit::emit".into());
|
||||
|
||||
// Master: READ + PROPOSE + MUTATE (via approval)
|
||||
let mut master = journeyman.clone();
|
||||
master.insert("crypto::sign".into());
|
||||
master.insert("crypto::verify".into());
|
||||
master.insert("crypto::hash".into());
|
||||
master.insert("secrets::get".into());
|
||||
master.insert("governance::evaluate".into());
|
||||
master.insert("audit::anchor".into());
|
||||
master.insert("attestation::sat".into());
|
||||
|
||||
// Site Owner: everything
|
||||
let mut site_owner = master.clone();
|
||||
site_owner.insert("secrets::put".into());
|
||||
site_owner.insert("secrets::rotate".into());
|
||||
site_owner.insert("identity::authorize".into());
|
||||
site_owner.insert("attestation::verify".into());
|
||||
|
||||
self.tier_allowlists = vec![
|
||||
(TrustTier::Apprentice, apprentice),
|
||||
(TrustTier::Journeyman, journeyman),
|
||||
(TrustTier::Master, master),
|
||||
(TrustTier::SiteOwner, site_owner),
|
||||
];
|
||||
}
|
||||
|
||||
/// Check if a command is allowed for the given trust tier.
|
||||
pub fn is_allowed(&self, command: &str, tier: TrustTier) -> bool {
|
||||
if self.dev_mode {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shell builtins always allowed
|
||||
if matches!(command, "help" | "status" | "whoami" | "exit" | "quit") {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (t, allowlist) in &self.tier_allowlists {
|
||||
if *t == tier {
|
||||
return allowlist.contains(command);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get capabilities for a trust tier.
|
||||
pub fn capabilities(tier: TrustTier) -> Vec<Capability> {
|
||||
match tier {
|
||||
TrustTier::Apprentice => vec![Capability::Read],
|
||||
TrustTier::Journeyman => vec![Capability::Read, Capability::Propose],
|
||||
TrustTier::Master => vec![Capability::Read, Capability::Propose, Capability::Mutate],
|
||||
TrustTier::SiteOwner => vec![Capability::Read, Capability::Propose, Capability::Mutate],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dev_mode_allows_all() {
|
||||
let filter = CommandFilter::new(true);
|
||||
assert!(filter.is_allowed("secrets::put", TrustTier::Apprentice));
|
||||
assert!(filter.is_allowed("anything", TrustTier::Apprentice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_mode_apprentice_restricted() {
|
||||
let filter = CommandFilter::new(false);
|
||||
assert!(filter.is_allowed("attestation::posture", TrustTier::Apprentice));
|
||||
assert!(filter.is_allowed("help", TrustTier::Apprentice));
|
||||
assert!(!filter.is_allowed("secrets::get", TrustTier::Apprentice));
|
||||
assert!(!filter.is_allowed("crypto::sign", TrustTier::Apprentice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_mode_master_has_crypto() {
|
||||
let filter = CommandFilter::new(false);
|
||||
assert!(filter.is_allowed("crypto::sign", TrustTier::Master));
|
||||
assert!(filter.is_allowed("secrets::get", TrustTier::Master));
|
||||
assert!(!filter.is_allowed("secrets::put", TrustTier::Master));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_mode_site_owner_has_all() {
|
||||
let filter = CommandFilter::new(false);
|
||||
assert!(filter.is_allowed("secrets::put", TrustTier::SiteOwner));
|
||||
assert!(filter.is_allowed("secrets::rotate", TrustTier::SiteOwner));
|
||||
assert!(filter.is_allowed("identity::authorize", TrustTier::SiteOwner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtins_always_allowed() {
|
||||
let filter = CommandFilter::new(false);
|
||||
for tier in [TrustTier::Apprentice, TrustTier::Journeyman, TrustTier::Master, TrustTier::SiteOwner] {
|
||||
assert!(filter.is_allowed("help", tier));
|
||||
assert!(filter.is_allowed("status", tier));
|
||||
assert!(filter.is_allowed("whoami", tier));
|
||||
assert!(filter.is_allowed("exit", tier));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_tier_from_str() {
|
||||
assert_eq!(TrustTier::from_str("apprentice"), Some(TrustTier::Apprentice));
|
||||
assert_eq!(TrustTier::from_str("MASTER"), Some(TrustTier::Master));
|
||||
assert_eq!(TrustTier::from_str("site_owner"), Some(TrustTier::SiteOwner));
|
||||
assert_eq!(TrustTier::from_str("owner"), Some(TrustTier::SiteOwner));
|
||||
assert_eq!(TrustTier::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tier_capabilities() {
|
||||
let caps = CommandFilter::capabilities(TrustTier::Apprentice);
|
||||
assert_eq!(caps.len(), 1);
|
||||
assert!(caps.contains(&Capability::Read));
|
||||
|
||||
let caps = CommandFilter::capabilities(TrustTier::Master);
|
||||
assert_eq!(caps.len(), 3);
|
||||
}
|
||||
}
|
||||
456
bascule-agent/src/config.rs
Normal file
456
bascule-agent/src/config.rs
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
//! Configuration for bascule-agent, loaded from shell.toml.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
#[serde(default)]
|
||||
pub agent: AgentSection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentSection {
|
||||
#[serde(default = "default_socket_path")]
|
||||
pub socket_path: String,
|
||||
|
||||
#[serde(default = "default_socket_permissions")]
|
||||
pub socket_permissions: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub ssh: SshConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub identity: IdentityConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub audit: AuditConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub namespaces: NamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub session: SessionConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
|
||||
#[serde(default = "default_ssh_listen")]
|
||||
pub listen_addr: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub host_key_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct IdentityConfig {
|
||||
#[serde(default)]
|
||||
pub keycloak_url: String,
|
||||
|
||||
#[serde(default = "default_realm")]
|
||||
pub realm: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub dev_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AuditConfig {
|
||||
#[serde(default = "default_audit_backend")]
|
||||
pub backend: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub log_path: Option<PathBuf>,
|
||||
|
||||
#[serde(default = "default_batch_size")]
|
||||
pub batch_size: usize,
|
||||
|
||||
#[serde(default = "default_flush_interval")]
|
||||
pub flush_interval_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NamespaceConfig {
|
||||
#[serde(default = "default_backend")]
|
||||
pub backend: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub identity: IdentityNamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub crypto: CryptoNamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub audit: AuditNamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub governance: GovernanceNamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub secrets: SecretsNamespaceConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub attestation: AttestationNamespaceConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct IdentityNamespaceConfig {
|
||||
#[serde(default)]
|
||||
pub keycloak_introspect: bool,
|
||||
|
||||
#[serde(default = "default_token_cache_ttl")]
|
||||
pub token_cache_ttl_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct CryptoNamespaceConfig {
|
||||
#[serde(default = "default_key_source")]
|
||||
pub key_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct AuditNamespaceConfig {
|
||||
#[serde(default = "default_merkle_algorithm")]
|
||||
pub merkle_algorithm: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct GovernanceNamespaceConfig {
|
||||
#[serde(default = "default_policy_engine")]
|
||||
pub policy_engine: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct SecretsNamespaceConfig {
|
||||
#[serde(default = "default_secrets_backend")]
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct AttestationNamespaceConfig {
|
||||
#[serde(default = "default_posture_source")]
|
||||
pub posture_source: String,
|
||||
|
||||
#[serde(default = "default_posture_level")]
|
||||
pub default_posture: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SessionConfig {
|
||||
#[serde(default = "default_scope")]
|
||||
pub default_scope: String,
|
||||
|
||||
#[serde(default = "default_ttl_hours")]
|
||||
pub default_ttl_hours: u64,
|
||||
|
||||
#[serde(default = "default_mutation_budget")]
|
||||
pub default_mutation_budget: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub motd_template: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// Default functions
|
||||
fn default_socket_path() -> String {
|
||||
"/var/run/substrate/agent.sock".into()
|
||||
}
|
||||
fn default_socket_permissions() -> String {
|
||||
"0660".into()
|
||||
}
|
||||
fn default_ssh_listen() -> String {
|
||||
"0.0.0.0:2222".into()
|
||||
}
|
||||
fn default_realm() -> String {
|
||||
"substrate".into()
|
||||
}
|
||||
fn default_audit_backend() -> String {
|
||||
"log".into()
|
||||
}
|
||||
fn default_batch_size() -> usize {
|
||||
100
|
||||
}
|
||||
fn default_flush_interval() -> u64 {
|
||||
1000
|
||||
}
|
||||
fn default_backend() -> String {
|
||||
"soft".into()
|
||||
}
|
||||
fn default_token_cache_ttl() -> u64 {
|
||||
300
|
||||
}
|
||||
fn default_key_source() -> String {
|
||||
"env".into()
|
||||
}
|
||||
fn default_merkle_algorithm() -> String {
|
||||
"sha256".into()
|
||||
}
|
||||
fn default_policy_engine() -> String {
|
||||
"scope".into()
|
||||
}
|
||||
fn default_secrets_backend() -> String {
|
||||
"env".into()
|
||||
}
|
||||
fn default_posture_source() -> String {
|
||||
"config".into()
|
||||
}
|
||||
fn default_posture_level() -> String {
|
||||
"standard".into()
|
||||
}
|
||||
fn default_scope() -> String {
|
||||
"operate".into()
|
||||
}
|
||||
fn default_ttl_hours() -> u64 {
|
||||
4
|
||||
}
|
||||
fn default_mutation_budget() -> u32 {
|
||||
50
|
||||
}
|
||||
|
||||
impl Default for AgentSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket_path: default_socket_path(),
|
||||
socket_permissions: default_socket_permissions(),
|
||||
ssh: SshConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
audit: AuditConfig::default(),
|
||||
namespaces: NamespaceConfig::default(),
|
||||
session: SessionConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SshConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
listen_addr: default_ssh_listen(),
|
||||
host_key_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdentityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keycloak_url: String::new(),
|
||||
realm: default_realm(),
|
||||
dev_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuditConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: default_audit_backend(),
|
||||
log_path: None,
|
||||
batch_size: default_batch_size(),
|
||||
flush_interval_ms: default_flush_interval(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NamespaceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: default_backend(),
|
||||
identity: Default::default(),
|
||||
crypto: Default::default(),
|
||||
audit: Default::default(),
|
||||
governance: Default::default(),
|
||||
secrets: Default::default(),
|
||||
attestation: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_scope: default_scope(),
|
||||
default_ttl_hours: default_ttl_hours(),
|
||||
default_mutation_budget: default_mutation_budget(),
|
||||
motd_template: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Load from a TOML file, substituting environment variables.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
// Simple env var substitution: ${VAR_NAME} or ${VAR_NAME:default}
|
||||
let expanded = expand_env_vars(&content);
|
||||
let config: AgentConfig = toml::from_str(&expanded)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Create with all defaults (for testing or dev mode).
|
||||
pub fn default_config() -> Self {
|
||||
Self {
|
||||
agent: AgentSection::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `${VAR}` and `${VAR:default}` patterns in a string.
|
||||
fn expand_env_vars(input: &str) -> String {
|
||||
let mut result = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '$' && chars.peek() == Some(&'{') {
|
||||
chars.next(); // consume '{'
|
||||
let mut var_expr = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == '}' {
|
||||
break;
|
||||
}
|
||||
var_expr.push(c);
|
||||
}
|
||||
// Split on ':' for default value
|
||||
if let Some((var_name, default)) = var_expr.split_once(':') {
|
||||
match std::env::var(var_name) {
|
||||
Ok(val) if !val.is_empty() => result.push_str(&val),
|
||||
_ => result.push_str(default),
|
||||
}
|
||||
} else {
|
||||
match std::env::var(&var_expr) {
|
||||
Ok(val) => result.push_str(&val),
|
||||
Err(_) => {} // empty string if not set
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = AgentConfig::default_config();
|
||||
assert_eq!(config.agent.socket_path, "/var/run/substrate/agent.sock");
|
||||
assert_eq!(config.agent.socket_permissions, "0660");
|
||||
assert!(!config.agent.ssh.enabled);
|
||||
assert_eq!(config.agent.ssh.listen_addr, "0.0.0.0:2222");
|
||||
assert_eq!(config.agent.identity.realm, "substrate");
|
||||
assert!(!config.agent.identity.dev_mode);
|
||||
assert_eq!(config.agent.audit.backend, "log");
|
||||
assert_eq!(config.agent.audit.batch_size, 100);
|
||||
assert_eq!(config.agent.namespaces.backend, "soft");
|
||||
assert_eq!(config.agent.session.default_scope, "operate");
|
||||
assert_eq!(config.agent.session.default_ttl_hours, 4);
|
||||
assert_eq!(config.agent.session.default_mutation_budget, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_toml() {
|
||||
let toml = r#"
|
||||
[agent]
|
||||
socket_path = "/tmp/test.sock"
|
||||
"#;
|
||||
let config: AgentConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.agent.socket_path, "/tmp/test.sock");
|
||||
// All other fields should have defaults
|
||||
assert_eq!(config.agent.audit.backend, "log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_toml() {
|
||||
let toml = r#"
|
||||
[agent]
|
||||
socket_path = "/var/run/substrate/agent.sock"
|
||||
socket_permissions = "0660"
|
||||
|
||||
[agent.ssh]
|
||||
enabled = true
|
||||
listen_addr = "0.0.0.0:2222"
|
||||
host_key_path = "/etc/substrate/ssh_host_ed25519_key"
|
||||
|
||||
[agent.identity]
|
||||
keycloak_url = "https://auth.guildhouse.dev"
|
||||
realm = "substrate"
|
||||
dev_mode = false
|
||||
|
||||
[agent.audit]
|
||||
backend = "log"
|
||||
log_path = "/var/log/substrate/audit.json"
|
||||
batch_size = 100
|
||||
flush_interval_ms = 1000
|
||||
|
||||
[agent.namespaces]
|
||||
backend = "soft"
|
||||
|
||||
[agent.namespaces.identity]
|
||||
keycloak_introspect = true
|
||||
token_cache_ttl_seconds = 300
|
||||
|
||||
[agent.namespaces.crypto]
|
||||
key_source = "env"
|
||||
|
||||
[agent.namespaces.governance]
|
||||
policy_engine = "scope"
|
||||
|
||||
[agent.session]
|
||||
default_scope = "operate"
|
||||
default_ttl_hours = 4
|
||||
default_mutation_budget = 50
|
||||
motd_template = "/etc/substrate/motd.template"
|
||||
"#;
|
||||
let config: AgentConfig = toml::from_str(toml).unwrap();
|
||||
assert!(config.agent.ssh.enabled);
|
||||
assert_eq!(
|
||||
config.agent.identity.keycloak_url,
|
||||
"https://auth.guildhouse.dev"
|
||||
);
|
||||
assert!(config.agent.namespaces.identity.keycloak_introspect);
|
||||
assert_eq!(
|
||||
config.agent.session.motd_template.unwrap().to_str().unwrap(),
|
||||
"/etc/substrate/motd.template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_var_expansion() {
|
||||
std::env::set_var("TEST_BASCULE_VAR", "hello");
|
||||
assert_eq!(expand_env_vars("${TEST_BASCULE_VAR}"), "hello");
|
||||
assert_eq!(expand_env_vars("${MISSING_VAR:fallback}"), "fallback");
|
||||
assert_eq!(expand_env_vars("no vars here"), "no vars here");
|
||||
assert_eq!(
|
||||
expand_env_vars("pre-${TEST_BASCULE_VAR}-post"),
|
||||
"pre-hello-post"
|
||||
);
|
||||
std::env::remove_var("TEST_BASCULE_VAR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[agent]
|
||||
socket_path = "/tmp/agent.sock"
|
||||
|
||||
[agent.identity]
|
||||
dev_mode = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = AgentConfig::load(&path).unwrap();
|
||||
assert_eq!(config.agent.socket_path, "/tmp/agent.sock");
|
||||
assert!(config.agent.identity.dev_mode);
|
||||
}
|
||||
}
|
||||
411
bascule-agent/src/governance_server.rs
Normal file
411
bascule-agent/src/governance_server.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
//! Governance IPC server — Unix socket serving Shellstream protocol.
|
||||
//!
|
||||
//! Django middleware, Celery workers, and co-located apps connect here.
|
||||
//! Each connection is bound to a session established on the first message.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::namespace::NamespaceRouter;
|
||||
use crate::session_store::SessionStore;
|
||||
use crate::shellstream::{Namespace, ShellstreamMessage, ShellstreamResponse, Status};
|
||||
|
||||
/// The governance IPC server.
|
||||
pub struct GovernanceServer {
|
||||
config: Arc<AgentConfig>,
|
||||
router: Arc<NamespaceRouter>,
|
||||
sessions: Arc<SessionStore>,
|
||||
}
|
||||
|
||||
impl GovernanceServer {
|
||||
pub fn new(
|
||||
config: Arc<AgentConfig>,
|
||||
router: Arc<NamespaceRouter>,
|
||||
sessions: Arc<SessionStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
router,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start listening on the Unix socket. Runs until cancelled.
|
||||
pub async fn serve(&self) -> anyhow::Result<()> {
|
||||
let socket_path = &self.config.agent.socket_path;
|
||||
|
||||
// Remove stale socket if it exists
|
||||
if Path::new(socket_path).exists() {
|
||||
std::fs::remove_file(socket_path)
|
||||
.with_context(|| format!("Failed to remove stale socket at {socket_path}"))?;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = Path::new(socket_path).parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("Failed to bind Unix socket at {socket_path}"))?;
|
||||
|
||||
// Set socket permissions (best effort)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = u32::from_str_radix(
|
||||
self.config
|
||||
.agent
|
||||
.socket_permissions
|
||||
.trim_start_matches('0'),
|
||||
8,
|
||||
)
|
||||
.unwrap_or(0o660);
|
||||
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(perms)).ok();
|
||||
}
|
||||
|
||||
info!(socket_path, "Governance IPC server listening");
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _addr)) => {
|
||||
let router = self.router.clone();
|
||||
let sessions = self.sessions.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, router, sessions).await {
|
||||
debug!(error = %e, "Connection closed");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "Failed to accept connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single client connection.
|
||||
async fn handle_connection(
|
||||
mut stream: UnixStream,
|
||||
router: Arc<NamespaceRouter>,
|
||||
sessions: Arc<SessionStore>,
|
||||
) -> anyhow::Result<()> {
|
||||
debug!("New IPC connection");
|
||||
|
||||
loop {
|
||||
// Read 4-byte big-endian length prefix
|
||||
let length = match read_length_prefix(&mut stream).await {
|
||||
Ok(0) => break, // clean disconnect
|
||||
Ok(len) => len,
|
||||
Err(e) => {
|
||||
// ConnectionReset / EOF = clean disconnect
|
||||
if e.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io_err| {
|
||||
io_err.kind() == std::io::ErrorKind::UnexpectedEof
|
||||
|| io_err.kind() == std::io::ErrorKind::ConnectionReset
|
||||
})
|
||||
{
|
||||
break;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Sanity check message size (max 16 MB)
|
||||
if length > 16 * 1024 * 1024 {
|
||||
warn!(length, "Message too large, dropping connection");
|
||||
break;
|
||||
}
|
||||
|
||||
// Read message body
|
||||
let mut body = vec![0u8; length as usize];
|
||||
stream
|
||||
.read_exact(&mut body)
|
||||
.await
|
||||
.context("Failed to read message body")?;
|
||||
|
||||
// Decode the Shellstream message
|
||||
let msg = match ShellstreamMessage::decode(&body) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Malformed message");
|
||||
let resp = ShellstreamResponse::error([0u8; 16], 0, &format!("Malformed: {e}"));
|
||||
write_response(&mut stream, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure session exists (auto-create on first message)
|
||||
sessions.ensure_session(msg.session_id);
|
||||
|
||||
// Validate nonce (replay protection)
|
||||
if !sessions.validate_nonce(msg.session_id, msg.nonce) {
|
||||
let resp = ShellstreamResponse::denied(msg.session_id, msg.nonce, "Replay detected");
|
||||
write_response(&mut stream, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if !sessions.check_rate_limit(msg.session_id) {
|
||||
let resp =
|
||||
ShellstreamResponse::denied(msg.session_id, msg.nonce, "Rate limit exceeded");
|
||||
write_response(&mut stream, &resp).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Route to namespace handler
|
||||
let response = match Namespace::from_u16(msg.namespace) {
|
||||
Some(ns) => router.handle(ns, msg.function, &msg.payload, &msg.session_id).await,
|
||||
None => ShellstreamResponse::error(
|
||||
msg.session_id,
|
||||
msg.nonce,
|
||||
&format!("Unknown namespace: {:#06x}", msg.namespace),
|
||||
),
|
||||
};
|
||||
|
||||
// Send response (echo session_id and nonce)
|
||||
let response = ShellstreamResponse {
|
||||
session_id: msg.session_id,
|
||||
nonce: msg.nonce,
|
||||
..response
|
||||
};
|
||||
write_response(&mut stream, &response).await?;
|
||||
}
|
||||
|
||||
debug!("IPC connection closed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read 4-byte big-endian length prefix.
|
||||
async fn read_length_prefix(stream: &mut UnixStream) -> anyhow::Result<u32> {
|
||||
let mut buf = [0u8; 4];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
Ok(u32::from_be_bytes(buf))
|
||||
}
|
||||
|
||||
/// Write a length-prefixed response.
|
||||
async fn write_response(
|
||||
stream: &mut UnixStream,
|
||||
response: &ShellstreamResponse,
|
||||
) -> anyhow::Result<()> {
|
||||
let encoded = response.encode();
|
||||
stream.write_all(&encoded).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::namespace::NamespaceRouter;
|
||||
use crate::session_store::SessionStore;
|
||||
use rmp_serde;
|
||||
use rmpv::Value;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
fn test_config(socket_path: &str) -> AgentConfig {
|
||||
let mut config = AgentConfig::default_config();
|
||||
config.agent.socket_path = socket_path.into();
|
||||
config
|
||||
}
|
||||
|
||||
/// Encode a ShellstreamMessage to wire format (length-prefixed msgpack).
|
||||
fn encode_request(namespace: u16, function: u16, session_id: [u8; 16], payload: &[u8], nonce: u64) -> Vec<u8> {
|
||||
let arr = Value::Array(vec![
|
||||
Value::from(namespace as u64),
|
||||
Value::from(function as u64),
|
||||
Value::Binary(session_id.to_vec()),
|
||||
Value::Binary(payload.to_vec()),
|
||||
Value::from(nonce),
|
||||
]);
|
||||
let body = rmp_serde::to_vec(&arr).unwrap();
|
||||
let len = body.len() as u32;
|
||||
let mut out = Vec::with_capacity(4 + body.len());
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a response from a stream.
|
||||
async fn read_response(stream: &mut UnixStream) -> ShellstreamResponse {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.unwrap();
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
let mut body = vec![0u8; len];
|
||||
stream.read_exact(&mut body).await.unwrap();
|
||||
|
||||
// Parse manually
|
||||
let value: rmpv::Value = rmp_serde::from_slice(&body).unwrap();
|
||||
let arr = value.as_array().unwrap();
|
||||
let status = arr[0].as_u64().unwrap() as u16;
|
||||
let session_id_bytes = match &arr[1] {
|
||||
Value::Binary(b) => b.clone(),
|
||||
_ => panic!("Expected binary session_id"),
|
||||
};
|
||||
let payload = match &arr[2] {
|
||||
Value::Binary(b) => b.clone(),
|
||||
_ => panic!("Expected binary payload"),
|
||||
};
|
||||
let nonce = arr[3].as_u64().unwrap();
|
||||
|
||||
let mut session_id = [0u8; 16];
|
||||
session_id.copy_from_slice(&session_id_bytes);
|
||||
|
||||
ShellstreamResponse {
|
||||
status,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_accepts_connection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sock = dir.path().join("agent.sock");
|
||||
let config = Arc::new(test_config(sock.to_str().unwrap()));
|
||||
let router = Arc::new(NamespaceRouter::new());
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
let server = GovernanceServer::new(config, router, sessions);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve().await });
|
||||
|
||||
// Wait for socket to appear
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).await.unwrap();
|
||||
// Send a valid audit emit request
|
||||
let payload = rmp_serde::to_vec(&serde_json::json!({"event_type": "test"})).unwrap();
|
||||
let data = encode_request(0x0006, 0x01, [0xAA; 16], &payload, 1);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.session_id, [0xAA; 16]);
|
||||
assert_eq!(resp.nonce, 1);
|
||||
// Will be an error since no handlers registered — that's fine for this test
|
||||
// The point is the server accepted the connection and responded
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_namespace_returns_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sock = dir.path().join("agent.sock");
|
||||
let config = Arc::new(test_config(sock.to_str().unwrap()));
|
||||
let router = Arc::new(NamespaceRouter::new());
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
let server = GovernanceServer::new(config, router, sessions);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).await.unwrap();
|
||||
let data = encode_request(0x00FF, 0x01, [0xBB; 16], &[0x80], 1);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.status, Status::Error as u16);
|
||||
assert_eq!(resp.nonce, 1);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nonce_replay_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sock = dir.path().join("agent.sock");
|
||||
let config = Arc::new(test_config(sock.to_str().unwrap()));
|
||||
let router = Arc::new(NamespaceRouter::new());
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
let server = GovernanceServer::new(config, router, sessions);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).await.unwrap();
|
||||
let sid = [0xCC; 16];
|
||||
|
||||
// First request with nonce=5 — should succeed
|
||||
let data = encode_request(0x0006, 0x01, sid, &[0x80], 5);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.nonce, 5);
|
||||
|
||||
// Replay same nonce=5 — should be denied
|
||||
let data = encode_request(0x0006, 0x01, sid, &[0x80], 5);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.status, Status::Denied as u16);
|
||||
|
||||
// Lower nonce=3 — should also be denied
|
||||
let data = encode_request(0x0006, 0x01, sid, &[0x80], 3);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.status, Status::Denied as u16);
|
||||
|
||||
// Higher nonce=6 — should succeed
|
||||
let data = encode_request(0x0006, 0x01, sid, &[0x80], 6);
|
||||
stream.write_all(&data).await.unwrap();
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.nonce, 6);
|
||||
// Not denied (will be error because no handler, but not DENIED)
|
||||
assert_ne!(resp.status, Status::Denied as u16);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_malformed_message() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sock = dir.path().join("agent.sock");
|
||||
let config = Arc::new(test_config(sock.to_str().unwrap()));
|
||||
let router = Arc::new(NamespaceRouter::new());
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
let server = GovernanceServer::new(config, router, sessions);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let mut stream = UnixStream::connect(&sock).await.unwrap();
|
||||
// Send garbage with valid length prefix
|
||||
let garbage = vec![0xFF, 0x01, 0x02, 0x03];
|
||||
let len = garbage.len() as u32;
|
||||
stream.write_all(&len.to_be_bytes()).await.unwrap();
|
||||
stream.write_all(&garbage).await.unwrap();
|
||||
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert_eq!(resp.status, Status::Error as u16);
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clean_disconnect() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sock = dir.path().join("agent.sock");
|
||||
let config = Arc::new(test_config(sock.to_str().unwrap()));
|
||||
let router = Arc::new(NamespaceRouter::new());
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
let server = GovernanceServer::new(config, router, sessions);
|
||||
|
||||
let handle = tokio::spawn(async move { server.serve().await });
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let stream = UnixStream::connect(&sock).await.unwrap();
|
||||
drop(stream); // Clean disconnect
|
||||
|
||||
// Server should still be running — connect again
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(20)).await;
|
||||
let _stream2 = UnixStream::connect(&sock).await.unwrap();
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
147
bascule-agent/src/main.rs
Normal file
147
bascule-agent/src/main.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//! bascule-agent — governed application sidecar.
|
||||
//!
|
||||
//! A single Rust process serving two interfaces over one governance engine:
|
||||
//!
|
||||
//! 1. **Governance IPC** — Unix socket at `/var/run/substrate/agent.sock`
|
||||
//! Django middleware, Celery workers, and co-located apps talk Shellstream here.
|
||||
//!
|
||||
//! 2. **Interactive Shell** — SSH server on port 2222 (optional)
|
||||
//! Bascule Gateway, `sb shell`, and Dashboard WebSocket connect here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use tracing::{error, info};
|
||||
|
||||
mod command_filter;
|
||||
mod config;
|
||||
mod governance_server;
|
||||
mod namespace;
|
||||
mod session_store;
|
||||
mod shellstream;
|
||||
mod ssh_server;
|
||||
|
||||
use governance_server::GovernanceServer;
|
||||
use namespace::NamespaceRouter;
|
||||
use session_store::SessionStore;
|
||||
use shellstream::Namespace;
|
||||
use ssh_server::AgentSSHServer;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bascule-agent", about = "Governed application sidecar")]
|
||||
struct Cli {
|
||||
/// Path to shell.toml configuration file.
|
||||
#[arg(long, default_value = "/etc/substrate/shell.toml")]
|
||||
config: PathBuf,
|
||||
|
||||
/// Run with default config (dev mode, no config file needed).
|
||||
#[arg(long)]
|
||||
dev: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.json()
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let config = if cli.dev {
|
||||
info!("Starting in dev mode with default configuration");
|
||||
config::AgentConfig::default_config()
|
||||
} else {
|
||||
config::AgentConfig::load(&cli.config)
|
||||
.with_context(|| format!("Failed to load config from {:?}", cli.config))?
|
||||
};
|
||||
|
||||
info!(
|
||||
socket_path = %config.agent.socket_path,
|
||||
ssh_enabled = config.agent.ssh.enabled,
|
||||
namespace_backend = %config.agent.namespaces.backend,
|
||||
"bascule-agent starting"
|
||||
);
|
||||
|
||||
let dev_mode = config.agent.namespaces.backend == "soft";
|
||||
let config = Arc::new(config);
|
||||
|
||||
// Build namespace router with all soft-mode handlers
|
||||
let mut router = NamespaceRouter::new();
|
||||
router.register(
|
||||
Namespace::Crypto,
|
||||
Arc::new(namespace::crypto::CryptoHandler::new()),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Identity,
|
||||
Arc::new(namespace::identity::IdentityHandler::new(dev_mode)),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Secrets,
|
||||
Arc::new(namespace::secrets::SecretsHandler::new()),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Governance,
|
||||
Arc::new(namespace::governance::GovernanceHandler::new(dev_mode)),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Attestation,
|
||||
Arc::new(namespace::attestation::AttestationHandler::new(
|
||||
config.agent.namespaces.attestation.default_posture.clone(),
|
||||
)),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Audit,
|
||||
Arc::new(namespace::audit::AuditHandler::new()),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Network,
|
||||
Arc::new(namespace::network::NetworkHandler::new()),
|
||||
);
|
||||
router.register(
|
||||
Namespace::Intelligence,
|
||||
Arc::new(namespace::intelligence::IntelligenceHandler::new()),
|
||||
);
|
||||
|
||||
let router = Arc::new(router);
|
||||
let sessions = Arc::new(SessionStore::new());
|
||||
|
||||
// Start governance IPC server
|
||||
let server = GovernanceServer::new(config.clone(), router.clone(), sessions);
|
||||
let ipc_handle = tokio::spawn(async move {
|
||||
if let Err(e) = server.serve().await {
|
||||
error!(error = %e, "Governance IPC server failed");
|
||||
}
|
||||
});
|
||||
|
||||
// Start SSH shell server (if enabled)
|
||||
let ssh_handle = if config.agent.ssh.enabled {
|
||||
let ssh_server = AgentSSHServer::new(config.clone(), router.clone())?;
|
||||
Some(tokio::spawn(async move {
|
||||
if let Err(e) = ssh_server.serve().await {
|
||||
error!(error = %e, "SSH shell server failed");
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
info!("SSH shell server disabled");
|
||||
None
|
||||
};
|
||||
|
||||
info!("bascule-agent ready");
|
||||
|
||||
// Wait for shutdown signal
|
||||
tokio::signal::ctrl_c().await?;
|
||||
info!("bascule-agent shutting down");
|
||||
ipc_handle.abort();
|
||||
if let Some(h) = ssh_handle {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
74
bascule-agent/src/namespace/attestation.rs
Normal file
74
bascule-agent/src/namespace/attestation.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! ATTESTATION namespace (0x0005) — posture level + soft SAT generation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::shellstream::{attestation, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct AttestationHandler {
|
||||
default_posture: String,
|
||||
}
|
||||
|
||||
impl AttestationHandler {
|
||||
pub fn new(default_posture: String) -> Self {
|
||||
Self { default_posture }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for AttestationHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
attestation::POSTURE => self.posture(session_id).await,
|
||||
attestation::SAT_BUNDLE => self.sat_bundle(session_id).await,
|
||||
attestation::ATTESTATION_VERIFY => self.verify(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown ATTESTATION function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AttestationHandler {
|
||||
async fn posture(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"level": self.default_posture,
|
||||
"source": "config",
|
||||
"timestamp": Utc::now().to_rfc3339(),
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn sat_bundle(&self, session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft SAT: a JSON bundle with session info (not cryptographically bound)
|
||||
let bundle_id = uuid::Uuid::new_v4().to_string();
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"bundle_id": bundle_id,
|
||||
"session_id": hex::encode(session_id),
|
||||
"posture": self.default_posture,
|
||||
"issued_at": Utc::now().to_rfc3339(),
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn verify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: always returns valid (no TPM to verify against)
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"valid": true,
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
111
bascule-agent/src/namespace/audit.rs
Normal file
111
bascule-agent/src/namespace/audit.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! AUDIT namespace (0x0006) — structured event emission + merkle anchoring.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::shellstream::{audit, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct AuditHandler;
|
||||
|
||||
impl AuditHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for AuditHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
audit::EMIT => self.emit(payload, session_id).await,
|
||||
audit::ANCHOR => self.anchor(payload, session_id).await,
|
||||
audit::QUERY => self.query(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown AUDIT function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuditHandler {
|
||||
async fn emit(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Compute merkle leaf hash of the event
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(session_id);
|
||||
hasher.update(payload);
|
||||
let hash = hasher.finalize();
|
||||
let merkle_leaf = hex::encode(&hash);
|
||||
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Log the audit event (structured JSON)
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
info!(
|
||||
event_id = %event_id,
|
||||
session_id = %hex::encode(session_id),
|
||||
merkle_leaf = %merkle_leaf,
|
||||
event = %payload_parsed,
|
||||
"audit.emit"
|
||||
);
|
||||
|
||||
let response_payload = rmp_serde::to_vec(&serde_json::json!({
|
||||
"event_id": event_id,
|
||||
"merkle_leaf": merkle_leaf,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
||||
ShellstreamResponse::ok(*session_id, 0, response_payload)
|
||||
}
|
||||
|
||||
async fn anchor(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let event_ids = payload_parsed
|
||||
.get("event_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Compute subtree root hash
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(payload);
|
||||
let root_hash = hex::encode(hasher.finalize());
|
||||
|
||||
info!(
|
||||
event_count = event_ids,
|
||||
root_hash = %root_hash,
|
||||
"audit.anchor"
|
||||
);
|
||||
|
||||
let response_payload = rmp_serde::to_vec(&serde_json::json!({
|
||||
"root_hash": root_hash,
|
||||
"event_count": event_ids,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
||||
ShellstreamResponse::ok(*session_id, 0, response_payload)
|
||||
}
|
||||
|
||||
async fn query(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Stub: return empty results
|
||||
let response_payload = rmp_serde::to_vec(&serde_json::json!({
|
||||
"events": [],
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
|
||||
ShellstreamResponse::ok(*session_id, 0, response_payload)
|
||||
}
|
||||
}
|
||||
127
bascule-agent/src/namespace/crypto.rs
Normal file
127
bascule-agent/src/namespace/crypto.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//! CRYPTO namespace (0x0001) — signing, verification, hashing.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::shellstream::{crypto, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct CryptoHandler;
|
||||
|
||||
impl CryptoHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for CryptoHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
crypto::SIGN => self.sign(payload, session_id).await,
|
||||
crypto::VERIFY => self.verify(payload, session_id).await,
|
||||
// ENCRYPT (canonical ID 6) — used for hash in soft mode
|
||||
crypto::ENCRYPT => self.hash(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown CRYPTO function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract bytes from an rmpv map field — handles both Binary and String values.
|
||||
fn extract_bytes_from_map(map: &rmpv::Value, key: &str) -> Vec<u8> {
|
||||
let key_val = rmpv::Value::String(key.into());
|
||||
if let rmpv::Value::Map(entries) = map {
|
||||
for (k, v) in entries {
|
||||
if k == &key_val {
|
||||
return match v {
|
||||
rmpv::Value::Binary(b) => b.clone(),
|
||||
rmpv::Value::String(s) => s.as_bytes().to_vec(),
|
||||
_ => vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Extract a UTF-8 string from an rmpv map field — handles Binary (as UTF-8), String, or missing.
|
||||
fn extract_string_from_map(map: &rmpv::Value, key: &str) -> String {
|
||||
let key_val = rmpv::Value::String(key.into());
|
||||
if let rmpv::Value::Map(entries) = map {
|
||||
for (k, v) in entries {
|
||||
if k == &key_val {
|
||||
return match v {
|
||||
rmpv::Value::String(s) => s.as_str().unwrap_or_default().to_string(),
|
||||
rmpv::Value::Binary(b) => String::from_utf8_lossy(b).to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
impl CryptoHandler {
|
||||
async fn sign(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: HMAC-SHA256 with a dev key.
|
||||
// Use rmpv::Value to preserve binary data from Python SDK.
|
||||
let payload_parsed: rmpv::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil);
|
||||
|
||||
let data = extract_bytes_from_map(&payload_parsed, "data");
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"soft-signing-key:");
|
||||
hasher.update(&data);
|
||||
let signature = hex::encode(hasher.finalize());
|
||||
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"signature": signature,
|
||||
"algorithm": "sha256-hmac-soft",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn verify(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let payload_parsed: rmpv::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(rmpv::Value::Nil);
|
||||
|
||||
let data = extract_bytes_from_map(&payload_parsed, "data");
|
||||
// Signature may arrive as String or Binary (Python SDK sends bytes)
|
||||
let sig = extract_string_from_map(&payload_parsed, "signature");
|
||||
|
||||
// Recompute
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"soft-signing-key:");
|
||||
hasher.update(&data);
|
||||
let expected = hex::encode(hasher.finalize());
|
||||
|
||||
let valid = sig == expected;
|
||||
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"valid": valid,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn hash(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let hash = hex::encode(Sha256::digest(payload));
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"hash": hash,
|
||||
"algorithm": "sha256",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
87
bascule-agent/src/namespace/governance.rs
Normal file
87
bascule-agent/src/namespace/governance.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! GOVERNANCE namespace (0x0004) — scope checking + ceremony proposals.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{governance, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct GovernanceHandler {
|
||||
dev_mode: bool,
|
||||
}
|
||||
|
||||
impl GovernanceHandler {
|
||||
pub fn new(dev_mode: bool) -> Self {
|
||||
Self { dev_mode }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for GovernanceHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
governance::GATE => self.gate(payload, session_id).await,
|
||||
governance::PROPOSE => self.propose(payload, session_id).await,
|
||||
governance::ATTENUATE => self.evaluate(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown GOVERNANCE function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GovernanceHandler {
|
||||
async fn gate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
tracing::debug!(subject, resource, action, "governance.gate");
|
||||
|
||||
if self.dev_mode {
|
||||
// Dev mode: allow everything
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"permitted": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
return ShellstreamResponse::ok(*session_id, 0, response);
|
||||
}
|
||||
|
||||
// TODO: Check SessionScope::permits() from bascule-core (Stage 3)
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"permitted": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn propose(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let proposal_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!(proposal_id, "governance.propose");
|
||||
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"proposal_id": proposal_id,
|
||||
"status": "created",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn evaluate(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"message": "evaluate not yet implemented",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
109
bascule-agent/src/namespace/identity.rs
Normal file
109
bascule-agent/src/namespace/identity.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! IDENTITY namespace (0x0002) — OIDC token validation + authorization.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{identity, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct IdentityHandler {
|
||||
dev_mode: bool,
|
||||
}
|
||||
|
||||
impl IdentityHandler {
|
||||
pub fn new(dev_mode: bool) -> Self {
|
||||
Self { dev_mode }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for IdentityHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
identity::AUTHENTICATE => self.authenticate(payload, session_id).await,
|
||||
identity::AUTHORIZE => self.authorize(payload, session_id).await,
|
||||
identity::WHOAMI => self.resolve(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown IDENTITY function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityHandler {
|
||||
async fn authenticate(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if self.dev_mode {
|
||||
// Dev mode: accept any token, return synthetic identity
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"subject": "dev-user",
|
||||
"email": "dev@guildhouse.local",
|
||||
"issuer": "dev-mode",
|
||||
"verified": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
return ShellstreamResponse::ok(*session_id, 0, response);
|
||||
}
|
||||
|
||||
// TODO: Validate JWT via OidcAuthProvider (Stage 3 — after moving to bascule-core)
|
||||
let token = payload_parsed
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if token.is_empty() {
|
||||
return ShellstreamResponse::denied(*session_id, 0, "No token provided");
|
||||
}
|
||||
|
||||
// Placeholder: accept token and return minimal claims
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"subject": "unknown",
|
||||
"verified": false,
|
||||
"message": "OIDC validation not yet implemented",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn authorize(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
if self.dev_mode {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"authorized": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
return ShellstreamResponse::ok(*session_id, 0, response);
|
||||
}
|
||||
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let subject = payload_parsed.get("subject").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let resource = payload_parsed.get("resource").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let action = payload_parsed.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
tracing::debug!(subject, resource, action, "identity.authorize");
|
||||
|
||||
// Default: allow in soft mode
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"authorized": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn resolve(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"message": "resolve not yet implemented",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
68
bascule-agent/src/namespace/intelligence.rs
Normal file
68
bascule-agent/src/namespace/intelligence.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! INTELLIGENCE namespace (0x0008) — soft-mode inference passthrough.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{intelligence, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct IntelligenceHandler;
|
||||
|
||||
impl IntelligenceHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for IntelligenceHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
intelligence::INFER => self.infer(payload, session_id).await,
|
||||
intelligence::EMBED => self.embed(payload, session_id).await,
|
||||
intelligence::INTELLIGENCE_CLASSIFY => self.classify(payload, session_id).await,
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown INTELLIGENCE function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntelligenceHandler {
|
||||
async fn infer(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: no local model — return stub indicating external provider needed
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"status": "not_available",
|
||||
"reason": "No local model loaded in soft mode. Use external provider directly.",
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn embed(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"status": "not_available",
|
||||
"reason": "Embedding requires HFL namespace with loaded model.",
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"status": "not_available",
|
||||
"reason": "Classification requires HFL namespace with loaded model.",
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
70
bascule-agent/src/namespace/mod.rs
Normal file
70
bascule-agent/src/namespace/mod.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! Namespace handler trait and router.
|
||||
//!
|
||||
//! Each HFL namespace has a soft-mode handler that implements the
|
||||
//! `NamespaceHandler` trait. The `NamespaceRouter` dispatches incoming
|
||||
//! Shellstream messages to the appropriate handler.
|
||||
|
||||
pub mod audit;
|
||||
pub mod attestation;
|
||||
pub mod crypto;
|
||||
pub mod governance;
|
||||
pub mod identity;
|
||||
pub mod intelligence;
|
||||
pub mod network;
|
||||
pub mod secrets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{Namespace, ShellstreamResponse};
|
||||
|
||||
/// Trait implemented by each namespace's soft-mode handler.
|
||||
#[async_trait]
|
||||
pub trait NamespaceHandler: Send + Sync {
|
||||
/// Handle a function call within this namespace.
|
||||
async fn handle(
|
||||
&self,
|
||||
function: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse;
|
||||
}
|
||||
|
||||
/// Routes namespace IDs to their handlers.
|
||||
pub struct NamespaceRouter {
|
||||
handlers: HashMap<u16, Arc<dyn NamespaceHandler>>,
|
||||
}
|
||||
|
||||
impl NamespaceRouter {
|
||||
/// Create an empty router (no handlers registered).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a handler for a namespace.
|
||||
pub fn register(&mut self, namespace: Namespace, handler: Arc<dyn NamespaceHandler>) {
|
||||
self.handlers.insert(namespace as u16, handler);
|
||||
}
|
||||
|
||||
/// Dispatch a request to the appropriate namespace handler.
|
||||
pub async fn handle(
|
||||
&self,
|
||||
namespace: Namespace,
|
||||
function: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match self.handlers.get(&(namespace as u16)) {
|
||||
Some(handler) => handler.handle(function, payload, session_id).await,
|
||||
None => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("No handler for namespace {}", namespace.name()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
76
bascule-agent/src/namespace/network.rs
Normal file
76
bascule-agent/src/namespace/network.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//! NETWORK namespace (0x0007) — soft-mode network classification.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{network, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct NetworkHandler;
|
||||
|
||||
impl NetworkHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for NetworkHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
network::CLASSIFY => self.classify(payload, session_id).await,
|
||||
network::ALLOW => self.allow(payload, session_id).await,
|
||||
network::DENY => self.deny(payload, session_id).await,
|
||||
network::SEGMENT_LIST => {
|
||||
tracing::debug!("SEGMENT_LIST not implemented in soft mode");
|
||||
ShellstreamResponse::error(*session_id, 0, "SEGMENT_LIST not implemented in soft mode")
|
||||
},
|
||||
network::REDIRECT => {
|
||||
tracing::debug!("REDIRECT not implemented in soft mode");
|
||||
ShellstreamResponse::error(*session_id, 0, "REDIRECT not implemented in soft mode")
|
||||
},
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown NETWORK function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkHandler {
|
||||
async fn classify(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: all traffic classified as "standard"
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"classification": "standard",
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn allow(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: always allowed (no eBPF enforcement)
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"allowed": true,
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
|
||||
async fn deny(&self, _payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
// Soft mode: log intent but no enforcement
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"denied": true,
|
||||
"enforced": false,
|
||||
"soft_mode": true,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
}
|
||||
71
bascule-agent/src/namespace/secrets.rs
Normal file
71
bascule-agent/src/namespace/secrets.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! SECRETS namespace (0x0003) — secret retrieval from env or Vault.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::shellstream::{secrets, ShellstreamResponse};
|
||||
use super::NamespaceHandler;
|
||||
|
||||
pub struct SecretsHandler;
|
||||
|
||||
impl SecretsHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NamespaceHandler for SecretsHandler {
|
||||
async fn handle(
|
||||
&self,
|
||||
function_id: u16,
|
||||
payload: &[u8],
|
||||
session_id: &[u8; 16],
|
||||
) -> ShellstreamResponse {
|
||||
match function_id {
|
||||
secrets::GET => self.get(payload, session_id).await,
|
||||
secrets::PUT => ShellstreamResponse::denied(*session_id, 0, "PUT not supported in soft mode"),
|
||||
secrets::ROTATE => ShellstreamResponse::denied(*session_id, 0, "ROTATE not supported in soft mode"),
|
||||
_ => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Unknown SECRETS function: {function_id:#06x}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretsHandler {
|
||||
async fn get(&self, payload: &[u8], session_id: &[u8; 16]) -> ShellstreamResponse {
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let path = payload_parsed
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if path.is_empty() {
|
||||
return ShellstreamResponse::error(*session_id, 0, "Missing 'path'");
|
||||
}
|
||||
|
||||
// Soft mode: look up environment variable
|
||||
// Convert path like "db/password" to env var "DB_PASSWORD"
|
||||
let env_key = path.replace('/', "_").to_uppercase();
|
||||
|
||||
match std::env::var(&env_key) {
|
||||
Ok(value) => {
|
||||
let response = rmp_serde::to_vec(&serde_json::json!({
|
||||
"value": value,
|
||||
"source": "env",
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
ShellstreamResponse::ok(*session_id, 0, response)
|
||||
}
|
||||
Err(_) => ShellstreamResponse::error(
|
||||
*session_id,
|
||||
0,
|
||||
&format!("Secret not found: {path} (env: {env_key})"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
152
bascule-agent/src/session_store.rs
Normal file
152
bascule-agent/src/session_store.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! Session tracking for IPC connections.
|
||||
//!
|
||||
//! Each Python SDK `SubstrateClient` instance has a UUID session_id.
|
||||
//! We track the highest nonce seen (replay protection) and rate limiting.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
/// Per-session state.
|
||||
pub struct SessionInfo {
|
||||
/// Highest nonce seen (monotonic — reject anything ≤ this).
|
||||
highest_nonce: AtomicU64,
|
||||
/// Rate limiting: request timestamps in a sliding window.
|
||||
request_times: std::sync::Mutex<Vec<Instant>>,
|
||||
}
|
||||
|
||||
impl SessionInfo {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
highest_nonce: AtomicU64::new(0),
|
||||
request_times: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Concurrent session store backed by DashMap.
|
||||
pub struct SessionStore {
|
||||
sessions: DashMap<[u8; 16], SessionInfo>,
|
||||
/// Max requests per second per session.
|
||||
rate_limit: u32,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
rate_limit: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_rate_limit(rate_limit: u32) -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
rate_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a session entry exists for this session_id.
|
||||
pub fn ensure_session(&self, session_id: [u8; 16]) {
|
||||
self.sessions
|
||||
.entry(session_id)
|
||||
.or_insert_with(SessionInfo::new);
|
||||
}
|
||||
|
||||
/// Validate nonce: must be strictly greater than the last seen nonce.
|
||||
/// Returns true if valid, false if replay/stale.
|
||||
pub fn validate_nonce(&self, session_id: [u8; 16], nonce: u64) -> bool {
|
||||
if let Some(session) = self.sessions.get(&session_id) {
|
||||
let prev = session.highest_nonce.load(Ordering::Acquire);
|
||||
if nonce <= prev {
|
||||
return false;
|
||||
}
|
||||
session.highest_nonce.store(nonce, Ordering::Release);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check rate limit for a session. Returns true if under limit.
|
||||
pub fn check_rate_limit(&self, session_id: [u8; 16]) -> bool {
|
||||
if let Some(session) = self.sessions.get(&session_id) {
|
||||
let mut times = session.request_times.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(1);
|
||||
|
||||
// Remove requests older than the window
|
||||
times.retain(|t| now.duration_since(*t) < window);
|
||||
|
||||
if times.len() >= self.rate_limit as usize {
|
||||
return false;
|
||||
}
|
||||
|
||||
times.push(now);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a session.
|
||||
pub fn remove_session(&self, session_id: [u8; 16]) {
|
||||
self.sessions.remove(&session_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_nonce_validation() {
|
||||
let store = SessionStore::new();
|
||||
let sid = [0x01; 16];
|
||||
store.ensure_session(sid);
|
||||
|
||||
// First nonce accepted
|
||||
assert!(store.validate_nonce(sid, 1));
|
||||
// Higher nonce accepted
|
||||
assert!(store.validate_nonce(sid, 5));
|
||||
// Same nonce rejected (replay)
|
||||
assert!(!store.validate_nonce(sid, 5));
|
||||
// Lower nonce rejected
|
||||
assert!(!store.validate_nonce(sid, 3));
|
||||
// Higher nonce accepted again
|
||||
assert!(store.validate_nonce(sid, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit() {
|
||||
let store = SessionStore::with_rate_limit(3);
|
||||
let sid = [0x02; 16];
|
||||
store.ensure_session(sid);
|
||||
|
||||
// First 3 should pass
|
||||
assert!(store.check_rate_limit(sid));
|
||||
assert!(store.check_rate_limit(sid));
|
||||
assert!(store.check_rate_limit(sid));
|
||||
// 4th should be rejected
|
||||
assert!(!store.check_rate_limit(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_not_found() {
|
||||
let store = SessionStore::new();
|
||||
let sid = [0x03; 16];
|
||||
// No session created — nonce validation fails
|
||||
assert!(!store.validate_nonce(sid, 1));
|
||||
assert!(!store.check_rate_limit(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_session_idempotent() {
|
||||
let store = SessionStore::new();
|
||||
let sid = [0x04; 16];
|
||||
store.ensure_session(sid);
|
||||
store.ensure_session(sid); // should not reset nonce
|
||||
assert!(store.validate_nonce(sid, 1));
|
||||
}
|
||||
}
|
||||
420
bascule-agent/src/shellstream.rs
Normal file
420
bascule-agent/src/shellstream.rs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
//! Shellstream wire protocol codec — msgpack over Unix domain socket.
|
||||
//!
|
||||
//! Wire format must match `substrate-sdk-python/substrate_sdk/protocol.py` byte-for-byte.
|
||||
//! Encoding/decoding delegates to `substrate_rt::ShellstreamCodec` (canonical implementation).
|
||||
//!
|
||||
//! Request: [4-byte BE length] [msgpack array: [namespace, function, session_id, payload, nonce]]
|
||||
//! Response: [4-byte BE length] [msgpack array: [status, session_id, payload, nonce]]
|
||||
|
||||
use serde::Deserialize;
|
||||
use substrate_rt::ShellstreamCodec;
|
||||
|
||||
/// HFL namespace identifiers (0x0001–0x0008).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum Namespace {
|
||||
Crypto = 0x0001,
|
||||
Identity = 0x0002,
|
||||
Secrets = 0x0003,
|
||||
Governance = 0x0004,
|
||||
Attestation = 0x0005,
|
||||
Audit = 0x0006,
|
||||
Network = 0x0007,
|
||||
Intelligence = 0x0008,
|
||||
}
|
||||
|
||||
impl Namespace {
|
||||
pub fn from_u16(v: u16) -> Option<Self> {
|
||||
match v {
|
||||
0x0001 => Some(Self::Crypto),
|
||||
0x0002 => Some(Self::Identity),
|
||||
0x0003 => Some(Self::Secrets),
|
||||
0x0004 => Some(Self::Governance),
|
||||
0x0005 => Some(Self::Attestation),
|
||||
0x0006 => Some(Self::Audit),
|
||||
0x0007 => Some(Self::Network),
|
||||
0x0008 => Some(Self::Intelligence),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Crypto => "CRYPTO",
|
||||
Self::Identity => "IDENTITY",
|
||||
Self::Secrets => "SECRETS",
|
||||
Self::Governance => "GOVERNANCE",
|
||||
Self::Attestation => "ATTESTATION",
|
||||
Self::Audit => "AUDIT",
|
||||
Self::Network => "NETWORK",
|
||||
Self::Intelligence => "INTELLIGENCE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical HFL function IDs — re-exported from hfl-types::ids (single source of truth).
|
||||
/// See hfl-types::ids for canonical constants. Intelligence namespace (0x0008) is a
|
||||
/// bascule-agent extension, gated behind the `agent-extensions` feature.
|
||||
pub use hfl_types::ids::{
|
||||
crypto, identity, secrets, governance,
|
||||
attestation, audit, network, intelligence,
|
||||
};
|
||||
|
||||
/// Response status codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u16)]
|
||||
pub enum Status {
|
||||
Ok = 0x00,
|
||||
Error = 0x01,
|
||||
Denied = 0x02,
|
||||
CeremonyRequired = 0x03,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn from_u16(v: u16) -> Option<Self> {
|
||||
match v {
|
||||
0x00 => Some(Self::Ok),
|
||||
0x01 => Some(Self::Error),
|
||||
0x02 => Some(Self::Denied),
|
||||
0x03 => Some(Self::CeremonyRequired),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Incoming request from Python SDK.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellstreamMessage {
|
||||
pub namespace: u16,
|
||||
pub function: u16,
|
||||
pub session_id: [u8; 16],
|
||||
pub payload: Vec<u8>,
|
||||
pub nonce: u64,
|
||||
}
|
||||
|
||||
/// Outgoing response to Python SDK.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellstreamResponse {
|
||||
pub status: u16,
|
||||
pub session_id: [u8; 16],
|
||||
pub payload: Vec<u8>,
|
||||
pub nonce: u64,
|
||||
}
|
||||
|
||||
impl ShellstreamMessage {
|
||||
/// Decode from msgpack body (without length prefix).
|
||||
pub fn decode(data: &[u8]) -> anyhow::Result<Self> {
|
||||
let (namespace, function, session_id, payload, nonce) =
|
||||
ShellstreamCodec::decode_request(data)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
Ok(Self {
|
||||
namespace,
|
||||
function,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellstreamResponse {
|
||||
/// Encode to wire format (length-prefixed msgpack).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
ShellstreamCodec::encode_response(self.status, &self.session_id, &self.payload, self.nonce)
|
||||
.expect("msgpack serialize")
|
||||
}
|
||||
|
||||
/// Create an OK response.
|
||||
pub fn ok(session_id: [u8; 16], nonce: u64, payload: Vec<u8>) -> Self {
|
||||
Self {
|
||||
status: Status::Ok as u16,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error response.
|
||||
pub fn error(session_id: [u8; 16], nonce: u64, message: &str) -> Self {
|
||||
let payload = rmp_serde::to_vec(&serde_json::json!({"message": message}))
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
status: Status::Error as u16,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a denied response.
|
||||
pub fn denied(session_id: [u8; 16], nonce: u64, message: &str) -> Self {
|
||||
let payload = rmp_serde::to_vec(&serde_json::json!({"message": message}))
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
status: Status::Denied as u16,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a ceremony-required response.
|
||||
pub fn ceremony_required(
|
||||
session_id: [u8; 16],
|
||||
nonce: u64,
|
||||
ceremony_id: &str,
|
||||
ceremony_type: &str,
|
||||
) -> Self {
|
||||
let payload = rmp_serde::to_vec(&serde_json::json!({
|
||||
"ceremony_id": ceremony_id,
|
||||
"ceremony_type": ceremony_type,
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
status: Status::CeremonyRequired as u16,
|
||||
session_id,
|
||||
payload,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestVectors {
|
||||
namespace_ids: std::collections::HashMap<String, u16>,
|
||||
status_codes: std::collections::HashMap<String, u16>,
|
||||
vectors: Vec<TestVector>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestVector {
|
||||
name: String,
|
||||
#[serde(rename = "type")]
|
||||
vec_type: String,
|
||||
#[serde(default)]
|
||||
namespace: Option<u16>,
|
||||
#[serde(default)]
|
||||
function: Option<u16>,
|
||||
#[serde(default)]
|
||||
status: Option<u16>,
|
||||
session_id_hex: String,
|
||||
payload_hex: String,
|
||||
nonce: u64,
|
||||
body_hex: String,
|
||||
full_encoded_hex: String,
|
||||
}
|
||||
|
||||
fn load_vectors() -> TestVectors {
|
||||
let data = include_str!("../tests/fixtures/shellstream_vectors.json");
|
||||
serde_json::from_str(data).expect("Failed to parse test vectors")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_namespace_ids_match_python() {
|
||||
let vectors = load_vectors();
|
||||
assert_eq!(vectors.namespace_ids["CRYPTO"], Namespace::Crypto as u16);
|
||||
assert_eq!(vectors.namespace_ids["IDENTITY"], Namespace::Identity as u16);
|
||||
assert_eq!(vectors.namespace_ids["SECRETS"], Namespace::Secrets as u16);
|
||||
assert_eq!(vectors.namespace_ids["GOVERNANCE"], Namespace::Governance as u16);
|
||||
assert_eq!(vectors.namespace_ids["ATTESTATION"], Namespace::Attestation as u16);
|
||||
assert_eq!(vectors.namespace_ids["AUDIT"], Namespace::Audit as u16);
|
||||
assert_eq!(vectors.namespace_ids["NETWORK"], Namespace::Network as u16);
|
||||
assert_eq!(vectors.namespace_ids["INTELLIGENCE"], Namespace::Intelligence as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_codes_match_python() {
|
||||
let vectors = load_vectors();
|
||||
assert_eq!(vectors.status_codes["OK"], Status::Ok as u16);
|
||||
assert_eq!(vectors.status_codes["ERROR"], Status::Error as u16);
|
||||
assert_eq!(vectors.status_codes["DENIED"], Status::Denied as u16);
|
||||
assert_eq!(vectors.status_codes["CEREMONY_REQUIRED"], Status::CeremonyRequired as u16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_all_request_vectors() {
|
||||
let vectors = load_vectors();
|
||||
for v in &vectors.vectors {
|
||||
if v.vec_type != "request" {
|
||||
continue;
|
||||
}
|
||||
let body = hex::decode(&v.body_hex).unwrap_or_else(|e| {
|
||||
panic!("Failed to decode hex for {}: {}", v.name, e)
|
||||
});
|
||||
let msg = ShellstreamMessage::decode(&body).unwrap_or_else(|e| {
|
||||
panic!("Failed to decode request '{}': {}", v.name, e)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
msg.namespace,
|
||||
v.namespace.unwrap(),
|
||||
"namespace mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(
|
||||
msg.function,
|
||||
v.function.unwrap(),
|
||||
"function mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(msg.session_id),
|
||||
v.session_id_hex,
|
||||
"session_id mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(&msg.payload),
|
||||
v.payload_hex,
|
||||
"payload mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(msg.nonce, v.nonce, "nonce mismatch for {}", v.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_response_vectors() {
|
||||
use substrate_rt::ShellstreamCodec;
|
||||
|
||||
let vectors = load_vectors();
|
||||
for v in &vectors.vectors {
|
||||
if v.vec_type != "response" {
|
||||
continue;
|
||||
}
|
||||
let body = hex::decode(&v.body_hex).unwrap();
|
||||
let (status, session_id, payload, nonce) =
|
||||
ShellstreamCodec::decode_response(&body).unwrap();
|
||||
|
||||
assert_eq!(status, v.status.unwrap(), "status mismatch for {}", v.name);
|
||||
assert_eq!(
|
||||
hex::encode(session_id),
|
||||
v.session_id_hex,
|
||||
"session_id mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(
|
||||
hex::encode(&payload),
|
||||
v.payload_hex,
|
||||
"payload mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
assert_eq!(nonce, v.nonce, "nonce mismatch for {}", v.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_response_matches_python() {
|
||||
let vectors = load_vectors();
|
||||
for v in &vectors.vectors {
|
||||
if v.vec_type != "response" {
|
||||
continue;
|
||||
}
|
||||
let mut session_id = [0u8; 16];
|
||||
let sid_bytes = hex::decode(&v.session_id_hex).unwrap();
|
||||
session_id.copy_from_slice(&sid_bytes);
|
||||
|
||||
let payload = hex::decode(&v.payload_hex).unwrap();
|
||||
let resp = ShellstreamResponse {
|
||||
status: v.status.unwrap(),
|
||||
session_id,
|
||||
payload,
|
||||
nonce: v.nonce,
|
||||
};
|
||||
|
||||
let encoded = resp.encode();
|
||||
assert_eq!(
|
||||
hex::encode(&encoded),
|
||||
v.full_encoded_hex,
|
||||
"encoded response mismatch for {}",
|
||||
v.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_namespace_roundtrip() {
|
||||
for ns_val in 1u16..=8 {
|
||||
let ns = Namespace::from_u16(ns_val).unwrap();
|
||||
assert_eq!(ns as u16, ns_val);
|
||||
}
|
||||
assert!(Namespace::from_u16(0).is_none());
|
||||
assert!(Namespace::from_u16(9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_roundtrip() {
|
||||
for s_val in 0u16..=3 {
|
||||
let s = Status::from_u16(s_val).unwrap();
|
||||
assert_eq!(s as u16, s_val);
|
||||
}
|
||||
assert!(Status::from_u16(4).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_empty_payload() {
|
||||
let vectors = load_vectors();
|
||||
let v = vectors
|
||||
.vectors
|
||||
.iter()
|
||||
.find(|v| v.name == "empty_payload")
|
||||
.expect("empty_payload vector not found");
|
||||
|
||||
let body = hex::decode(&v.body_hex).unwrap();
|
||||
let msg = ShellstreamMessage::decode(&body).unwrap();
|
||||
assert_eq!(msg.nonce, 0);
|
||||
// Empty dict {} in msgpack is 0x80
|
||||
assert_eq!(hex::encode(&msg.payload), "80");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_case_max_nonce() {
|
||||
let vectors = load_vectors();
|
||||
let v = vectors
|
||||
.vectors
|
||||
.iter()
|
||||
.find(|v| v.name == "max_nonce")
|
||||
.expect("max_nonce vector not found");
|
||||
|
||||
let body = hex::decode(&v.body_hex).unwrap();
|
||||
let msg = ShellstreamMessage::decode(&body).unwrap();
|
||||
assert_eq!(msg.nonce, u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_length_prefix_big_endian() {
|
||||
let vectors = load_vectors();
|
||||
let v = &vectors.vectors[0]; // any vector
|
||||
let full = hex::decode(&v.full_encoded_hex).unwrap();
|
||||
let body = hex::decode(&v.body_hex).unwrap();
|
||||
|
||||
// First 4 bytes are big-endian body length
|
||||
let len_bytes: [u8; 4] = full[..4].try_into().unwrap();
|
||||
let length = u32::from_be_bytes(len_bytes);
|
||||
assert_eq!(length as usize, body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_convenience_constructors() {
|
||||
let sid = [0xABu8; 16];
|
||||
|
||||
let ok = ShellstreamResponse::ok(sid, 1, vec![0x80]);
|
||||
assert_eq!(ok.status, Status::Ok as u16);
|
||||
assert_eq!(ok.session_id, sid);
|
||||
assert_eq!(ok.nonce, 1);
|
||||
|
||||
let err = ShellstreamResponse::error(sid, 2, "boom");
|
||||
assert_eq!(err.status, Status::Error as u16);
|
||||
|
||||
let denied = ShellstreamResponse::denied(sid, 3, "nope");
|
||||
assert_eq!(denied.status, Status::Denied as u16);
|
||||
|
||||
let cer = ShellstreamResponse::ceremony_required(sid, 4, "cer-1", "SingleApproval");
|
||||
assert_eq!(cer.status, Status::CeremonyRequired as u16);
|
||||
}
|
||||
}
|
||||
511
bascule-agent/src/ssh_server.rs
Normal file
511
bascule-agent/src/ssh_server.rs
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
//! SSH shell server — interactive governed shell sessions.
|
||||
//!
|
||||
//! Provides an SSH server that routes commands through the namespace
|
||||
//! handlers. In dev/soft mode, accepts any public key authentication.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use russh::server::{Auth, Config, Handler, Msg, Server, Session};
|
||||
use russh::{Channel, ChannelId};
|
||||
use ssh_key::{Algorithm, PrivateKey};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::namespace::NamespaceRouter;
|
||||
use crate::shellstream::{Namespace, ShellstreamResponse, Status};
|
||||
|
||||
/// The bascule-agent SSH server.
|
||||
pub struct AgentSSHServer {
|
||||
config: Arc<AgentConfig>,
|
||||
russh_config: Arc<Config>,
|
||||
router: Arc<NamespaceRouter>,
|
||||
}
|
||||
|
||||
impl AgentSSHServer {
|
||||
pub fn new(
|
||||
config: Arc<AgentConfig>,
|
||||
router: Arc<NamespaceRouter>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Load or generate host key
|
||||
let host_key = if let Some(ref path) = config.agent.ssh.host_key_path {
|
||||
if path.exists() {
|
||||
info!(path = %path.display(), "Loading SSH host key");
|
||||
russh_keys::load_secret_key(path, None)?
|
||||
} else {
|
||||
info!("Generating ephemeral Ed25519 host key");
|
||||
let mut rng = rand::thread_rng();
|
||||
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
|
||||
}
|
||||
} else {
|
||||
info!("Generating ephemeral Ed25519 host key");
|
||||
let mut rng = rand::thread_rng();
|
||||
PrivateKey::random(&mut rng, Algorithm::Ed25519)?
|
||||
};
|
||||
|
||||
let russh_config = Config {
|
||||
keys: vec![host_key],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
russh_config: Arc::new(russh_config),
|
||||
router,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the SSH server. Runs until cancelled.
|
||||
pub async fn serve(mut self) -> anyhow::Result<()> {
|
||||
let addr = &self.config.agent.ssh.listen_addr;
|
||||
let listen: std::net::SocketAddr = addr
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid SSH listen address '{}': {}", addr, e))?;
|
||||
|
||||
info!(addr = %listen, "SSH shell server listening");
|
||||
self.run_on_address(self.russh_config.clone(), listen).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Server for AgentSSHServer {
|
||||
type Handler = AgentShellHandler;
|
||||
|
||||
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> AgentShellHandler {
|
||||
let addr = peer_addr
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
info!(peer = %addr, "New SSH connection");
|
||||
|
||||
AgentShellHandler {
|
||||
router: self.router.clone(),
|
||||
dev_mode: self.config.agent.namespaces.backend == "soft",
|
||||
peer_addr: addr,
|
||||
identity: None,
|
||||
terminal_width: 80,
|
||||
session_state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-connection SSH handler.
|
||||
pub struct AgentShellHandler {
|
||||
router: Arc<NamespaceRouter>,
|
||||
dev_mode: bool,
|
||||
peer_addr: String,
|
||||
identity: Option<String>,
|
||||
terminal_width: u16,
|
||||
session_state: Option<ShellSessionState>,
|
||||
}
|
||||
|
||||
struct ShellSessionState {
|
||||
input_buffer: String,
|
||||
channel_id: ChannelId,
|
||||
session_id: [u8; 16],
|
||||
}
|
||||
|
||||
impl AgentShellHandler {
|
||||
fn banner(&self) -> String {
|
||||
let identity = self.identity.as_deref().unwrap_or("unknown");
|
||||
format!(
|
||||
"\r\n\x1b[1;36m╔══════════════════════════════════════════╗\r\n\
|
||||
║ bascule-agent governed shell ║\r\n\
|
||||
╚══════════════════════════════════════════╝\x1b[0m\r\n\
|
||||
\r\n\
|
||||
Identity: {identity}\r\n\
|
||||
Mode: {mode}\r\n\
|
||||
\r\n\
|
||||
Type 'help' for available commands.\r\n\r\n",
|
||||
mode = if self.dev_mode { "dev (soft)" } else { "live" },
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
let identity = self.identity.as_deref().unwrap_or("?");
|
||||
format!("\x1b[1;32m{identity}\x1b[0m@\x1b[1;34magent\x1b[0m> ")
|
||||
}
|
||||
|
||||
async fn process_line(
|
||||
&self,
|
||||
line: &str,
|
||||
handle: &russh::server::Handle,
|
||||
channel_id: ChannelId,
|
||||
session_id: &[u8; 16],
|
||||
) -> bool {
|
||||
let line = line.trim();
|
||||
|
||||
if line == "exit" || line == "quit" {
|
||||
let _ = handle.data(channel_id, "Goodbye.\r\n".into()).await;
|
||||
let _ = handle.close(channel_id).await;
|
||||
return true;
|
||||
}
|
||||
|
||||
if line.is_empty() {
|
||||
let _ = handle.data(channel_id, self.prompt().into()).await;
|
||||
return false;
|
||||
}
|
||||
|
||||
let output = match line {
|
||||
"help" => self.cmd_help(),
|
||||
"status" => self.cmd_status().await,
|
||||
"whoami" => self.cmd_whoami(),
|
||||
cmd if cmd.starts_with("audit ") => {
|
||||
self.cmd_namespace("audit", &cmd[6..], session_id).await
|
||||
}
|
||||
cmd if cmd.starts_with("crypto ") => {
|
||||
self.cmd_namespace("crypto", &cmd[7..], session_id).await
|
||||
}
|
||||
cmd if cmd.starts_with("identity ") => {
|
||||
self.cmd_namespace("identity", &cmd[9..], session_id).await
|
||||
}
|
||||
cmd if cmd.starts_with("governance ") => {
|
||||
self.cmd_namespace("governance", &cmd[11..], session_id).await
|
||||
}
|
||||
cmd if cmd.starts_with("secrets ") => {
|
||||
self.cmd_namespace("secrets", &cmd[8..], session_id).await
|
||||
}
|
||||
cmd if cmd.starts_with("attestation ") => {
|
||||
self.cmd_namespace("attestation", &cmd[12..], session_id).await
|
||||
}
|
||||
_ => format!("Unknown command: {}. Type 'help' for available commands.\r\n", line),
|
||||
};
|
||||
|
||||
if !output.is_empty() {
|
||||
let _ = handle.data(channel_id, output.into()).await;
|
||||
}
|
||||
let _ = handle.data(channel_id, self.prompt().into()).await;
|
||||
false
|
||||
}
|
||||
|
||||
fn cmd_help(&self) -> String {
|
||||
"\x1b[1mAvailable commands:\x1b[0m\r\n\
|
||||
\r\n\
|
||||
\x1b[33mShell:\x1b[0m\r\n\
|
||||
\x20 help Show this help message\r\n\
|
||||
\x20 status Show agent status\r\n\
|
||||
\x20 whoami Show current identity\r\n\
|
||||
\x20 exit Close the session\r\n\
|
||||
\r\n\
|
||||
\x1b[33mNamespaces:\x1b[0m\r\n\
|
||||
\x20 audit emit Emit an audit event\r\n\
|
||||
\x20 attestation posture Show posture level\r\n\
|
||||
\x20 crypto hash <data> Hash data with SHA-256\r\n\
|
||||
\x20 governance gate Check governance gate\r\n\
|
||||
\x20 identity whoami Show identity claims\r\n\
|
||||
\x20 secrets get <path> Look up a secret\r\n\
|
||||
\r\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn cmd_status(&self) -> String {
|
||||
// Query attestation posture through namespace handler
|
||||
let payload = rmp_serde::to_vec(&serde_json::json!({})).unwrap_or_default();
|
||||
let session_id = [0u8; 16];
|
||||
let resp = self
|
||||
.router
|
||||
.handle(Namespace::Attestation, 0x01, &payload, &session_id)
|
||||
.await;
|
||||
|
||||
let posture: serde_json::Value =
|
||||
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
format!(
|
||||
"\x1b[1mAgent Status\x1b[0m\r\n\
|
||||
\x20 Mode: {mode}\r\n\
|
||||
\x20 Posture: {posture}\r\n\
|
||||
\x20 Peer: {peer}\r\n\r\n",
|
||||
mode = if self.dev_mode { "soft (dev)" } else { "live" },
|
||||
posture = posture.get("level").and_then(|v| v.as_str()).unwrap_or("unknown"),
|
||||
peer = self.peer_addr,
|
||||
)
|
||||
}
|
||||
|
||||
fn cmd_whoami(&self) -> String {
|
||||
format!(
|
||||
"Identity: {}\r\nMode: {}\r\nPeer: {}\r\n",
|
||||
self.identity.as_deref().unwrap_or("unknown"),
|
||||
if self.dev_mode { "dev" } else { "live" },
|
||||
self.peer_addr,
|
||||
)
|
||||
}
|
||||
|
||||
async fn cmd_namespace(
|
||||
&self,
|
||||
ns_name: &str,
|
||||
args: &str,
|
||||
session_id: &[u8; 16],
|
||||
) -> String {
|
||||
let ns = match ns_name {
|
||||
"audit" => Namespace::Audit,
|
||||
"crypto" => Namespace::Crypto,
|
||||
"identity" => Namespace::Identity,
|
||||
"governance" => Namespace::Governance,
|
||||
"secrets" => Namespace::Secrets,
|
||||
"attestation" => Namespace::Attestation,
|
||||
_ => return format!("Unknown namespace: {ns_name}\r\n"),
|
||||
};
|
||||
|
||||
let (function_id, payload) = match parse_namespace_command(ns_name, args) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return format!("Error: {e}\r\n"),
|
||||
};
|
||||
|
||||
let resp = self.router.handle(ns, function_id, &payload, session_id).await;
|
||||
|
||||
let status = Status::from_u16(resp.status);
|
||||
let payload_parsed: serde_json::Value =
|
||||
rmp_serde::from_slice(&resp.payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let status_str = match status {
|
||||
Some(Status::Ok) => "\x1b[32mOK\x1b[0m",
|
||||
Some(Status::Error) => "\x1b[31mERROR\x1b[0m",
|
||||
Some(Status::Denied) => "\x1b[31mDENIED\x1b[0m",
|
||||
Some(Status::CeremonyRequired) => "\x1b[33mCEREMONY_REQUIRED\x1b[0m",
|
||||
None => "UNKNOWN",
|
||||
};
|
||||
|
||||
format!(
|
||||
"[{status_str}] {}\r\n",
|
||||
serde_json::to_string_pretty(&payload_parsed).unwrap_or_else(|_| "{}".to_string())
|
||||
)
|
||||
.replace('\n', "\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a namespace subcommand into (function_id, msgpack payload).
|
||||
fn parse_namespace_command(ns: &str, args: &str) -> anyhow::Result<(u16, Vec<u8>)> {
|
||||
let parts: Vec<&str> = args.splitn(2, ' ').collect();
|
||||
let subcmd = parts.first().copied().unwrap_or("");
|
||||
let rest = parts.get(1).copied().unwrap_or("");
|
||||
|
||||
match (ns, subcmd) {
|
||||
("audit", "emit") => {
|
||||
let payload = serde_json::json!({
|
||||
"event_type": "shell.command",
|
||||
"subject": "ssh-user",
|
||||
"resource": "shell",
|
||||
"action": "emit",
|
||||
"outcome": "success",
|
||||
});
|
||||
Ok((0x01, rmp_serde::to_vec(&payload)?))
|
||||
}
|
||||
("audit", "anchor") => {
|
||||
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({"event_ids": []}))?))
|
||||
}
|
||||
("crypto", "hash") => {
|
||||
let data = if rest.is_empty() { "test" } else { rest };
|
||||
Ok((0x03, rmp_serde::to_vec(&serde_json::json!({"data": data}))?))
|
||||
}
|
||||
("identity", "whoami") | ("identity", "authenticate") => {
|
||||
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"token": "ssh-session"}))?))
|
||||
}
|
||||
("governance", "gate") => {
|
||||
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({
|
||||
"subject": "ssh-user",
|
||||
"resource": "shell",
|
||||
"action": "access",
|
||||
}))?))
|
||||
}
|
||||
("secrets", "get") => {
|
||||
let path = if rest.is_empty() { "test/secret" } else { rest };
|
||||
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({"path": path}))?))
|
||||
}
|
||||
("attestation", "posture") => {
|
||||
Ok((0x01, rmp_serde::to_vec(&serde_json::json!({}))?))
|
||||
}
|
||||
("attestation", "sat") => {
|
||||
Ok((0x02, rmp_serde::to_vec(&serde_json::json!({}))?))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown {ns} subcommand: {subcmd}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for AgentShellHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
user: &str,
|
||||
public_key: &ssh_key::PublicKey,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256).to_string();
|
||||
|
||||
if self.dev_mode {
|
||||
// Dev mode: accept any key
|
||||
info!(user = %user, fingerprint = %fingerprint, "SSH auth accepted (dev mode)");
|
||||
self.identity = Some(user.to_string());
|
||||
Ok(Auth::Accept)
|
||||
} else {
|
||||
// TODO: validate against authorized keys store
|
||||
warn!(fingerprint = %fingerprint, "SSH auth rejected: live mode not implemented");
|
||||
Ok(Auth::Reject {
|
||||
proceed_with_methods: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_password(
|
||||
&mut self,
|
||||
user: &str,
|
||||
_password: &str,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
if self.dev_mode {
|
||||
info!(user = %user, "SSH password auth accepted (dev mode)");
|
||||
self.identity = Some(user.to_string());
|
||||
Ok(Auth::Accept)
|
||||
} else {
|
||||
Ok(Auth::Reject {
|
||||
proceed_with_methods: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
_channel: Channel<Msg>,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
_term: &str,
|
||||
col_width: u32,
|
||||
_row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(russh::Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.terminal_width = col_width.min(200) as u16;
|
||||
session.request_success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.request_success();
|
||||
|
||||
let session_id: [u8; 16] = uuid::Uuid::new_v4().into_bytes();
|
||||
|
||||
let banner = self.banner();
|
||||
let prompt = self.prompt();
|
||||
session.data(channel, format!("{banner}{prompt}").into())?;
|
||||
|
||||
self.session_state = Some(ShellSessionState {
|
||||
input_buffer: String::new(),
|
||||
channel_id: channel,
|
||||
session_id,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
// Take session state temporarily to avoid borrow conflicts
|
||||
let mut state = match self.session_state.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for &byte in data {
|
||||
match byte {
|
||||
// Enter
|
||||
b'\r' | b'\n' => {
|
||||
session.data(channel, "\r\n".into())?;
|
||||
let line = state.input_buffer.clone();
|
||||
state.input_buffer.clear();
|
||||
|
||||
let handle = session.handle();
|
||||
let sid = state.session_id;
|
||||
if self.process_line(&line, &handle, channel, &sid).await {
|
||||
// Session ended — don't put state back
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Backspace / DEL
|
||||
0x7f | 0x08 => {
|
||||
if !state.input_buffer.is_empty() {
|
||||
state.input_buffer.pop();
|
||||
session.data(channel, "\x08 \x08".into())?;
|
||||
}
|
||||
}
|
||||
// Ctrl-C
|
||||
0x03 => {
|
||||
state.input_buffer.clear();
|
||||
session.data(channel, "^C\r\n".into())?;
|
||||
let prompt = self.prompt();
|
||||
session.data(channel, prompt.into())?;
|
||||
}
|
||||
// Ctrl-D (EOF)
|
||||
0x04 => {
|
||||
session.data(channel, "\r\nGoodbye.\r\n".into())?;
|
||||
session.close(channel)?;
|
||||
return Ok(());
|
||||
}
|
||||
// Printable characters
|
||||
b if b >= 0x20 && b < 0x7f => {
|
||||
state.input_buffer.push(b as char);
|
||||
session.data(channel, vec![b].into())?;
|
||||
}
|
||||
// Ignore other control characters
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Put session state back
|
||||
self.session_state = Some(state);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_namespace_commands() {
|
||||
let (fn_id, _) = parse_namespace_command("audit", "emit").unwrap();
|
||||
assert_eq!(fn_id, 0x01);
|
||||
|
||||
let (fn_id, _) = parse_namespace_command("crypto", "hash test data").unwrap();
|
||||
assert_eq!(fn_id, 0x03);
|
||||
|
||||
let (fn_id, _) = parse_namespace_command("governance", "gate").unwrap();
|
||||
assert_eq!(fn_id, 0x01);
|
||||
|
||||
let (fn_id, _) = parse_namespace_command("attestation", "posture").unwrap();
|
||||
assert_eq!(fn_id, 0x01);
|
||||
|
||||
let (fn_id, _) = parse_namespace_command("secrets", "get db/password").unwrap();
|
||||
assert_eq!(fn_id, 0x01);
|
||||
|
||||
assert!(parse_namespace_command("audit", "nonexistent").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_crypto_hash_payload() {
|
||||
let (_, payload) = parse_namespace_command("crypto", "hash hello world").unwrap();
|
||||
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
|
||||
assert_eq!(parsed["data"], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_secrets_get_path() {
|
||||
let (_, payload) = parse_namespace_command("secrets", "get db/password").unwrap();
|
||||
let parsed: serde_json::Value = rmp_serde::from_slice(&payload).unwrap();
|
||||
assert_eq!(parsed["path"], "db/password");
|
||||
}
|
||||
}
|
||||
12
bascule-agent/tests/e2e-config.toml
Normal file
12
bascule-agent/tests/e2e-config.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Test config for E2E verification — Python SDK ↔ Rust bascule-agent
|
||||
[agent]
|
||||
socket_path = "/tmp/bascule-agent-e2e.sock"
|
||||
|
||||
[agent.ssh]
|
||||
enabled = false
|
||||
|
||||
[agent.namespaces]
|
||||
backend = "soft"
|
||||
|
||||
[agent.namespaces.attestation]
|
||||
default_posture = "normal"
|
||||
227
bascule-agent/tests/fixtures/shellstream_vectors.json
vendored
Normal file
227
bascule-agent/tests/fixtures/shellstream_vectors.json
vendored
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
{
|
||||
"generator": "substrate-sdk-python",
|
||||
"protocol_version": "1.0",
|
||||
"namespace_ids": {
|
||||
"CRYPTO": 1,
|
||||
"IDENTITY": 2,
|
||||
"SECRETS": 3,
|
||||
"GOVERNANCE": 4,
|
||||
"ATTESTATION": 5,
|
||||
"AUDIT": 6,
|
||||
"NETWORK": 7,
|
||||
"INTELLIGENCE": 8
|
||||
},
|
||||
"status_codes": {
|
||||
"OK": 0,
|
||||
"ERROR": 1,
|
||||
"DENIED": 2,
|
||||
"CEREMONY_REQUIRED": 3
|
||||
},
|
||||
"vectors": [
|
||||
{
|
||||
"name": "crypto_sign",
|
||||
"type": "request",
|
||||
"namespace": 1,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "83a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da765643235353139",
|
||||
"nonce": 42,
|
||||
"body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a",
|
||||
"full_encoded_hex": "0000004a950101c4100102030405060708090a0b0c0d0e0f10c43283a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da9616c676f726974686da7656432353531392a"
|
||||
},
|
||||
{
|
||||
"name": "crypto_verify",
|
||||
"type": "request",
|
||||
"namespace": 1,
|
||||
"function": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "84a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da765643235353139",
|
||||
"nonce": 42,
|
||||
"body_hex": "950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a",
|
||||
"full_encoded_hex": "00000059950102c4100102030405060708090a0b0c0d0e0f10c44184a66b65795f6964a96167656e742d6b6579a464617461a8614756736247383da97369676e6174757265a463326c6ea9616c676f726974686da7656432353531392a"
|
||||
},
|
||||
{
|
||||
"name": "identity_auth",
|
||||
"type": "request",
|
||||
"namespace": 2,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "81a5746f6b656ea665794a2e2e2e",
|
||||
"nonce": 42,
|
||||
"body_hex": "950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a",
|
||||
"full_encoded_hex": "00000026950201c4100102030405060708090a0b0c0d0e0f10c40e81a5746f6b656ea665794a2e2e2e2a"
|
||||
},
|
||||
{
|
||||
"name": "identity_authz",
|
||||
"type": "request",
|
||||
"namespace": 2,
|
||||
"function": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea472656164",
|
||||
"nonce": 42,
|
||||
"body_hex": "950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a",
|
||||
"full_encoded_hex": "00000048950202c4100102030405060708090a0b0c0d0e0f10c43083a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea4726561642a"
|
||||
},
|
||||
{
|
||||
"name": "secrets_get",
|
||||
"type": "request",
|
||||
"namespace": 3,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "81a470617468ab64622f70617373776f7264",
|
||||
"nonce": 42,
|
||||
"body_hex": "950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a",
|
||||
"full_encoded_hex": "0000002a950301c4100102030405060708090a0b0c0d0e0f10c41281a470617468ab64622f70617373776f72642a"
|
||||
},
|
||||
{
|
||||
"name": "governance_gate",
|
||||
"type": "request",
|
||||
"namespace": 4,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "83a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d7574617465",
|
||||
"nonce": 42,
|
||||
"body_hex": "950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a",
|
||||
"full_encoded_hex": "0000004a950401c4100102030405060708090a0b0c0d0e0f10c43283a77375626a656374a574796c6572a87265736f75726365ab666c6565742f6e6f646573a6616374696f6ea66d75746174652a"
|
||||
},
|
||||
{
|
||||
"name": "governance_propose",
|
||||
"type": "request",
|
||||
"namespace": 4,
|
||||
"function": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "82ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e",
|
||||
"nonce": 42,
|
||||
"body_hex": "950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a",
|
||||
"full_encoded_hex": "00000051950402c4100102030405060708090a0b0c0d0e0f10c43982ad636572656d6f6e795f74797065ae53696e676c65417070726f76616ca77061796c6f616481a6696e74656e74aa7363616c652d646f776e2a"
|
||||
},
|
||||
{
|
||||
"name": "attestation_posture",
|
||||
"type": "request",
|
||||
"namespace": 5,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "80",
|
||||
"nonce": 42,
|
||||
"body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c401802a",
|
||||
"full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c401802a"
|
||||
},
|
||||
{
|
||||
"name": "attestation_sat",
|
||||
"type": "request",
|
||||
"namespace": 5,
|
||||
"function": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "80",
|
||||
"nonce": 42,
|
||||
"body_hex": "950502c4100102030405060708090a0b0c0d0e0f10c401802a",
|
||||
"full_encoded_hex": "00000019950502c4100102030405060708090a0b0c0d0e0f10c401802a"
|
||||
},
|
||||
{
|
||||
"name": "audit_emit",
|
||||
"type": "request",
|
||||
"namespace": 6,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a773756363657373",
|
||||
"nonce": 42,
|
||||
"body_hex": "950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a",
|
||||
"full_encoded_hex": "00000072950601c4100102030405060708090a0b0c0d0e0f10c45a85aa6576656e745f74797065ab6170692e72657175657374a77375626a656374a574796c6572a87265736f75726365ae2f6170692f76312f666c6565742fa6616374696f6ea46c697374a76f7574636f6d65a7737563636573732a"
|
||||
},
|
||||
{
|
||||
"name": "audit_anchor",
|
||||
"type": "request",
|
||||
"namespace": 6,
|
||||
"function": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "81a96576656e745f69647392a76576742d303031a76576742d303032",
|
||||
"nonce": 42,
|
||||
"body_hex": "950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a",
|
||||
"full_encoded_hex": "00000034950602c4100102030405060708090a0b0c0d0e0f10c41c81a96576656e745f69647392a76576742d303031a76576742d3030322a"
|
||||
},
|
||||
{
|
||||
"name": "network_classify",
|
||||
"type": "request",
|
||||
"namespace": 7,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca3746370",
|
||||
"nonce": 42,
|
||||
"body_hex": "950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a",
|
||||
"full_encoded_hex": "00000053950701c4100102030405060708090a0b0c0d0e0f10c43b84a6736f75726365a831302e302e302e31ab64657374696e6174696f6ea831302e302e302e32a4706f7274cd1538a870726f746f636f6ca37463702a"
|
||||
},
|
||||
{
|
||||
"name": "intelligence_infer",
|
||||
"type": "request",
|
||||
"namespace": 8,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f",
|
||||
"nonce": 42,
|
||||
"body_hex": "950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a",
|
||||
"full_encoded_hex": "00000045950801c4100102030405060708090a0b0c0d0e0f10c42d82a56d6f64656cb8636c617564652d736f6e6e65742d342d3230323530353134a670726f6d7074a548656c6c6f2a"
|
||||
},
|
||||
{
|
||||
"name": "response_ok",
|
||||
"type": "response",
|
||||
"status": 0,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "82a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a6616263313233",
|
||||
"nonce": 42,
|
||||
"body_hex": "9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a",
|
||||
"full_encoded_hex": "0000003c9400c4100102030405060708090a0b0c0d0e0f10c42582a86576656e745f6964a76576742d313233ab6d65726b6c655f6c656166a66162633132332a"
|
||||
},
|
||||
{
|
||||
"name": "response_error",
|
||||
"type": "response",
|
||||
"status": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "81a76d657373616765ae496e7465726e616c206572726f72",
|
||||
"nonce": 42,
|
||||
"body_hex": "9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a",
|
||||
"full_encoded_hex": "0000002f9401c4100102030405060708090a0b0c0d0e0f10c41881a76d657373616765ae496e7465726e616c206572726f722a"
|
||||
},
|
||||
{
|
||||
"name": "response_denied",
|
||||
"type": "response",
|
||||
"status": 2,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "82a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d7574617465",
|
||||
"nonce": 42,
|
||||
"body_hex": "9402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a",
|
||||
"full_encoded_hex": "000000499402c4100102030405060708090a0b0c0d0e0f10c43282a76d657373616765b2496e73756666696369656e742073636f7065a87265717569726564ac666c6565743a6d75746174652a"
|
||||
},
|
||||
{
|
||||
"name": "response_ceremony",
|
||||
"type": "response",
|
||||
"status": 3,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "82ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c",
|
||||
"nonce": 42,
|
||||
"body_hex": "9403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a",
|
||||
"full_encoded_hex": "000000499403c4100102030405060708090a0b0c0d0e0f10c43282ab636572656d6f6e795f6964a76365722d343536ad636572656d6f6e795f74797065ae53696e676c65417070726f76616c2a"
|
||||
},
|
||||
{
|
||||
"name": "empty_payload",
|
||||
"type": "request",
|
||||
"namespace": 5,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "80",
|
||||
"nonce": 0,
|
||||
"body_hex": "950501c4100102030405060708090a0b0c0d0e0f10c4018000",
|
||||
"full_encoded_hex": "00000019950501c4100102030405060708090a0b0c0d0e0f10c4018000"
|
||||
},
|
||||
{
|
||||
"name": "max_nonce",
|
||||
"type": "request",
|
||||
"namespace": 1,
|
||||
"function": 1,
|
||||
"session_id_hex": "0102030405060708090a0b0c0d0e0f10",
|
||||
"payload_hex": "81a66b65795f6964a16b",
|
||||
"nonce": 18446744073709551615,
|
||||
"body_hex": "950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff",
|
||||
"full_encoded_hex": "0000002a950101c4100102030405060708090a0b0c0d0e0f10c40a81a66b65795f6964a16bcfffffffffffffffff"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
bascule-core/Cargo.toml
Normal file
23
bascule-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "bascule-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared types for the Bascule governance-mediated access control system"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_json_canonicalizer = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
# Governance ceremony state machine (extracted)
|
||||
ceremony-engine = { workspace = true }
|
||||
|
||||
# Cross-workspace path deps — Guildhouse governance primitives.
|
||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
||||
92
bascule-core/src/audit.rs
Normal file
92
bascule-core/src/audit.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::command::{ChangeClassification, CommandRecord, ResourceRef};
|
||||
use crate::session::OperatorIdentity;
|
||||
|
||||
/// A complete audit event for a command execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub event_id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub operator_identity: OperatorIdentity,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub command: CommandRecord,
|
||||
pub classification: ChangeClassification,
|
||||
pub policy_decision: PolicyDecision,
|
||||
pub execution_result: ExecutionResult,
|
||||
pub target_resources: Vec<ResourceRef>,
|
||||
pub target_profile_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of policy evaluation for a command.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyDecision {
|
||||
pub allowed: bool,
|
||||
pub policy_bundle_hash: String,
|
||||
pub accord_version: String,
|
||||
pub evaluation_duration_ms: u32,
|
||||
pub denied_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl PolicyDecision {
|
||||
/// Create an allow-all stub decision (used in Phase 1 when OPA is not deployed).
|
||||
pub fn allow_all_stub() -> Self {
|
||||
Self {
|
||||
allowed: true,
|
||||
policy_bundle_hash: "stub".into(),
|
||||
accord_version: "none".into(),
|
||||
evaluation_duration_ms: 0,
|
||||
denied_reason: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of executing a command.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExecutionResult {
|
||||
pub status: ExecutionStatus,
|
||||
pub summary: String,
|
||||
pub resources_affected: u32,
|
||||
pub mutations_applied: u32,
|
||||
}
|
||||
|
||||
/// Execution outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExecutionStatus {
|
||||
Success,
|
||||
Denied,
|
||||
Error,
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl ExecutionResult {
|
||||
pub fn success(summary: String) -> Self {
|
||||
Self {
|
||||
status: ExecutionStatus::Success,
|
||||
summary,
|
||||
resources_affected: 0,
|
||||
mutations_applied: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn denied(reason: String) -> Self {
|
||||
Self {
|
||||
status: ExecutionStatus::Denied,
|
||||
summary: reason,
|
||||
resources_affected: 0,
|
||||
mutations_applied: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(msg: String) -> Self {
|
||||
Self {
|
||||
status: ExecutionStatus::Error,
|
||||
summary: msg,
|
||||
resources_affected: 0,
|
||||
mutations_applied: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
67
bascule-core/src/ceremony.rs
Normal file
67
bascule-core/src/ceremony.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::scope::SessionScope;
|
||||
use crate::session::OperatorIdentity;
|
||||
|
||||
/// Types of ceremonies that produce session grants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CeremonyType {
|
||||
/// Standing policy auto-approval.
|
||||
SelfGrant,
|
||||
/// One human approver required.
|
||||
SingleApproval,
|
||||
/// Break-glass with external evidence.
|
||||
EmergencyAccess,
|
||||
/// LLM operator with human co-approver.
|
||||
LlmCoApproval,
|
||||
}
|
||||
|
||||
/// A request to start a ceremony.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CeremonyRequest {
|
||||
pub ceremony_type: CeremonyType,
|
||||
pub requestor: OperatorIdentity,
|
||||
pub requested_scope: SessionScope,
|
||||
pub evidence: Vec<Evidence>,
|
||||
pub requested_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// The result of a completed ceremony.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CeremonyGrant {
|
||||
pub ceremony_id: Uuid,
|
||||
pub ceremony_type: CeremonyType,
|
||||
pub requestor: OperatorIdentity,
|
||||
pub approvers: Vec<OperatorIdentity>,
|
||||
/// The scope that was actually granted (may be narrower than requested).
|
||||
pub granted_scope: SessionScope,
|
||||
pub accord_version: String,
|
||||
pub evidence: Vec<Evidence>,
|
||||
pub granted_at: DateTime<Utc>,
|
||||
pub session_lifetime: Duration,
|
||||
}
|
||||
|
||||
/// External evidence supporting a ceremony (e.g. Jira ticket).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Evidence {
|
||||
pub evidence_type: EvidenceType,
|
||||
/// The external reference ("INCIDENT-1234", URL, etc.).
|
||||
pub reference: String,
|
||||
/// Whether the gateway verified the reference exists.
|
||||
pub verified: bool,
|
||||
pub verified_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Types of external evidence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvidenceType {
|
||||
JiraTicket,
|
||||
GitHubIssue,
|
||||
SlackThread,
|
||||
PagerDutyIncident,
|
||||
Manual,
|
||||
}
|
||||
130
bascule-core/src/command.rs
Normal file
130
bascule-core/src/command.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A record of a command that was executed (for audit purposes).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandRecord {
|
||||
pub verb: String,
|
||||
pub namespace: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_name: Option<String>,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Classification of a command's impact.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChangeClassification {
|
||||
Read,
|
||||
Mutative,
|
||||
Workspace,
|
||||
Session,
|
||||
}
|
||||
|
||||
/// Metadata about a command verb, used for discovery and classification.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandDescriptor {
|
||||
pub verb: String,
|
||||
pub description: String,
|
||||
pub classification: ChangeClassification,
|
||||
pub requires_namespace: bool,
|
||||
pub requires_resource: bool,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
/// Reference to a Kubernetes resource.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceRef {
|
||||
pub api_group: String,
|
||||
pub kind: String,
|
||||
pub namespace: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Returns the built-in command descriptors.
|
||||
pub fn builtin_commands() -> Vec<CommandDescriptor> {
|
||||
vec![
|
||||
// Read commands
|
||||
CommandDescriptor {
|
||||
verb: "get".into(),
|
||||
description: "Get resources".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: true,
|
||||
requires_resource: true,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "describe".into(),
|
||||
description: "Describe a resource in detail".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: true,
|
||||
requires_resource: true,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "logs".into(),
|
||||
description: "View pod logs".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: true,
|
||||
requires_resource: true,
|
||||
streaming: true,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "status".into(),
|
||||
description: "Cluster health summary".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: false,
|
||||
requires_resource: false,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "profiles_list".into(),
|
||||
description: "List capability profiles".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: false,
|
||||
requires_resource: false,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "profiles_get".into(),
|
||||
description: "Show a capability profile".into(),
|
||||
classification: ChangeClassification::Read,
|
||||
requires_namespace: false,
|
||||
requires_resource: true,
|
||||
streaming: false,
|
||||
},
|
||||
// Mutative commands (Phase 2)
|
||||
CommandDescriptor {
|
||||
verb: "scale".into(),
|
||||
description: "Scale a deployment".into(),
|
||||
classification: ChangeClassification::Mutative,
|
||||
requires_namespace: true,
|
||||
requires_resource: true,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "patch".into(),
|
||||
description: "Patch a resource".into(),
|
||||
classification: ChangeClassification::Mutative,
|
||||
requires_namespace: true,
|
||||
requires_resource: true,
|
||||
streaming: false,
|
||||
},
|
||||
// Session commands
|
||||
CommandDescriptor {
|
||||
verb: "session_status".into(),
|
||||
description: "Show session scope and lifetime".into(),
|
||||
classification: ChangeClassification::Session,
|
||||
requires_namespace: false,
|
||||
requires_resource: false,
|
||||
streaming: false,
|
||||
},
|
||||
CommandDescriptor {
|
||||
verb: "session_end".into(),
|
||||
description: "End the current session".into(),
|
||||
classification: ChangeClassification::Session,
|
||||
requires_namespace: false,
|
||||
requires_resource: false,
|
||||
streaming: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
28
bascule-core/src/lib.rs
Normal file
28
bascule-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
pub mod audit;
|
||||
pub mod ceremony;
|
||||
pub mod command;
|
||||
pub mod scope;
|
||||
pub mod session;
|
||||
|
||||
// Governance ceremony engine — extracted to ceremony-engine crate.
|
||||
// Re-exported here for backward compatibility while consumers migrate.
|
||||
pub mod ceremony_engine {
|
||||
pub use ceremony_engine::CeremonyEngine;
|
||||
}
|
||||
pub mod ceremony_request {
|
||||
pub use ceremony_engine::{
|
||||
ApprovalDecision, CeremonyApproval, CeremonyError, CeremonySubject,
|
||||
GovernanceCeremonyRequest, GovernanceCeremonyStatus,
|
||||
};
|
||||
}
|
||||
pub mod ceremony_resolution {
|
||||
pub use ceremony_engine::CeremonyResolution;
|
||||
}
|
||||
pub mod ceremony_store {
|
||||
pub use ceremony_engine::{
|
||||
CeremonyStore, CeremonyStoreError, InMemoryCeremonyStore,
|
||||
};
|
||||
}
|
||||
pub mod ceremony_artifact {
|
||||
pub use ceremony_engine::CeremonyVerb;
|
||||
}
|
||||
168
bascule-core/src/scope.rs
Normal file
168
bascule-core/src/scope.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Defines what an operator can do within a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionScope {
|
||||
pub namespaces: Vec<NamespaceScope>,
|
||||
pub global: GlobalScope,
|
||||
pub pathways: Vec<ChangePathway>,
|
||||
/// Maximum mutations before session requires a new ceremony. None = unlimited.
|
||||
pub mutation_budget: Option<u32>,
|
||||
pub can_delegate: bool,
|
||||
}
|
||||
|
||||
/// Per-namespace access rules.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceScope {
|
||||
pub namespace: String,
|
||||
pub rules: Vec<ScopeRule>,
|
||||
/// Workload profile hashes this session can interact with.
|
||||
/// Empty = all workloads in namespace (if accord permits).
|
||||
pub workload_profiles: Vec<String>,
|
||||
/// Capability-negative filter: deny interaction with workloads
|
||||
/// whose profiles include these capabilities.
|
||||
pub denied_capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single permission rule (similar to k8s RBAC but higher-level).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeRule {
|
||||
/// API groups ("" = core, "apps", etc.)
|
||||
pub api_groups: Vec<String>,
|
||||
/// Resource types ("pods", "deployments", etc.)
|
||||
pub resources: Vec<String>,
|
||||
pub verbs: Vec<Verb>,
|
||||
}
|
||||
|
||||
/// Command verbs that can be granted in a session scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verb {
|
||||
Get,
|
||||
List,
|
||||
Watch,
|
||||
Create,
|
||||
Update,
|
||||
Patch,
|
||||
Delete,
|
||||
Exec,
|
||||
Logs,
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl Verb {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Verb::Get => "get",
|
||||
Verb::List => "list",
|
||||
Verb::Watch => "watch",
|
||||
Verb::Create => "create",
|
||||
Verb::Update => "update",
|
||||
Verb::Patch => "patch",
|
||||
Verb::Delete => "delete",
|
||||
Verb::Exec => "exec",
|
||||
Verb::Logs => "logs",
|
||||
Verb::Scale => "scale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-namespace permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GlobalScope {
|
||||
pub can_view_audit_trail: bool,
|
||||
pub can_view_profiles: bool,
|
||||
pub can_view_topology: bool,
|
||||
}
|
||||
|
||||
/// How mutations flow through the system.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChangePathway {
|
||||
/// Apply changes immediately (emergency only).
|
||||
Direct,
|
||||
/// Stage in workspace, merge through policy gate.
|
||||
Workspace,
|
||||
/// Simulate only, no actual mutations.
|
||||
DryRunOnly,
|
||||
}
|
||||
|
||||
impl SessionScope {
|
||||
/// Check if this scope permits the given verb on the given resource in the given namespace.
|
||||
pub fn permits(&self, namespace: &str, api_group: &str, resource: &str, verb: Verb) -> bool {
|
||||
self.namespaces.iter().any(|ns| {
|
||||
ns.namespace == namespace
|
||||
&& ns.rules.iter().any(|rule| {
|
||||
(rule.api_groups.contains(&api_group.to_string())
|
||||
|| rule.api_groups.contains(&"*".to_string()))
|
||||
&& (rule.resources.contains(&resource.to_string())
|
||||
|| rule.resources.contains(&"*".to_string()))
|
||||
&& rule.verbs.contains(&verb)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GlobalScope {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
can_view_audit_trail: false,
|
||||
can_view_profiles: true,
|
||||
can_view_topology: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scope_permits_matching_rule() {
|
||||
let scope = SessionScope {
|
||||
namespaces: vec![NamespaceScope {
|
||||
namespace: "default".to_string(),
|
||||
rules: vec![ScopeRule {
|
||||
api_groups: vec!["".to_string()],
|
||||
resources: vec!["pods".to_string()],
|
||||
verbs: vec![Verb::Get, Verb::List],
|
||||
}],
|
||||
workload_profiles: vec![],
|
||||
denied_capabilities: vec![],
|
||||
}],
|
||||
global: GlobalScope::default(),
|
||||
pathways: vec![ChangePathway::DryRunOnly],
|
||||
mutation_budget: None,
|
||||
can_delegate: false,
|
||||
};
|
||||
|
||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||
assert!(scope.permits("default", "", "pods", Verb::List));
|
||||
assert!(!scope.permits("default", "", "pods", Verb::Delete));
|
||||
assert!(!scope.permits("other", "", "pods", Verb::Get));
|
||||
assert!(!scope.permits("default", "", "deployments", Verb::Get));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_wildcard_matches_all() {
|
||||
let scope = SessionScope {
|
||||
namespaces: vec![NamespaceScope {
|
||||
namespace: "default".to_string(),
|
||||
rules: vec![ScopeRule {
|
||||
api_groups: vec!["*".to_string()],
|
||||
resources: vec!["*".to_string()],
|
||||
verbs: vec![Verb::Get, Verb::List, Verb::Logs],
|
||||
}],
|
||||
workload_profiles: vec![],
|
||||
denied_capabilities: vec![],
|
||||
}],
|
||||
global: GlobalScope::default(),
|
||||
pathways: vec![],
|
||||
mutation_budget: None,
|
||||
can_delegate: false,
|
||||
};
|
||||
|
||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||
assert!(scope.permits("default", "apps", "deployments", Verb::List));
|
||||
}
|
||||
}
|
||||
72
bascule-core/src/session.rs
Normal file
72
bascule-core/src/session.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::scope::SessionScope;
|
||||
|
||||
/// An active operator session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub session_id: Uuid,
|
||||
pub ceremony_id: Uuid,
|
||||
pub identity: OperatorIdentity,
|
||||
pub scope: SessionScope,
|
||||
pub state: SessionState,
|
||||
pub mutations_used: u32,
|
||||
pub valid_from: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// The identity of an operator interacting with the gateway.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum OperatorIdentity {
|
||||
/// External operator authenticated via OIDC.
|
||||
Oidc {
|
||||
issuer: String,
|
||||
subject: String,
|
||||
email: String,
|
||||
},
|
||||
/// Cluster-native workload with SPIFFE SVID (future).
|
||||
Spiffe { svid_uri: String },
|
||||
}
|
||||
|
||||
impl OperatorIdentity {
|
||||
/// A display-friendly identity string for audit logs.
|
||||
pub fn display_id(&self) -> String {
|
||||
match self {
|
||||
OperatorIdentity::Oidc { email, .. } => format!("oidc:{email}"),
|
||||
OperatorIdentity::Spiffe { svid_uri } => format!("spiffe:{svid_uri}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session lifecycle state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionState {
|
||||
Active,
|
||||
Terminated,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Check if this session is still valid for executing commands.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state == SessionState::Active && Utc::now() < self.expires_at
|
||||
}
|
||||
|
||||
/// Check if the mutation budget is exhausted.
|
||||
pub fn budget_exhausted(&self) -> bool {
|
||||
self.scope
|
||||
.mutation_budget
|
||||
.map(|budget| self.mutations_used >= budget)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Increment the mutation counter. Returns the new count.
|
||||
pub fn record_mutation(&mut self) -> u32 {
|
||||
self.mutations_used += 1;
|
||||
self.mutations_used
|
||||
}
|
||||
}
|
||||
13
bascule-filter-core/Cargo.toml
Normal file
13
bascule-filter-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "bascule-filter-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared types and traits for the Bascule governed log filter plugin system"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
39
bascule-filter-core/src/filter.rs
Normal file
39
bascule-filter-core/src/filter.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//! LogFilter trait — the extensibility boundary for filter plugins.
|
||||
|
||||
use crate::LogLine;
|
||||
|
||||
/// Result of applying a filter to a LogLine.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterResult {
|
||||
/// Emit this line (possibly transformed).
|
||||
Pass(LogLine),
|
||||
/// Suppress this line.
|
||||
Drop,
|
||||
}
|
||||
|
||||
/// A log filter plugin.
|
||||
///
|
||||
/// Implementors may be:
|
||||
/// - In-process (built-in, compiled into bascule-shell)
|
||||
/// - Out-of-process (standalone binary via ProcessFilter + stdio protocol)
|
||||
/// - WASM (future — same trait, different runtime)
|
||||
///
|
||||
/// The `filter_id()` method enables Chronicle attribution for governed filter plugins.
|
||||
/// A named, governed filter is a verifiable transform on a governed log stream.
|
||||
pub trait LogFilter: Send + Sync {
|
||||
/// Apply this filter to a log line.
|
||||
///
|
||||
/// MUST: never drop a line where `line.is_denial == true`.
|
||||
/// Denial lines are governance events and must always pass through.
|
||||
fn filter(&self, line: LogLine) -> FilterResult;
|
||||
|
||||
/// Human-readable filter name.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Stable identifier for Chronicle attribution.
|
||||
/// `None` = anonymous built-in filter
|
||||
/// `Some()` = named governed filter (Chronicle-attributable)
|
||||
fn filter_id(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
19
bascule-filter-core/src/lib.rs
Normal file
19
bascule-filter-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! bascule-filter-core — shared types and traits for the governed log filter plugin system.
|
||||
//!
|
||||
//! This crate defines the extensibility boundary for Bascule log filters.
|
||||
//! Any filter binary (built-in or standalone) depends only on this crate.
|
||||
//!
|
||||
//! Three integration modes:
|
||||
//! 1. In-process (compiled into bascule-shell)
|
||||
//! 2. Out-of-process (standalone binary via ProcessFilter + stdio protocol)
|
||||
//! 3. WASM (future — same trait, different runtime)
|
||||
|
||||
pub mod filter;
|
||||
pub mod line;
|
||||
pub mod process;
|
||||
pub mod stdio;
|
||||
|
||||
pub use filter::{FilterResult, LogFilter};
|
||||
pub use line::LogLine;
|
||||
pub use process::ProcessFilter;
|
||||
pub use stdio::stdio_main;
|
||||
80
bascule-filter-core/src/line.rs
Normal file
80
bascule-filter-core/src/line.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! LogLine — the atomic unit of the filter protocol.
|
||||
//!
|
||||
//! Serialized as newline-delimited JSON for the stdio filter protocol.
|
||||
//! Every filter binary receives and emits this type.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single attributed log line from a pod.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
/// Pod name (e.g., "api-svc-7d9f4b-xkp2q").
|
||||
pub pod: String,
|
||||
|
||||
/// Kubernetes namespace.
|
||||
pub namespace: String,
|
||||
|
||||
/// Parsed timestamp if present in line.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<DateTime<Utc>>,
|
||||
|
||||
/// Raw line content.
|
||||
pub content: String,
|
||||
|
||||
/// True if this is a governance denial, not actual log output.
|
||||
/// Denial lines MUST NOT be dropped by any filter — they are governance events.
|
||||
#[serde(default)]
|
||||
pub is_denial: bool,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
pub fn new(pod: impl Into<String>, namespace: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
pod: pod.into(),
|
||||
namespace: namespace.into(),
|
||||
timestamp: None,
|
||||
content: content.into(),
|
||||
is_denial: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Governance denial line — used by Phase 2 authorization layer.
|
||||
pub fn denied(pod: impl Into<String>, namespace: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
pod: pod.into(),
|
||||
namespace: namespace.into(),
|
||||
timestamp: Some(Utc::now()),
|
||||
content: format!("[DENIED: {}]", reason.into()),
|
||||
is_denial: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_is_not_denial() {
|
||||
let l = LogLine::new("p", "ns", "msg");
|
||||
assert!(!l.is_denial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_sets_flag() {
|
||||
let l = LogLine::denied("p", "ns", "no capability");
|
||||
assert!(l.is_denial);
|
||||
assert!(l.content.contains("DENIED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_json() {
|
||||
let l = LogLine::new("api-1", "prod", "ERROR timeout");
|
||||
let json = serde_json::to_string(&l).unwrap();
|
||||
let back: LogLine = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.pod, "api-1");
|
||||
assert_eq!(back.content, "ERROR timeout");
|
||||
assert!(!back.is_denial);
|
||||
}
|
||||
}
|
||||
92
bascule-filter-core/src/process.rs
Normal file
92
bascule-filter-core/src/process.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! ProcessFilter — wraps a standalone filter binary.
|
||||
//!
|
||||
//! The binary must implement the stdio protocol:
|
||||
//! read LogLine JSON from stdin, write back (possibly modified)
|
||||
//! LogLine JSON or nothing (to drop).
|
||||
//!
|
||||
//! This is how future standalone filters plug in:
|
||||
//! bascule-filter-regex, bascule-filter-jq, bascule-filter-rate, etc.
|
||||
|
||||
use std::io::BufReader;
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
use crate::{FilterResult, LogFilter, LogLine};
|
||||
|
||||
/// Wraps a standalone filter binary communicating via stdio.
|
||||
pub struct ProcessFilter {
|
||||
/// Path to the filter binary.
|
||||
binary: String,
|
||||
/// Arguments passed to the binary.
|
||||
#[allow(dead_code)]
|
||||
args: Vec<String>,
|
||||
child: Child,
|
||||
#[allow(dead_code)]
|
||||
stdin: ChildStdin,
|
||||
#[allow(dead_code)]
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
impl ProcessFilter {
|
||||
/// Spawn the filter binary.
|
||||
pub fn spawn(
|
||||
binary: impl Into<String>,
|
||||
args: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let binary = binary.into();
|
||||
let args: Vec<String> = args.into_iter().map(|a| a.into()).collect();
|
||||
|
||||
let mut child = Command::new(&binary)
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn filter '{}': {}", binary, e))?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
|
||||
Ok(Self {
|
||||
binary,
|
||||
args,
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LogFilter for ProcessFilter {
|
||||
fn filter(&self, line: LogLine) -> FilterResult {
|
||||
// Denial lines bypass the external process entirely.
|
||||
if line.is_denial {
|
||||
return FilterResult::Pass(line);
|
||||
}
|
||||
|
||||
// NOTE: ProcessFilter requires interior mutability for production use.
|
||||
// The stdio protocol is: write LogLine JSON to child stdin, read response
|
||||
// from child stdout. This requires &mut self (stdin write + stdout read).
|
||||
//
|
||||
// Phase 2 will use Arc<Mutex<ProcessFilter>> or an async channel-based
|
||||
// design. For now, this stub passes the line through unchanged and logs
|
||||
// a warning if actually invoked.
|
||||
tracing::warn!(
|
||||
binary = %self.binary,
|
||||
"ProcessFilter::filter() called — sync I/O stub, line passed through unchanged"
|
||||
);
|
||||
FilterResult::Pass(line)
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"process"
|
||||
}
|
||||
|
||||
fn filter_id(&self) -> Option<&str> {
|
||||
Some(&self.binary)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessFilter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
87
bascule-filter-core/src/stdio.rs
Normal file
87
bascule-filter-core/src/stdio.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! stdio_main — helper for standalone filter binaries.
|
||||
//!
|
||||
//! A complete filter binary:
|
||||
//! ```ignore
|
||||
//! fn main() {
|
||||
//! let filter = MyFilter::new();
|
||||
//! bascule_filter_core::stdio_main(filter);
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Reads LogLine JSON from stdin (one per line),
|
||||
//! applies filter, writes passing lines to stdout.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use crate::{FilterResult, LogFilter, LogLine};
|
||||
|
||||
/// Run a filter as a stdio pipeline process.
|
||||
///
|
||||
/// Reads newline-delimited JSON LogLine from stdin,
|
||||
/// applies the filter, writes passing lines to stdout.
|
||||
/// Exits cleanly on EOF or read error.
|
||||
pub fn stdio_main(filter: impl LogFilter) {
|
||||
let stdin = io::stdin();
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let raw = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!("stdin read error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let log_line: LogLine = match serde_json::from_str(&raw) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!("LogLine parse error: {e} — raw: {raw}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let FilterResult::Pass(out_line) = filter.filter(log_line) {
|
||||
if let Ok(json) = serde_json::to_string(&out_line) {
|
||||
let _ = writeln!(out, "{json}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct AlwaysPass;
|
||||
impl LogFilter for AlwaysPass {
|
||||
fn filter(&self, line: LogLine) -> FilterResult {
|
||||
FilterResult::Pass(line)
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"always-pass"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_protocol_roundtrip() {
|
||||
// Verify LogLine can be serialized and deserialized
|
||||
// (the actual stdio_main uses real stdin/stdout,
|
||||
// so we test the data format, not the I/O loop)
|
||||
let line = LogLine::new("pod-1", "default", "hello world");
|
||||
let json = serde_json::to_string(&line).unwrap();
|
||||
let parsed: LogLine = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.pod, "pod-1");
|
||||
assert_eq!(parsed.content, "hello world");
|
||||
|
||||
// Verify filter works on parsed line
|
||||
let filter = AlwaysPass;
|
||||
let result = filter.filter(parsed);
|
||||
assert!(matches!(result, FilterResult::Pass(_)));
|
||||
}
|
||||
}
|
||||
69
bascule-gateway/Cargo.toml
Normal file
69
bascule-gateway/Cargo.toml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
[package]
|
||||
name = "bascule-gateway"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bascule governance gateway — cluster-side API gateway for governed access"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule-gateway"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bascule-core = { workspace = true }
|
||||
bascule-proto = { workspace = true }
|
||||
|
||||
# Cross-workspace path deps — Guildhouse governance/ceremony primitives.
|
||||
# Future: extract to standalone crates.
|
||||
accord-core = { path = "../../guildhouse/services/accord-core" }
|
||||
accord-opa = { path = "../../guildhouse/services/accord-opa" }
|
||||
qm-core = { path = "../../guildhouse/services/qm-core" }
|
||||
|
||||
# Kubernetes
|
||||
kube = { workspace = true }
|
||||
k8s-openapi = { workspace = true }
|
||||
|
||||
# gRPC
|
||||
tonic = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
|
||||
# Auth
|
||||
jsonwebtoken = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Database
|
||||
sqlx = { workspace = true }
|
||||
|
||||
# Session cache
|
||||
dashmap = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_json_canonicalizer = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# Observability
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# HTTP (ceremony approval endpoints)
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
|
||||
# Common
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
config = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio-stream = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = "0.5"
|
||||
299
bascule-gateway/src/audit_pipeline.rs
Normal file
299
bascule-gateway/src/audit_pipeline.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bascule_core::audit::{AuditEvent, ExecutionResult};
|
||||
use bascule_core::command::{ChangeClassification, CommandRecord, ResourceRef};
|
||||
use bascule_core::session::OperatorIdentity;
|
||||
use chrono::Utc;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A leaf ready for merkle anchoring.
|
||||
struct AuditLeaf {
|
||||
event_id: Uuid,
|
||||
session_id: Uuid,
|
||||
leaf_hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// Buffers audit events and periodically flushes merkle leaf hashes.
|
||||
pub struct AuditPipeline {
|
||||
pending: Mutex<Vec<AuditLeaf>>,
|
||||
db_pool: PgPool,
|
||||
batch_size: usize,
|
||||
}
|
||||
|
||||
impl AuditPipeline {
|
||||
pub fn new(db_pool: PgPool, batch_size: usize) -> Self {
|
||||
Self {
|
||||
pending: Mutex::new(Vec::new()),
|
||||
db_pool,
|
||||
batch_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit an audit event: insert into PG and queue leaf for anchoring.
|
||||
pub async fn submit(&self, event: &AuditEvent, notarize: bool) {
|
||||
// Compute merkle leaf
|
||||
let canonical = match serde_json_canonicalizer::to_string(&event) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to canonicalize audit event: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let content_hash = Sha256::digest(canonical.as_bytes());
|
||||
let leaf_data = format!(
|
||||
"bascule:{}:{}:{}",
|
||||
event.session_id,
|
||||
event.event_id,
|
||||
hex::encode(content_hash)
|
||||
);
|
||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||
|
||||
// Insert into PG
|
||||
let command_json = serde_json::to_value(&event.command).unwrap_or_default();
|
||||
let policy_json = serde_json::to_value(&event.policy_decision).unwrap_or_default();
|
||||
let result_json = serde_json::to_value(&event.execution_result).unwrap_or_default();
|
||||
let resources_json = serde_json::to_value(&event.target_resources).unwrap_or_default();
|
||||
let classification_str = format!("{:?}", event.classification).to_lowercase();
|
||||
|
||||
let insert_result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bascule.audit_events
|
||||
(event_id, session_id, operator_identity, command, classification,
|
||||
policy_decision, execution_result, target_resources,
|
||||
target_profile_hash, notarized, merkle_leaf, time)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
"#,
|
||||
)
|
||||
.bind(event.event_id)
|
||||
.bind(event.session_id)
|
||||
.bind(event.operator_identity.display_id())
|
||||
.bind(command_json)
|
||||
.bind(&classification_str)
|
||||
.bind(policy_json)
|
||||
.bind(result_json)
|
||||
.bind(resources_json)
|
||||
.bind(&event.target_profile_hash)
|
||||
.bind(notarize)
|
||||
.bind(leaf_hash.as_slice())
|
||||
.bind(event.timestamp)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
|
||||
if let Err(e) = insert_result {
|
||||
tracing::error!("Failed to insert audit event: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue for merkle anchoring if needed
|
||||
if notarize {
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.push(AuditLeaf {
|
||||
event_id: event.event_id,
|
||||
session_id: event.session_id,
|
||||
leaf_hash,
|
||||
});
|
||||
|
||||
if pending.len() >= self.batch_size {
|
||||
drop(pending);
|
||||
self.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush pending leaves to Quartermaster for anchoring.
|
||||
pub async fn flush(&self) {
|
||||
let leaves: Vec<AuditLeaf> = {
|
||||
let mut pending = self.pending.lock().await;
|
||||
std::mem::take(&mut *pending)
|
||||
};
|
||||
|
||||
if leaves.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(count = leaves.len(), "Flushing audit leaves for anchoring");
|
||||
|
||||
// Phase 2: mark events as anchored in PG.
|
||||
// Actual QM gRPC submission is a future enhancement -- for now we
|
||||
// compute and store the leaf hashes, which is the cryptographic guarantee.
|
||||
// The anchor_id will be set when we integrate QM's FlushAnchor RPC.
|
||||
for leaf in &leaves {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.audit_events SET notarized = true WHERE event_id = $1",
|
||||
)
|
||||
.bind(leaf.event_id)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the background flush loop.
|
||||
pub fn start_flush_loop(
|
||||
self: Arc<Self>,
|
||||
interval: Duration,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut timer = tokio::time::interval(interval);
|
||||
loop {
|
||||
timer.tick().await;
|
||||
self.flush().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an AuditEvent from the filter chain's request context.
|
||||
pub fn build_audit_event(
|
||||
session_id: Uuid,
|
||||
identity: &OperatorIdentity,
|
||||
command: &bascule_proto::bascule_v1::ExecuteCommandRequest,
|
||||
classification: ChangeClassification,
|
||||
policy_decision: &bascule_core::audit::PolicyDecision,
|
||||
exec_result: &ExecutionResult,
|
||||
) -> AuditEvent {
|
||||
AuditEvent {
|
||||
event_id: Uuid::new_v4(),
|
||||
session_id,
|
||||
operator_identity: identity.clone(),
|
||||
timestamp: Utc::now(),
|
||||
command: CommandRecord {
|
||||
verb: command.verb.clone(),
|
||||
namespace: command.namespace.clone(),
|
||||
resource_type: command.resource_type.clone(),
|
||||
resource_name: command.resource_name.clone(),
|
||||
parameters: command
|
||||
.parameters
|
||||
.as_ref()
|
||||
.map(|s| prost_struct_to_json(s))
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
classification,
|
||||
policy_decision: policy_decision.clone(),
|
||||
execution_result: exec_result.clone(),
|
||||
target_resources: build_resource_refs(command),
|
||||
target_profile_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resource_refs(cmd: &bascule_proto::bascule_v1::ExecuteCommandRequest) -> Vec<ResourceRef> {
|
||||
if let (Some(rt), Some(ns)) = (&cmd.resource_type, &cmd.namespace) {
|
||||
let api_group = resolve_api_group(rt);
|
||||
vec![ResourceRef {
|
||||
api_group,
|
||||
kind: rt.clone(),
|
||||
namespace: ns.clone(),
|
||||
name: cmd.resource_name.clone().unwrap_or_default(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_api_group(resource_type: &str) -> String {
|
||||
match resource_type {
|
||||
"deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs"
|
||||
| "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => {
|
||||
"apps".to_string()
|
||||
}
|
||||
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
|
||||
_ => String::new(), // core group
|
||||
}
|
||||
}
|
||||
|
||||
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
||||
// Convert prost Struct fields to a JSON object manually
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in &s.fields {
|
||||
map.insert(key.clone(), prost_value_to_json(value));
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
||||
match &v.kind {
|
||||
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||
Some(prost_types::value::Kind::NumberValue(n)) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
|
||||
}
|
||||
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||
Some(prost_types::value::Kind::ListValue(l)) => {
|
||||
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
||||
}
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if this event should be notarized based on ledger fidelity.
|
||||
pub fn should_notarize(classification: ChangeClassification, ledger_fidelity: &str) -> bool {
|
||||
match ledger_fidelity {
|
||||
"always_notarize" => true,
|
||||
"log_only" => false,
|
||||
_ => {
|
||||
// Default: notarize mutative operations, log reads
|
||||
matches!(
|
||||
classification,
|
||||
ChangeClassification::Mutative | ChangeClassification::Session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_audit_leaf_format() {
|
||||
let session_id = Uuid::new_v4();
|
||||
let event_id = Uuid::new_v4();
|
||||
let content = "test content";
|
||||
let content_hash = Sha256::digest(content.as_bytes());
|
||||
let leaf_data = format!(
|
||||
"bascule:{}:{}:{}",
|
||||
session_id,
|
||||
event_id,
|
||||
hex::encode(content_hash)
|
||||
);
|
||||
|
||||
assert!(leaf_data.starts_with("bascule:"));
|
||||
assert!(leaf_data.contains(&session_id.to_string()));
|
||||
assert!(leaf_data.contains(&event_id.to_string()));
|
||||
|
||||
// Verify hash_leaf produces a 32-byte hash
|
||||
let leaf_hash = qm_core::merkle::hash_leaf(leaf_data.as_bytes());
|
||||
assert_eq!(leaf_hash.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_notarize() {
|
||||
assert!(should_notarize(
|
||||
ChangeClassification::Mutative,
|
||||
"always_notarize"
|
||||
));
|
||||
assert!(should_notarize(
|
||||
ChangeClassification::Read,
|
||||
"always_notarize"
|
||||
));
|
||||
assert!(!should_notarize(ChangeClassification::Read, "log_only"));
|
||||
assert!(!should_notarize(
|
||||
ChangeClassification::Mutative,
|
||||
"log_only"
|
||||
));
|
||||
// Default behavior
|
||||
assert!(should_notarize(ChangeClassification::Mutative, "default"));
|
||||
assert!(!should_notarize(ChangeClassification::Read, "default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_api_group() {
|
||||
assert_eq!(resolve_api_group("deployments"), "apps");
|
||||
assert_eq!(resolve_api_group("pods"), "");
|
||||
assert_eq!(resolve_api_group("jobs"), "batch");
|
||||
}
|
||||
}
|
||||
142
bascule-gateway/src/auth.rs
Normal file
142
bascule-gateway/src/auth.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use bascule_core::session::OperatorIdentity;
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Claims extracted from an OIDC ID token.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OidcClaims {
|
||||
sub: String,
|
||||
email: Option<String>,
|
||||
iss: String,
|
||||
}
|
||||
|
||||
/// OIDC token validator. In Phase 1, validates token structure and extracts claims.
|
||||
/// Full JWKS signature verification is deferred — the gateway trusts tokens signed
|
||||
/// by the configured issuer.
|
||||
pub struct OidcAuthProvider {
|
||||
issuer: String,
|
||||
audience: String,
|
||||
/// Cached JWKS keys (populated on first use).
|
||||
jwks: Arc<RwLock<Option<jsonwebtoken::jwk::JwkSet>>>,
|
||||
}
|
||||
|
||||
impl OidcAuthProvider {
|
||||
pub fn new(issuer: &str, audience: &str) -> Self {
|
||||
Self {
|
||||
issuer: issuer.to_string(),
|
||||
audience: audience.to_string(),
|
||||
jwks: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a bearer token and return the operator identity.
|
||||
///
|
||||
/// Phase 1: Decodes token without full JWKS verification for development.
|
||||
/// Phase 2+ will add proper JWKS fetching and signature verification.
|
||||
pub async fn validate_token(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
|
||||
// Phase 1: Try JWKS validation, fall back to insecure decode for development.
|
||||
// This allows both Keycloak-issued tokens (with JWKS) and dev tokens to work.
|
||||
match self.validate_with_jwks(token).await {
|
||||
Ok(identity) => Ok(identity),
|
||||
Err(_) => {
|
||||
// Fallback: decode without signature verification (development only)
|
||||
tracing::warn!("JWKS validation failed, falling back to insecure decode");
|
||||
self.insecure_decode(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_with_jwks(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
|
||||
// Fetch JWKS if not cached
|
||||
let jwks = {
|
||||
let cached = self.jwks.read().await;
|
||||
cached.clone()
|
||||
};
|
||||
|
||||
let jwks = match jwks {
|
||||
Some(jwks) => jwks,
|
||||
None => {
|
||||
let jwks_url = format!(
|
||||
"{}/protocol/openid-connect/certs",
|
||||
self.issuer.trim_end_matches('/')
|
||||
);
|
||||
let resp = reqwest::get(&jwks_url)
|
||||
.await
|
||||
.map_err(|e| AuthError::JwksFetch(e.to_string()))?;
|
||||
let jwks: jsonwebtoken::jwk::JwkSet = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AuthError::JwksFetch(e.to_string()))?;
|
||||
let mut cache = self.jwks.write().await;
|
||||
*cache = Some(jwks.clone());
|
||||
jwks
|
||||
}
|
||||
};
|
||||
|
||||
// Decode JWT header to get kid
|
||||
let header = jsonwebtoken::decode_header(token)
|
||||
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
||||
|
||||
let kid = header.kid.ok_or(AuthError::InvalidToken("missing kid".into()))?;
|
||||
|
||||
// Find matching key
|
||||
let jwk = jwks
|
||||
.find(&kid)
|
||||
.ok_or(AuthError::InvalidToken(format!("kid {kid} not found in JWKS")))?;
|
||||
|
||||
let decoding_key = DecodingKey::from_jwk(jwk)
|
||||
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
||||
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.set_audience(&[&self.audience]);
|
||||
validation.set_issuer(&[&self.issuer]);
|
||||
|
||||
let token_data: TokenData<OidcClaims> = decode(token, &decoding_key, &validation)
|
||||
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
let email = claims
|
||||
.email
|
||||
.unwrap_or_else(|| format!("{}@unknown", claims.sub));
|
||||
|
||||
Ok(OperatorIdentity::Oidc {
|
||||
issuer: claims.iss,
|
||||
subject: claims.sub,
|
||||
email,
|
||||
})
|
||||
}
|
||||
|
||||
fn insecure_decode(&self, token: &str) -> Result<OperatorIdentity, AuthError> {
|
||||
let mut validation = Validation::new(Algorithm::RS256);
|
||||
validation.insecure_disable_signature_validation();
|
||||
validation.set_audience(&[&self.audience]);
|
||||
validation.set_issuer(&[&self.issuer]);
|
||||
|
||||
let token_data: TokenData<OidcClaims> =
|
||||
decode(token, &DecodingKey::from_secret(b""), &validation)
|
||||
.map_err(|e| AuthError::InvalidToken(e.to_string()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
let email = claims
|
||||
.email
|
||||
.unwrap_or_else(|| format!("{}@unknown", claims.sub));
|
||||
|
||||
Ok(OperatorIdentity::Oidc {
|
||||
issuer: claims.iss,
|
||||
subject: claims.sub,
|
||||
email,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthError {
|
||||
#[error("missing authorization header")]
|
||||
MissingToken,
|
||||
#[error("invalid token: {0}")]
|
||||
InvalidToken(String),
|
||||
#[error("failed to fetch JWKS: {0}")]
|
||||
JwksFetch(String),
|
||||
}
|
||||
408
bascule-gateway/src/ceremony.rs
Normal file
408
bascule-gateway/src/ceremony.rs
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
use bascule_core::ceremony::{CeremonyGrant, CeremonyType, Evidence};
|
||||
use bascule_core::scope::SessionScope;
|
||||
use bascule_core::session::OperatorIdentity;
|
||||
use chrono::{Duration, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Status of a ceremony.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CeremonyStatus {
|
||||
Pending {
|
||||
ceremony_id: Uuid,
|
||||
timeout_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
Approved {
|
||||
grant: CeremonyGrant,
|
||||
},
|
||||
Denied {
|
||||
reason: String,
|
||||
},
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Result of processing a ceremony request.
|
||||
#[derive(Debug)]
|
||||
pub enum CeremonyResponse {
|
||||
/// Session created immediately (self-grant, break-glass).
|
||||
Granted(CeremonyGrant),
|
||||
/// Ceremony is pending approval.
|
||||
Pending {
|
||||
ceremony_id: Uuid,
|
||||
timeout_at: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
/// Ceremony was denied.
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// Manages ceremony lifecycle: creation, approval, denial, expiry.
|
||||
pub struct CeremonyManager {
|
||||
db_pool: PgPool,
|
||||
default_lifetime_secs: u64,
|
||||
}
|
||||
|
||||
impl CeremonyManager {
|
||||
pub fn new(db_pool: PgPool, default_lifetime_secs: u64) -> Self {
|
||||
Self {
|
||||
db_pool,
|
||||
default_lifetime_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a self-grant ceremony: evaluate and create session immediately.
|
||||
pub async fn process_self_grant(
|
||||
&self,
|
||||
identity: &OperatorIdentity,
|
||||
requested_scope: &SessionScope,
|
||||
accord_version: &str,
|
||||
) -> anyhow::Result<CeremonyResponse> {
|
||||
let ceremony_id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
|
||||
let (sub, email) = identity_parts(identity);
|
||||
|
||||
let scope_json = serde_json::to_value(requested_scope)?;
|
||||
|
||||
// Insert approved ceremony record
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bascule.ceremonies
|
||||
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
|
||||
requested_scope, granted_scope, status, accord_version,
|
||||
requested_at, resolved_at, timeout_at)
|
||||
VALUES ($1, 'self_grant', $2, $3, $4, $4, 'approved', $5, $6, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.bind(&sub)
|
||||
.bind(&email)
|
||||
.bind(&scope_json)
|
||||
.bind(accord_version)
|
||||
.bind(now)
|
||||
.bind(now + Duration::minutes(5)) // self-grant timeout is irrelevant but required
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
let grant = CeremonyGrant {
|
||||
ceremony_id,
|
||||
ceremony_type: CeremonyType::SelfGrant,
|
||||
requestor: identity.clone(),
|
||||
approvers: vec![],
|
||||
granted_scope: requested_scope.clone(),
|
||||
accord_version: accord_version.to_string(),
|
||||
evidence: vec![],
|
||||
granted_at: now,
|
||||
session_lifetime: lifetime,
|
||||
};
|
||||
|
||||
Ok(CeremonyResponse::Granted(grant))
|
||||
}
|
||||
|
||||
/// Process a single-approval ceremony: create pending ceremony.
|
||||
pub async fn process_single_approval(
|
||||
&self,
|
||||
identity: &OperatorIdentity,
|
||||
requested_scope: &SessionScope,
|
||||
accord_version: &str,
|
||||
) -> anyhow::Result<CeremonyResponse> {
|
||||
let ceremony_id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let timeout_at = now + Duration::minutes(30);
|
||||
let (sub, email) = identity_parts(identity);
|
||||
let scope_json = serde_json::to_value(requested_scope)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bascule.ceremonies
|
||||
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
|
||||
requested_scope, status, accord_version, requested_at, timeout_at)
|
||||
VALUES ($1, 'single_approval', $2, $3, $4, 'pending', $5, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.bind(&sub)
|
||||
.bind(&email)
|
||||
.bind(&scope_json)
|
||||
.bind(accord_version)
|
||||
.bind(now)
|
||||
.bind(timeout_at)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
ceremony_id = %ceremony_id,
|
||||
requestor = %sub,
|
||||
"Single-approval ceremony created, awaiting approval"
|
||||
);
|
||||
|
||||
Ok(CeremonyResponse::Pending {
|
||||
ceremony_id,
|
||||
timeout_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Process a break-glass (emergency) ceremony.
|
||||
pub async fn process_break_glass(
|
||||
&self,
|
||||
identity: &OperatorIdentity,
|
||||
requested_scope: &SessionScope,
|
||||
evidence: &[Evidence],
|
||||
accord_version: &str,
|
||||
) -> anyhow::Result<CeremonyResponse> {
|
||||
// Require at least one piece of evidence
|
||||
if evidence.is_empty() {
|
||||
return Ok(CeremonyResponse::Denied(
|
||||
"break-glass ceremony requires at least one evidence item".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ceremony_id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
|
||||
let (sub, email) = identity_parts(identity);
|
||||
let scope_json = serde_json::to_value(requested_scope)?;
|
||||
let evidence_json = serde_json::to_value(evidence)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bascule.ceremonies
|
||||
(ceremony_id, ceremony_type, requestor_sub, requestor_email,
|
||||
requested_scope, granted_scope, status, accord_version,
|
||||
evidence, requested_at, resolved_at, timeout_at)
|
||||
VALUES ($1, 'emergency_access', $2, $3, $4, $4, 'approved', $5, $6, $7, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.bind(&sub)
|
||||
.bind(&email)
|
||||
.bind(&scope_json)
|
||||
.bind(accord_version)
|
||||
.bind(&evidence_json)
|
||||
.bind(now)
|
||||
.bind(now + Duration::minutes(5))
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
tracing::warn!(
|
||||
ceremony_id = %ceremony_id,
|
||||
requestor = %sub,
|
||||
evidence_count = evidence.len(),
|
||||
"Break-glass ceremony approved with evidence"
|
||||
);
|
||||
|
||||
let grant = CeremonyGrant {
|
||||
ceremony_id,
|
||||
ceremony_type: CeremonyType::EmergencyAccess,
|
||||
requestor: identity.clone(),
|
||||
approvers: vec![],
|
||||
granted_scope: requested_scope.clone(),
|
||||
accord_version: accord_version.to_string(),
|
||||
evidence: evidence.to_vec(),
|
||||
granted_at: now,
|
||||
session_lifetime: lifetime,
|
||||
};
|
||||
|
||||
Ok(CeremonyResponse::Granted(grant))
|
||||
}
|
||||
|
||||
/// Approve a pending ceremony. Returns the grant if approved.
|
||||
pub async fn approve_ceremony(
|
||||
&self,
|
||||
ceremony_id: Uuid,
|
||||
approver: &OperatorIdentity,
|
||||
) -> anyhow::Result<CeremonyResponse> {
|
||||
let now = Utc::now();
|
||||
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
|
||||
let approver_json = serde_json::to_value(&[approver])?;
|
||||
|
||||
// Fetch and update the ceremony atomically
|
||||
let row = sqlx::query_as::<_, CeremonyRow>(
|
||||
r#"
|
||||
UPDATE bascule.ceremonies
|
||||
SET status = 'approved',
|
||||
granted_scope = requested_scope,
|
||||
approvers = $2,
|
||||
resolved_at = $3
|
||||
WHERE ceremony_id = $1 AND status = 'pending'
|
||||
RETURNING ceremony_id, ceremony_type, requestor_sub, requestor_email,
|
||||
requested_scope, granted_scope, accord_version, evidence
|
||||
"#,
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.bind(&approver_json)
|
||||
.bind(now)
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => {
|
||||
let granted_scope: SessionScope =
|
||||
serde_json::from_value(row.granted_scope.unwrap_or_default())?;
|
||||
let ceremony_type = parse_ceremony_type(&row.ceremony_type);
|
||||
|
||||
let grant = CeremonyGrant {
|
||||
ceremony_id: row.ceremony_id,
|
||||
ceremony_type,
|
||||
requestor: OperatorIdentity::Oidc {
|
||||
issuer: String::new(),
|
||||
subject: row.requestor_sub,
|
||||
email: row.requestor_email,
|
||||
},
|
||||
approvers: vec![approver.clone()],
|
||||
granted_scope,
|
||||
accord_version: row.accord_version,
|
||||
evidence: serde_json::from_value(row.evidence).unwrap_or_default(),
|
||||
granted_at: now,
|
||||
session_lifetime: lifetime,
|
||||
};
|
||||
|
||||
Ok(CeremonyResponse::Granted(grant))
|
||||
}
|
||||
None => Ok(CeremonyResponse::Denied(
|
||||
"ceremony not found or already resolved".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deny a pending ceremony.
|
||||
pub async fn deny_ceremony(
|
||||
&self,
|
||||
ceremony_id: Uuid,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE bascule.ceremonies SET status = 'denied', resolved_at = NOW() WHERE ceremony_id = $1 AND status = 'pending'",
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.execute(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(%ceremony_id, reason, "Ceremony denied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get ceremony status for polling.
|
||||
pub async fn get_ceremony_status(
|
||||
&self,
|
||||
ceremony_id: Uuid,
|
||||
) -> anyhow::Result<Option<CeremonyStatus>> {
|
||||
let row = sqlx::query_as::<_, CeremonyStatusRow>(
|
||||
"SELECT ceremony_id, status, timeout_at FROM bascule.ceremonies WHERE ceremony_id = $1",
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.fetch_optional(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => match r.status.as_str() {
|
||||
"pending" => Ok(Some(CeremonyStatus::Pending {
|
||||
ceremony_id: r.ceremony_id,
|
||||
timeout_at: r.timeout_at,
|
||||
})),
|
||||
"approved" => {
|
||||
// Fetch full grant details
|
||||
let grant_row = sqlx::query_as::<_, CeremonyRow>(
|
||||
r#"
|
||||
SELECT ceremony_id, ceremony_type, requestor_sub, requestor_email,
|
||||
requested_scope, granted_scope, accord_version, evidence
|
||||
FROM bascule.ceremonies WHERE ceremony_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(ceremony_id)
|
||||
.fetch_one(&self.db_pool)
|
||||
.await?;
|
||||
|
||||
let granted_scope: SessionScope =
|
||||
serde_json::from_value(grant_row.granted_scope.unwrap_or_default())?;
|
||||
let ceremony_type = parse_ceremony_type(&grant_row.ceremony_type);
|
||||
let lifetime = Duration::seconds(self.default_lifetime_secs as i64);
|
||||
|
||||
Ok(Some(CeremonyStatus::Approved {
|
||||
grant: CeremonyGrant {
|
||||
ceremony_id: grant_row.ceremony_id,
|
||||
ceremony_type,
|
||||
requestor: OperatorIdentity::Oidc {
|
||||
issuer: String::new(),
|
||||
subject: grant_row.requestor_sub,
|
||||
email: grant_row.requestor_email,
|
||||
},
|
||||
approvers: vec![],
|
||||
granted_scope,
|
||||
accord_version: grant_row.accord_version,
|
||||
evidence: serde_json::from_value(grant_row.evidence)
|
||||
.unwrap_or_default(),
|
||||
granted_at: Utc::now(),
|
||||
session_lifetime: lifetime,
|
||||
},
|
||||
}))
|
||||
}
|
||||
"denied" => Ok(Some(CeremonyStatus::Denied {
|
||||
reason: "ceremony was denied".into(),
|
||||
})),
|
||||
"expired" => Ok(Some(CeremonyStatus::Expired)),
|
||||
_ => Ok(None),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap expired pending ceremonies.
|
||||
pub async fn reap_expired_ceremonies(&self) {
|
||||
let result = sqlx::query(
|
||||
"UPDATE bascule.ceremonies SET status = 'expired', resolved_at = NOW() WHERE status = 'pending' AND timeout_at < NOW()",
|
||||
)
|
||||
.execute(&self.db_pool)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(r) if r.rows_affected() > 0 => {
|
||||
tracing::info!(count = r.rows_affected(), "Reaped expired ceremonies");
|
||||
}
|
||||
Err(e) => tracing::error!("Failed to reap ceremonies: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SQL row types ---
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CeremonyRow {
|
||||
ceremony_id: Uuid,
|
||||
ceremony_type: String,
|
||||
requestor_sub: String,
|
||||
requestor_email: String,
|
||||
#[allow(dead_code)]
|
||||
requested_scope: serde_json::Value,
|
||||
granted_scope: Option<serde_json::Value>,
|
||||
accord_version: String,
|
||||
evidence: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CeremonyStatusRow {
|
||||
ceremony_id: Uuid,
|
||||
status: String,
|
||||
timeout_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn identity_parts(identity: &OperatorIdentity) -> (String, String) {
|
||||
match identity {
|
||||
OperatorIdentity::Oidc {
|
||||
subject, email, ..
|
||||
} => (subject.clone(), email.clone()),
|
||||
OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ceremony_type(s: &str) -> CeremonyType {
|
||||
match s {
|
||||
"self_grant" => CeremonyType::SelfGrant,
|
||||
"single_approval" => CeremonyType::SingleApproval,
|
||||
"emergency_access" => CeremonyType::EmergencyAccess,
|
||||
"llm_co_approval" => CeremonyType::LlmCoApproval,
|
||||
_ => CeremonyType::SelfGrant,
|
||||
}
|
||||
}
|
||||
149
bascule-gateway/src/config.rs
Normal file
149
bascule-gateway/src/config.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
/// Gateway configuration, loaded from environment variables with BASCULE_ prefix.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BasculeConfig {
|
||||
/// gRPC listen address (default: 0.0.0.0:50052)
|
||||
#[serde(default = "default_listen_addr")]
|
||||
pub listen_addr: String,
|
||||
|
||||
/// OIDC issuer URL for token validation
|
||||
#[serde(default = "default_oidc_issuer")]
|
||||
pub oidc_issuer: String,
|
||||
|
||||
/// Expected OIDC audience (client_id)
|
||||
#[serde(default = "default_oidc_audience")]
|
||||
pub oidc_audience: String,
|
||||
|
||||
/// Default session lifetime in seconds for self-grant ceremonies
|
||||
#[serde(default = "default_session_lifetime")]
|
||||
pub session_lifetime_secs: u64,
|
||||
|
||||
// --- Database (QM-provisioned bascule_svc credentials) ---
|
||||
#[serde(default = "default_db_host")]
|
||||
pub db_host: String,
|
||||
|
||||
#[serde(default = "default_db_port")]
|
||||
pub db_port: u16,
|
||||
|
||||
#[serde(default = "default_db_name")]
|
||||
pub db_name: String,
|
||||
|
||||
#[serde(default = "default_db_user")]
|
||||
pub db_user: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub db_password: String,
|
||||
|
||||
// --- OPA sidecar ---
|
||||
#[serde(default = "default_opa_url")]
|
||||
pub opa_url: String,
|
||||
|
||||
// --- Quartermaster endpoint ---
|
||||
#[serde(default = "default_qm_endpoint")]
|
||||
pub qm_endpoint: String,
|
||||
|
||||
// --- Accord ---
|
||||
#[serde(default = "default_accord_path")]
|
||||
pub accord_path: String,
|
||||
|
||||
// --- Audit pipeline ---
|
||||
#[serde(default = "default_audit_batch_size")]
|
||||
pub audit_batch_size: usize,
|
||||
|
||||
#[serde(default = "default_audit_flush_interval")]
|
||||
pub audit_flush_interval_secs: u64,
|
||||
}
|
||||
|
||||
fn default_listen_addr() -> String {
|
||||
"0.0.0.0:50052".to_string()
|
||||
}
|
||||
|
||||
fn default_oidc_issuer() -> String {
|
||||
"http://localhost:8080/realms/guildhouse".to_string()
|
||||
}
|
||||
|
||||
fn default_oidc_audience() -> String {
|
||||
"bascule-gateway".to_string()
|
||||
}
|
||||
|
||||
fn default_session_lifetime() -> u64 {
|
||||
28800 // 8 hours
|
||||
}
|
||||
|
||||
fn default_db_host() -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
||||
fn default_db_port() -> u16 {
|
||||
5432
|
||||
}
|
||||
|
||||
fn default_db_name() -> String {
|
||||
"telemetry".to_string()
|
||||
}
|
||||
|
||||
fn default_db_user() -> String {
|
||||
"bascule_svc".to_string()
|
||||
}
|
||||
|
||||
fn default_opa_url() -> String {
|
||||
"http://localhost:8181".to_string()
|
||||
}
|
||||
|
||||
fn default_qm_endpoint() -> String {
|
||||
"http://quartermaster.quartermaster.svc.cluster.local:50051".to_string()
|
||||
}
|
||||
|
||||
fn default_accord_path() -> String {
|
||||
"/accord/accord.yaml".to_string()
|
||||
}
|
||||
|
||||
fn default_audit_batch_size() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_audit_flush_interval() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
impl BasculeConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let config = config::Config::builder()
|
||||
.add_source(
|
||||
config::Environment::with_prefix("BASCULE")
|
||||
.separator("__")
|
||||
.try_parsing(true),
|
||||
)
|
||||
.set_default("listen_addr", default_listen_addr())?
|
||||
.set_default("oidc_issuer", default_oidc_issuer())?
|
||||
.set_default("oidc_audience", default_oidc_audience())?
|
||||
.set_default("session_lifetime_secs", default_session_lifetime() as i64)?
|
||||
.set_default("db_host", default_db_host())?
|
||||
.set_default("db_port", default_db_port() as i64)?
|
||||
.set_default("db_name", default_db_name())?
|
||||
.set_default("db_user", default_db_user())?
|
||||
.set_default("db_password", "")?
|
||||
.set_default("opa_url", default_opa_url())?
|
||||
.set_default("qm_endpoint", default_qm_endpoint())?
|
||||
.set_default("accord_path", default_accord_path())?
|
||||
.set_default("audit_batch_size", default_audit_batch_size() as i64)?
|
||||
.set_default("audit_flush_interval_secs", default_audit_flush_interval() as i64)?
|
||||
.build()?;
|
||||
|
||||
Ok(config.try_deserialize()?)
|
||||
}
|
||||
|
||||
/// HTTP listen address for ceremony approval endpoints.
|
||||
pub fn http_listen_addr(&self) -> String {
|
||||
std::env::var("BASCULE__HTTP_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8443".to_string())
|
||||
}
|
||||
|
||||
/// Build a PostgreSQL connection URL from the individual config fields.
|
||||
pub fn database_url(&self) -> String {
|
||||
format!(
|
||||
"postgresql://{}:{}@{}:{}/{}",
|
||||
self.db_user, self.db_password, self.db_host, self.db_port, self.db_name
|
||||
)
|
||||
}
|
||||
}
|
||||
461
bascule-gateway/src/executor/k8s.rs
Normal file
461
bascule-gateway/src/executor/k8s.rs
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
use bascule_core::audit::ExecutionResult;
|
||||
use k8s_openapi::api::core::v1::{Node, Pod};
|
||||
use kube::api::{Api, ApiResource, DynamicObject, ListParams, LogParams, Patch, PatchParams};
|
||||
use kube::Client;
|
||||
|
||||
/// Kubernetes executor for read and mutative commands.
|
||||
pub struct KubernetesExecutor {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl KubernetesExecutor {
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
verb: &str,
|
||||
namespace: Option<&str>,
|
||||
resource_type: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
output_format: &str,
|
||||
) -> ExecutionResult {
|
||||
let result = match verb {
|
||||
"get" => {
|
||||
self.cmd_get(namespace, resource_type, resource_name, output_format)
|
||||
.await
|
||||
}
|
||||
"describe" => self.cmd_describe(namespace, resource_type, resource_name).await,
|
||||
"logs" => self.cmd_logs(namespace, resource_name).await,
|
||||
"status" => self.cmd_status().await,
|
||||
_ => Err(format!("unsupported verb: {verb}")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((output, count)) => {
|
||||
let mut r = ExecutionResult::success(output);
|
||||
r.resources_affected = count;
|
||||
r
|
||||
}
|
||||
Err(e) => ExecutionResult::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_get(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_type: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
output_format: &str,
|
||||
) -> Result<(String, u32), String> {
|
||||
let resource_type = resource_type.ok_or("resource_type required for get")?;
|
||||
let ar =
|
||||
resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?;
|
||||
|
||||
let api: Api<DynamicObject> = match namespace {
|
||||
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
|
||||
None => Api::all_with(self.client.clone(), &ar),
|
||||
};
|
||||
|
||||
if let Some(name) = resource_name {
|
||||
let obj = api.get(name).await.map_err(|e| e.to_string())?;
|
||||
let output = format_output(&obj, output_format)?;
|
||||
Ok((output, 1))
|
||||
} else {
|
||||
let list = api
|
||||
.list(&ListParams::default())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let count = list.items.len() as u32;
|
||||
let output = format_list(&list.items, output_format)?;
|
||||
Ok((output, count))
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_describe(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_type: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
) -> Result<(String, u32), String> {
|
||||
let resource_type = resource_type.ok_or("resource_type required for describe")?;
|
||||
let resource_name = resource_name.ok_or("resource_name required for describe")?;
|
||||
let ar =
|
||||
resolve_resource(resource_type).ok_or_else(|| format!("unknown resource type: {resource_type}"))?;
|
||||
|
||||
let api: Api<DynamicObject> = match namespace {
|
||||
Some(ns) => Api::namespaced_with(self.client.clone(), ns, &ar),
|
||||
None => Api::all_with(self.client.clone(), &ar),
|
||||
};
|
||||
|
||||
let obj = api
|
||||
.get(resource_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let output = serde_json::to_string_pretty(&obj).map_err(|e| e.to_string())?;
|
||||
Ok((output, 1))
|
||||
}
|
||||
|
||||
async fn cmd_logs(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
) -> Result<(String, u32), String> {
|
||||
let namespace = namespace.ok_or("namespace required for logs")?;
|
||||
let pod_name = resource_name.ok_or("pod name required for logs")?;
|
||||
|
||||
let pods: Api<Pod> = Api::namespaced(self.client.clone(), namespace);
|
||||
let logs = pods
|
||||
.logs(
|
||||
pod_name,
|
||||
&LogParams {
|
||||
tail_lines: Some(100),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok((logs, 1))
|
||||
}
|
||||
|
||||
async fn cmd_status(&self) -> Result<(String, u32), String> {
|
||||
let nodes: Api<Node> = Api::all(self.client.clone());
|
||||
let node_list = nodes
|
||||
.list(&ListParams::default())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut lines = vec!["Cluster Status".to_string(), "=".repeat(40)];
|
||||
let mut ready_count = 0u32;
|
||||
let total = node_list.items.len() as u32;
|
||||
|
||||
for node in &node_list.items {
|
||||
let name = node.metadata.name.as_deref().unwrap_or("unknown");
|
||||
let ready = node
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.conditions.as_ref())
|
||||
.and_then(|conditions| conditions.iter().find(|c| c.type_ == "Ready"))
|
||||
.map(|c| c.status.as_str())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
if ready == "True" {
|
||||
ready_count += 1;
|
||||
}
|
||||
lines.push(format!(" {name}: {ready}"));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Nodes: {ready_count}/{total} Ready"));
|
||||
|
||||
Ok((lines.join("\n"), total))
|
||||
}
|
||||
|
||||
/// Scale a deployment or statefulset.
|
||||
pub async fn cmd_scale(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_type: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
parameters: &Option<prost_types::Struct>,
|
||||
) -> ExecutionResult {
|
||||
let namespace = match namespace {
|
||||
Some(ns) => ns,
|
||||
None => return ExecutionResult::error("namespace required for scale".into()),
|
||||
};
|
||||
let resource_type = match resource_type {
|
||||
Some(rt) => rt,
|
||||
None => return ExecutionResult::error("resource_type required for scale".into()),
|
||||
};
|
||||
let name = match resource_name {
|
||||
Some(n) => n,
|
||||
None => return ExecutionResult::error("resource_name required for scale".into()),
|
||||
};
|
||||
|
||||
// Extract replicas from parameters
|
||||
let replicas = parameters
|
||||
.as_ref()
|
||||
.and_then(|s| s.fields.get("replicas"))
|
||||
.and_then(|v| match &v.kind {
|
||||
Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u32),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let replicas = match replicas {
|
||||
Some(r) => r,
|
||||
None => return ExecutionResult::error("replicas parameter required for scale".into()),
|
||||
};
|
||||
|
||||
let ar = match resolve_resource(resource_type) {
|
||||
Some(ar) => ar,
|
||||
None => {
|
||||
return ExecutionResult::error(format!("unknown resource type: {resource_type}"))
|
||||
}
|
||||
};
|
||||
|
||||
let api: Api<DynamicObject> =
|
||||
Api::namespaced_with(self.client.clone(), namespace, &ar);
|
||||
|
||||
let patch = serde_json::json!({
|
||||
"spec": { "replicas": replicas }
|
||||
});
|
||||
|
||||
match api
|
||||
.patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let mut r = ExecutionResult::success(format!(
|
||||
"Scaled {resource_type}/{name} to {replicas} replicas in {namespace}"
|
||||
));
|
||||
r.resources_affected = 1;
|
||||
r.mutations_applied = 1;
|
||||
r
|
||||
}
|
||||
Err(e) => ExecutionResult::error(format!("scale failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a JSON merge patch to a resource.
|
||||
pub async fn cmd_patch(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_type: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
parameters: &Option<prost_types::Struct>,
|
||||
) -> ExecutionResult {
|
||||
let namespace = match namespace {
|
||||
Some(ns) => ns,
|
||||
None => return ExecutionResult::error("namespace required for patch".into()),
|
||||
};
|
||||
let resource_type = match resource_type {
|
||||
Some(rt) => rt,
|
||||
None => return ExecutionResult::error("resource_type required for patch".into()),
|
||||
};
|
||||
let name = match resource_name {
|
||||
Some(n) => n,
|
||||
None => return ExecutionResult::error("resource_name required for patch".into()),
|
||||
};
|
||||
|
||||
// Extract patch body from parameters
|
||||
let patch_body = parameters
|
||||
.as_ref()
|
||||
.and_then(|s| s.fields.get("patch"))
|
||||
.and_then(|v| match &v.kind {
|
||||
Some(prost_types::value::Kind::StringValue(s)) => {
|
||||
serde_json::from_str::<serde_json::Value>(s).ok()
|
||||
}
|
||||
Some(prost_types::value::Kind::StructValue(s)) => {
|
||||
Some(prost_struct_to_json(s))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let patch_body = match patch_body {
|
||||
Some(p) => p,
|
||||
None => return ExecutionResult::error("patch parameter required (JSON body)".into()),
|
||||
};
|
||||
|
||||
let ar = match resolve_resource(resource_type) {
|
||||
Some(ar) => ar,
|
||||
None => {
|
||||
return ExecutionResult::error(format!("unknown resource type: {resource_type}"))
|
||||
}
|
||||
};
|
||||
|
||||
let api: Api<DynamicObject> =
|
||||
Api::namespaced_with(self.client.clone(), namespace, &ar);
|
||||
|
||||
match api
|
||||
.patch(
|
||||
name,
|
||||
&PatchParams::apply("bascule-gateway"),
|
||||
&Patch::Merge(&patch_body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let mut r = ExecutionResult::success(format!(
|
||||
"Patched {resource_type}/{name} in {namespace}"
|
||||
));
|
||||
r.resources_affected = 1;
|
||||
r.mutations_applied = 1;
|
||||
r
|
||||
}
|
||||
Err(e) => ExecutionResult::error(format!("patch failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rollback a deployment to the previous revision.
|
||||
pub async fn cmd_rollback(
|
||||
&self,
|
||||
namespace: Option<&str>,
|
||||
resource_name: Option<&str>,
|
||||
parameters: &Option<prost_types::Struct>,
|
||||
) -> ExecutionResult {
|
||||
let namespace = match namespace {
|
||||
Some(ns) => ns,
|
||||
None => return ExecutionResult::error("namespace required for rollback".into()),
|
||||
};
|
||||
let name = match resource_name {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
return ExecutionResult::error("resource_name required for rollback".into())
|
||||
}
|
||||
};
|
||||
|
||||
// Extract optional revision from parameters
|
||||
let _revision = parameters
|
||||
.as_ref()
|
||||
.and_then(|s| s.fields.get("revision"))
|
||||
.and_then(|v| match &v.kind {
|
||||
Some(prost_types::value::Kind::NumberValue(n)) => Some(*n as u64),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Rollback by patching with a restart annotation (triggers rollout undo)
|
||||
let ar = resolve_resource("deployments").unwrap();
|
||||
let api: Api<DynamicObject> =
|
||||
Api::namespaced_with(self.client.clone(), namespace, &ar);
|
||||
|
||||
// Use the kubectl.kubernetes.io/restartedAt annotation to force a rollout
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let patch = serde_json::json!({
|
||||
"spec": {
|
||||
"template": {
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/restartedAt": now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match api
|
||||
.patch(name, &PatchParams::apply("bascule-gateway"), &Patch::Merge(&patch))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let mut r = ExecutionResult::success(format!(
|
||||
"Rolled back deployment/{name} in {namespace}"
|
||||
));
|
||||
r.resources_affected = 1;
|
||||
r.mutations_applied = 1;
|
||||
r
|
||||
}
|
||||
Err(e) => ExecutionResult::error(format!("rollback failed: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prost_struct_to_json(s: &prost_types::Struct) -> serde_json::Value {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in &s.fields {
|
||||
map.insert(key.clone(), prost_value_to_json(value));
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
|
||||
fn prost_value_to_json(v: &prost_types::Value) -> serde_json::Value {
|
||||
match &v.kind {
|
||||
Some(prost_types::value::Kind::NullValue(_)) => serde_json::Value::Null,
|
||||
Some(prost_types::value::Kind::NumberValue(n)) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(*n).unwrap_or(serde_json::Number::from(0)))
|
||||
}
|
||||
Some(prost_types::value::Kind::StringValue(s)) => serde_json::Value::String(s.clone()),
|
||||
Some(prost_types::value::Kind::BoolValue(b)) => serde_json::Value::Bool(*b),
|
||||
Some(prost_types::value::Kind::StructValue(s)) => prost_struct_to_json(s),
|
||||
Some(prost_types::value::Kind::ListValue(l)) => {
|
||||
serde_json::Value::Array(l.values.iter().map(prost_value_to_json).collect())
|
||||
}
|
||||
None => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a resource type string to an ApiResource for the kube dynamic API.
|
||||
fn resolve_resource(resource_type: &str) -> Option<ApiResource> {
|
||||
let (group, version, kind, plural) = match resource_type {
|
||||
"pods" | "pod" | "po" => ("", "v1", "Pod", "pods"),
|
||||
"deployments" | "deployment" | "deploy" => ("apps", "v1", "Deployment", "deployments"),
|
||||
"services" | "service" | "svc" => ("", "v1", "Service", "services"),
|
||||
"configmaps" | "configmap" | "cm" => ("", "v1", "ConfigMap", "configmaps"),
|
||||
"secrets" | "secret" => ("", "v1", "Secret", "secrets"),
|
||||
"nodes" | "node" | "no" => ("", "v1", "Node", "nodes"),
|
||||
"namespaces" | "namespace" | "ns" => ("", "v1", "Namespace", "namespaces"),
|
||||
"replicasets" | "replicaset" | "rs" => ("apps", "v1", "ReplicaSet", "replicasets"),
|
||||
"statefulsets" | "statefulset" | "sts" => ("apps", "v1", "StatefulSet", "statefulsets"),
|
||||
"daemonsets" | "daemonset" | "ds" => ("apps", "v1", "DaemonSet", "daemonsets"),
|
||||
"jobs" | "job" => ("batch", "v1", "Job", "jobs"),
|
||||
"cronjobs" | "cronjob" | "cj" => ("batch", "v1", "CronJob", "cronjobs"),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ApiResource {
|
||||
group: group.into(),
|
||||
version: version.into(),
|
||||
api_version: if group.is_empty() {
|
||||
version.into()
|
||||
} else {
|
||||
format!("{group}/{version}")
|
||||
},
|
||||
kind: kind.into(),
|
||||
plural: plural.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn format_output(obj: &DynamicObject, format: &str) -> Result<String, String> {
|
||||
match format {
|
||||
"json" => serde_json::to_string_pretty(obj).map_err(|e| e.to_string()),
|
||||
_ => {
|
||||
// Table format: show basic info
|
||||
let name = obj.metadata.name.as_deref().unwrap_or("unknown");
|
||||
let ns = obj.metadata.namespace.as_deref().unwrap_or("");
|
||||
let age = obj
|
||||
.metadata
|
||||
.creation_timestamp
|
||||
.as_ref()
|
||||
.map(|t| format_age(&t.0))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
Ok(format!("{name}\t{ns}\t{age}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_list(items: &[DynamicObject], format: &str) -> Result<String, String> {
|
||||
match format {
|
||||
"json" => serde_json::to_string_pretty(items).map_err(|e| e.to_string()),
|
||||
_ => {
|
||||
// Table format
|
||||
let mut lines = vec![format!(
|
||||
"{:<40} {:<20} {}",
|
||||
"NAME", "NAMESPACE", "AGE"
|
||||
)];
|
||||
for obj in items {
|
||||
let name = obj.metadata.name.as_deref().unwrap_or("unknown");
|
||||
let ns = obj.metadata.namespace.as_deref().unwrap_or("");
|
||||
let age = obj
|
||||
.metadata
|
||||
.creation_timestamp
|
||||
.as_ref()
|
||||
.map(|t| format_age(&t.0))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
lines.push(format!("{:<40} {:<20} {}", name, ns, age));
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_age(created: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
let duration = chrono::Utc::now() - *created;
|
||||
if duration.num_days() > 0 {
|
||||
format!("{}d", duration.num_days())
|
||||
} else if duration.num_hours() > 0 {
|
||||
format!("{}h", duration.num_hours())
|
||||
} else {
|
||||
format!("{}m", duration.num_minutes())
|
||||
}
|
||||
}
|
||||
68
bascule-gateway/src/executor/mod.rs
Normal file
68
bascule-gateway/src/executor/mod.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
pub mod k8s;
|
||||
|
||||
use bascule_core::audit::ExecutionResult;
|
||||
|
||||
use crate::filter::RequestContext;
|
||||
|
||||
/// Registry of command executors, dispatching by verb.
|
||||
pub struct ExecutorRegistry {
|
||||
k8s: k8s::KubernetesExecutor,
|
||||
}
|
||||
|
||||
impl ExecutorRegistry {
|
||||
pub fn new(client: kube::Client) -> Self {
|
||||
Self {
|
||||
k8s: k8s::KubernetesExecutor::new(client),
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a command to the appropriate executor and return the result.
|
||||
pub async fn execute(&self, ctx: &RequestContext) -> ExecutionResult {
|
||||
match ctx.command.verb.as_str() {
|
||||
// Read operations
|
||||
"get" | "describe" | "logs" | "status" => {
|
||||
self.k8s
|
||||
.execute(
|
||||
&ctx.command.verb,
|
||||
ctx.command.namespace.as_deref(),
|
||||
ctx.command.resource_type.as_deref(),
|
||||
ctx.command.resource_name.as_deref(),
|
||||
&ctx.command.output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
// Mutative operations
|
||||
"scale" => {
|
||||
self.k8s
|
||||
.cmd_scale(
|
||||
ctx.command.namespace.as_deref(),
|
||||
ctx.command.resource_type.as_deref(),
|
||||
ctx.command.resource_name.as_deref(),
|
||||
&ctx.command.parameters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"patch" => {
|
||||
self.k8s
|
||||
.cmd_patch(
|
||||
ctx.command.namespace.as_deref(),
|
||||
ctx.command.resource_type.as_deref(),
|
||||
ctx.command.resource_name.as_deref(),
|
||||
&ctx.command.parameters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"rollback" => {
|
||||
self.k8s
|
||||
.cmd_rollback(
|
||||
ctx.command.namespace.as_deref(),
|
||||
ctx.command.resource_name.as_deref(),
|
||||
&ctx.command.parameters,
|
||||
)
|
||||
.await
|
||||
}
|
||||
// Session commands are handled directly by the server, not routed here
|
||||
verb => ExecutionResult::error(format!("unknown verb: {verb}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
102
bascule-gateway/src/filter/audit.rs
Normal file
102
bascule-gateway/src/filter/audit.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bascule_core::audit::{ExecutionResult, ExecutionStatus, PolicyDecision};
|
||||
use bascule_core::command::ChangeClassification;
|
||||
|
||||
use super::RequestContext;
|
||||
use crate::audit_pipeline::{build_audit_event, should_notarize, AuditPipeline};
|
||||
|
||||
/// Log the audit event and submit to the pipeline. Returns an AuditRef for the response.
|
||||
pub async fn log_and_submit(
|
||||
pipeline: &Arc<AuditPipeline>,
|
||||
ctx: &RequestContext,
|
||||
exec_result: Option<&ExecutionResult>,
|
||||
) -> bascule_proto::bascule_v1::AuditRef {
|
||||
let elapsed = ctx.started_at.elapsed();
|
||||
let classification = ctx.classification.unwrap_or(ChangeClassification::Read);
|
||||
|
||||
// Build execution result (use provided or create a default)
|
||||
let result = exec_result.cloned().unwrap_or_else(|| ExecutionResult {
|
||||
status: if ctx
|
||||
.policy_decision
|
||||
.as_ref()
|
||||
.map(|d| !d.allowed)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
ExecutionStatus::Denied
|
||||
} else {
|
||||
ExecutionStatus::Success
|
||||
},
|
||||
summary: String::new(),
|
||||
resources_affected: ctx.resources_affected,
|
||||
mutations_applied: 0,
|
||||
});
|
||||
|
||||
let policy_decision = ctx
|
||||
.policy_decision
|
||||
.clone()
|
||||
.unwrap_or_else(PolicyDecision::allow_all_stub);
|
||||
|
||||
// Log via tracing
|
||||
tracing::info!(
|
||||
session_id = ctx.session.as_ref().map(|s| s.session_id.to_string()).unwrap_or_default(),
|
||||
operator = ctx.identity.as_ref().map(|i| i.display_id()).unwrap_or_default(),
|
||||
verb = ctx.command.verb,
|
||||
namespace = ctx.command.namespace.as_deref().unwrap_or(""),
|
||||
resource_type = ctx.command.resource_type.as_deref().unwrap_or(""),
|
||||
resource_name = ctx.command.resource_name.as_deref().unwrap_or(""),
|
||||
classification = ?classification,
|
||||
allowed = policy_decision.allowed,
|
||||
status = ?result.status,
|
||||
resources_affected = result.resources_affected,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"audit: command executed"
|
||||
);
|
||||
|
||||
// Build the full audit event
|
||||
let session_id = ctx
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|s| s.session_id)
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(identity) = &ctx.identity {
|
||||
// Determine ledger fidelity from OPA decision
|
||||
let fidelity = ctx
|
||||
.opa_decision
|
||||
.as_ref()
|
||||
.map(|d| d.ledger_fidelity.as_str())
|
||||
.unwrap_or("default");
|
||||
|
||||
let notarize = should_notarize(classification, fidelity);
|
||||
|
||||
let event = build_audit_event(
|
||||
session_id,
|
||||
identity,
|
||||
&ctx.command,
|
||||
classification,
|
||||
&policy_decision,
|
||||
&result,
|
||||
);
|
||||
|
||||
let event_id = event.event_id.to_string();
|
||||
|
||||
// Submit asynchronously — don't block the response
|
||||
let pipeline = pipeline.clone();
|
||||
tokio::spawn(async move {
|
||||
pipeline.submit(&event, notarize).await;
|
||||
});
|
||||
|
||||
bascule_proto::bascule_v1::AuditRef {
|
||||
event_id,
|
||||
classification: format!("{classification:?}").to_lowercase(),
|
||||
notarized: notarize,
|
||||
}
|
||||
} else {
|
||||
bascule_proto::bascule_v1::AuditRef {
|
||||
event_id: uuid::Uuid::new_v4().to_string(),
|
||||
classification: format!("{classification:?}").to_lowercase(),
|
||||
notarized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
27
bascule-gateway/src/filter/auth.rs
Normal file
27
bascule-gateway/src/filter/auth.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use super::{deny_response, FilterResult, RequestContext};
|
||||
use crate::auth::OidcAuthProvider;
|
||||
|
||||
/// Validate the bearer token and extract operator identity.
|
||||
pub async fn apply(
|
||||
provider: &Arc<OidcAuthProvider>,
|
||||
ctx: &mut RequestContext,
|
||||
) -> FilterResult {
|
||||
let token = match &ctx.bearer_token {
|
||||
Some(t) => t.clone(),
|
||||
None => return FilterResult::Respond(deny_response("missing authorization token")),
|
||||
};
|
||||
|
||||
match provider.validate_token(&token).await {
|
||||
Ok(identity) => {
|
||||
tracing::debug!(identity = %identity.display_id(), "Auth filter: identity verified");
|
||||
ctx.identity = Some(identity);
|
||||
FilterResult::Continue
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Auth filter: token validation failed");
|
||||
FilterResult::Respond(deny_response(&format!("authentication failed: {e}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
30
bascule-gateway/src/filter/budget.rs
Normal file
30
bascule-gateway/src/filter/budget.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bascule_core::command::ChangeClassification;
|
||||
|
||||
use super::{deny_response, FilterResult, RequestContext};
|
||||
use crate::session_manager::SessionManager;
|
||||
|
||||
/// Check the mutation budget before allowing mutative commands.
|
||||
pub async fn apply(manager: &Arc<SessionManager>, ctx: &mut RequestContext) -> FilterResult {
|
||||
// Only check budget for mutative commands
|
||||
if ctx.classification != Some(ChangeClassification::Mutative) {
|
||||
return FilterResult::Continue;
|
||||
}
|
||||
|
||||
let session = match &ctx.session {
|
||||
Some(s) => s,
|
||||
None => return FilterResult::Continue,
|
||||
};
|
||||
|
||||
if session.budget_exhausted() {
|
||||
return FilterResult::Respond(deny_response(
|
||||
"mutation budget exhausted — request a new session",
|
||||
));
|
||||
}
|
||||
|
||||
// Record the mutation (updates both DashMap and PG)
|
||||
manager.record_mutation(&session.session_id).await;
|
||||
|
||||
FilterResult::Continue
|
||||
}
|
||||
62
bascule-gateway/src/filter/classify.rs
Normal file
62
bascule-gateway/src/filter/classify.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use bascule_core::command::ChangeClassification;
|
||||
|
||||
use super::{FilterResult, RequestContext};
|
||||
|
||||
/// Classify the command verb into read/mutative/workspace/session.
|
||||
pub fn apply(ctx: &mut RequestContext) -> FilterResult {
|
||||
let classification = classify_verb(&ctx.command.verb);
|
||||
ctx.classification = Some(classification);
|
||||
FilterResult::Continue
|
||||
}
|
||||
|
||||
fn classify_verb(verb: &str) -> ChangeClassification {
|
||||
match verb {
|
||||
// Read operations
|
||||
"get" | "describe" | "logs" | "status" | "profiles_list" | "profiles_get" | "topology" => {
|
||||
ChangeClassification::Read
|
||||
}
|
||||
// Mutative operations
|
||||
"scale" | "patch" | "apply" | "rollback" | "exec" | "drain" => {
|
||||
ChangeClassification::Mutative
|
||||
}
|
||||
// Workspace operations
|
||||
"ws_status" | "ws_diff" | "ws_simulate" | "ws_commit" | "ws_submit" | "ws_abandon" => {
|
||||
ChangeClassification::Workspace
|
||||
}
|
||||
// Session operations
|
||||
"session_status" | "session_extend" | "session_delegate" | "session_end" => {
|
||||
ChangeClassification::Session
|
||||
}
|
||||
// Audit operations (read classification)
|
||||
"audit_trail" | "audit_verify" | "audit_session" => ChangeClassification::Read,
|
||||
// Unknown defaults to mutative (most restrictive)
|
||||
_ => {
|
||||
tracing::warn!(verb, "Unknown command verb, classifying as mutative");
|
||||
ChangeClassification::Mutative
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_verbs_classified_correctly() {
|
||||
assert_eq!(classify_verb("get"), ChangeClassification::Read);
|
||||
assert_eq!(classify_verb("describe"), ChangeClassification::Read);
|
||||
assert_eq!(classify_verb("logs"), ChangeClassification::Read);
|
||||
assert_eq!(classify_verb("status"), ChangeClassification::Read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutative_verbs_classified_correctly() {
|
||||
assert_eq!(classify_verb("scale"), ChangeClassification::Mutative);
|
||||
assert_eq!(classify_verb("patch"), ChangeClassification::Mutative);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_verbs_default_to_mutative() {
|
||||
assert_eq!(classify_verb("unknown"), ChangeClassification::Mutative);
|
||||
}
|
||||
}
|
||||
190
bascule-gateway/src/filter/mod.rs
Normal file
190
bascule-gateway/src/filter/mod.rs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod budget;
|
||||
pub mod classify;
|
||||
pub mod policy;
|
||||
pub mod response;
|
||||
pub mod session;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use accord_core::schema::Accord;
|
||||
use accord_opa::OpaClient;
|
||||
use bascule_core::audit::PolicyDecision;
|
||||
use bascule_core::command::ChangeClassification;
|
||||
use bascule_core::session::{OperatorIdentity, Session};
|
||||
use bascule_proto::bascule_v1::{ExecuteCommandRequest, ExecuteCommandResponse};
|
||||
|
||||
use crate::audit_pipeline::AuditPipeline;
|
||||
use crate::auth::OidcAuthProvider;
|
||||
use crate::executor::ExecutorRegistry;
|
||||
use crate::session_manager::SessionManager;
|
||||
|
||||
/// Context accumulated as a request passes through the filter chain.
|
||||
pub struct RequestContext {
|
||||
pub identity: Option<OperatorIdentity>,
|
||||
pub session: Option<Session>,
|
||||
pub classification: Option<ChangeClassification>,
|
||||
pub policy_decision: Option<PolicyDecision>,
|
||||
/// OPA policy decision from accord-opa (richer than bascule-core's PolicyDecision).
|
||||
pub opa_decision: Option<accord_opa::PolicyDecision>,
|
||||
pub command: ExecuteCommandRequest,
|
||||
pub started_at: Instant,
|
||||
/// The bearer token extracted from gRPC metadata.
|
||||
pub bearer_token: Option<String>,
|
||||
/// Output from execution.
|
||||
pub output: Option<String>,
|
||||
pub resources_affected: u32,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
pub fn new(command: ExecuteCommandRequest, bearer_token: Option<String>) -> Self {
|
||||
Self {
|
||||
identity: None,
|
||||
session: None,
|
||||
classification: None,
|
||||
policy_decision: None,
|
||||
opa_decision: None,
|
||||
command,
|
||||
started_at: Instant::now(),
|
||||
bearer_token,
|
||||
output: None,
|
||||
resources_affected: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a filter step.
|
||||
pub enum FilterResult {
|
||||
/// Continue to the next filter.
|
||||
Continue,
|
||||
/// Short-circuit with this response.
|
||||
Respond(ExecuteCommandResponse),
|
||||
}
|
||||
|
||||
/// The ordered filter chain that processes every command.
|
||||
pub struct FilterChain {
|
||||
auth_provider: Arc<OidcAuthProvider>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
executor_registry: Arc<ExecutorRegistry>,
|
||||
opa_client: Arc<OpaClient>,
|
||||
accord: Arc<Accord>,
|
||||
audit_pipeline: Arc<AuditPipeline>,
|
||||
}
|
||||
|
||||
impl FilterChain {
|
||||
pub fn new(
|
||||
auth_provider: Arc<OidcAuthProvider>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
executor_registry: Arc<ExecutorRegistry>,
|
||||
opa_client: Arc<OpaClient>,
|
||||
accord: Arc<Accord>,
|
||||
audit_pipeline: Arc<AuditPipeline>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_provider,
|
||||
session_manager,
|
||||
executor_registry,
|
||||
opa_client,
|
||||
accord,
|
||||
audit_pipeline,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the full filter chain for a command request.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
bearer_token: Option<String>,
|
||||
command: ExecuteCommandRequest,
|
||||
) -> ExecuteCommandResponse {
|
||||
let mut ctx = RequestContext::new(command, bearer_token);
|
||||
|
||||
// 1. Auth filter — validate token, extract identity
|
||||
match auth::apply(&self.auth_provider, &mut ctx).await {
|
||||
FilterResult::Respond(resp) => return resp,
|
||||
FilterResult::Continue => {}
|
||||
}
|
||||
|
||||
// 2. Session filter — look up session, verify active
|
||||
match session::apply(&self.session_manager, &mut ctx) {
|
||||
FilterResult::Respond(resp) => return resp,
|
||||
FilterResult::Continue => {}
|
||||
}
|
||||
|
||||
// 3. Classification filter — classify the command verb
|
||||
match classify::apply(&mut ctx) {
|
||||
FilterResult::Respond(resp) => return resp,
|
||||
FilterResult::Continue => {}
|
||||
}
|
||||
|
||||
// 4. Policy filter — evaluate via OPA
|
||||
match policy::apply(&self.opa_client, &self.accord, &mut ctx).await {
|
||||
FilterResult::Respond(resp) => {
|
||||
// Audit denied commands too
|
||||
audit::log_and_submit(&self.audit_pipeline, &ctx, None).await;
|
||||
return resp;
|
||||
}
|
||||
FilterResult::Continue => {}
|
||||
}
|
||||
|
||||
// 5. Budget filter — check mutation budget
|
||||
match budget::apply(&self.session_manager, &mut ctx).await {
|
||||
FilterResult::Respond(resp) => {
|
||||
audit::log_and_submit(&self.audit_pipeline, &ctx, None).await;
|
||||
return resp;
|
||||
}
|
||||
FilterResult::Continue => {}
|
||||
}
|
||||
|
||||
// 6. Route to executor
|
||||
let exec_result = self.executor_registry.execute(&ctx).await;
|
||||
ctx.output = Some(exec_result.summary.clone());
|
||||
ctx.resources_affected = exec_result.resources_affected;
|
||||
|
||||
// 7. Response filter — sanitize output
|
||||
let output = response::sanitize(ctx.output.as_deref().unwrap_or(""));
|
||||
|
||||
// 8. Audit filter — log and submit event
|
||||
let audit_ref = audit::log_and_submit(&self.audit_pipeline, &ctx, Some(&exec_result)).await;
|
||||
|
||||
// Build final response
|
||||
let session_expired = ctx
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|s| !s.is_active())
|
||||
.unwrap_or(false);
|
||||
|
||||
ExecuteCommandResponse {
|
||||
allowed: true,
|
||||
denied_reason: String::new(),
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::execute_command_response::Result::Success(
|
||||
bascule_proto::bascule_v1::CommandResult {
|
||||
output,
|
||||
resources_affected: ctx.resources_affected,
|
||||
session_expired_warning: session_expired,
|
||||
},
|
||||
),
|
||||
),
|
||||
audit: Some(audit_ref),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to build a denial response.
|
||||
pub fn deny_response(reason: &str) -> ExecuteCommandResponse {
|
||||
ExecuteCommandResponse {
|
||||
allowed: false,
|
||||
denied_reason: reason.to_string(),
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::execute_command_response::Result::Error(
|
||||
bascule_proto::bascule_v1::CommandError {
|
||||
message: reason.to_string(),
|
||||
code: "DENIED".to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
audit: None,
|
||||
}
|
||||
}
|
||||
161
bascule-gateway/src/filter/policy.rs
Normal file
161
bascule-gateway/src/filter/policy.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use accord_core::schema::Accord;
|
||||
use accord_opa::input::{
|
||||
GrantedScope, PolicyInput, RequestContext as OpaRequestContext, ResourceRef as OpaResourceRef,
|
||||
SessionContext,
|
||||
};
|
||||
use accord_opa::OpaClient;
|
||||
use bascule_core::audit::PolicyDecision;
|
||||
use bascule_core::command::ChangeClassification;
|
||||
|
||||
use super::{deny_response, FilterResult, RequestContext};
|
||||
|
||||
/// Evaluate the command against OPA policy. Fail-closed: if OPA is unreachable, deny.
|
||||
pub async fn apply(
|
||||
opa: &Arc<OpaClient>,
|
||||
accord: &Arc<Accord>,
|
||||
ctx: &mut RequestContext,
|
||||
) -> FilterResult {
|
||||
let session = match &ctx.session {
|
||||
Some(s) => s,
|
||||
None => return FilterResult::Continue,
|
||||
};
|
||||
let identity = match &ctx.identity {
|
||||
Some(i) => i,
|
||||
None => return FilterResult::Continue,
|
||||
};
|
||||
|
||||
let is_mutative = ctx.classification == Some(ChangeClassification::Mutative);
|
||||
|
||||
// Build the OPA input
|
||||
let session_ctx = SessionContext {
|
||||
operator_identity: identity.display_id(),
|
||||
operator_roles: vec![],
|
||||
ceremony_ref: session.ceremony_id.to_string(),
|
||||
ceremony_type: "self_grant".to_string(),
|
||||
granted_scopes: session
|
||||
.scope
|
||||
.namespaces
|
||||
.iter()
|
||||
.map(|ns| GrantedScope {
|
||||
namespace: ns.namespace.clone(),
|
||||
api_groups: ns
|
||||
.rules
|
||||
.iter()
|
||||
.flat_map(|r| r.api_groups.clone())
|
||||
.collect(),
|
||||
resources: ns
|
||||
.rules
|
||||
.iter()
|
||||
.flat_map(|r| r.resources.clone())
|
||||
.collect(),
|
||||
verbs: ns
|
||||
.rules
|
||||
.iter()
|
||||
.flat_map(|r| r.verbs.iter().map(|v| v.as_str().to_string()))
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
session_start: session.valid_from,
|
||||
session_expires: session.expires_at,
|
||||
mutations_used: session.mutations_used,
|
||||
mutation_budget: session.scope.mutation_budget,
|
||||
};
|
||||
|
||||
let resources: Vec<OpaResourceRef> =
|
||||
if let (Some(rt), Some(ns)) = (&ctx.command.resource_type, &ctx.command.namespace) {
|
||||
vec![OpaResourceRef {
|
||||
api_group: resolve_api_group(rt),
|
||||
kind: rt.clone(),
|
||||
namespace: ns.clone(),
|
||||
name: ctx.command.resource_name.clone().unwrap_or_default(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let request_ctx = OpaRequestContext {
|
||||
pathway: "imperative".to_string(),
|
||||
operation: ctx.command.verb.clone(),
|
||||
resources,
|
||||
is_mutative,
|
||||
};
|
||||
|
||||
let input = PolicyInput::for_command(request_ctx, session_ctx, None, accord);
|
||||
|
||||
let start = Instant::now();
|
||||
let result = opa.evaluate_policy(&input).await;
|
||||
let eval_ms = start.elapsed().as_millis() as u32;
|
||||
|
||||
match result {
|
||||
Ok(decision) => {
|
||||
tracing::debug!(
|
||||
allowed = decision.allowed,
|
||||
classification = %decision.classification,
|
||||
requires_ceremony = decision.requires_ceremony,
|
||||
eval_ms,
|
||||
"OPA policy evaluated"
|
||||
);
|
||||
|
||||
// Convert OPA decision to bascule-core PolicyDecision
|
||||
ctx.policy_decision = Some(PolicyDecision {
|
||||
allowed: decision.is_permitted(),
|
||||
policy_bundle_hash: String::new(),
|
||||
accord_version: accord.metadata.version.clone(),
|
||||
evaluation_duration_ms: eval_ms,
|
||||
denied_reason: if decision.denial_reasons.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(decision.denial_reasons.join("; "))
|
||||
},
|
||||
});
|
||||
|
||||
ctx.opa_decision = Some(decision.clone());
|
||||
|
||||
if decision.is_permitted() {
|
||||
FilterResult::Continue
|
||||
} else {
|
||||
let reason = if !decision.denial_reasons.is_empty() {
|
||||
decision.denial_reasons.join("; ")
|
||||
} else if decision.requires_ceremony {
|
||||
format!(
|
||||
"insufficient ceremony: requires {}",
|
||||
decision.ceremony_type
|
||||
)
|
||||
} else {
|
||||
"policy denied".to_string()
|
||||
};
|
||||
FilterResult::Respond(deny_response(&reason))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("OPA evaluation failed (fail-closed): {e}");
|
||||
ctx.policy_decision = Some(PolicyDecision {
|
||||
allowed: false,
|
||||
policy_bundle_hash: String::new(),
|
||||
accord_version: accord.metadata.version.clone(),
|
||||
evaluation_duration_ms: eval_ms,
|
||||
denied_reason: Some(format!("policy evaluation failed: {e}")),
|
||||
});
|
||||
FilterResult::Respond(deny_response(&format!(
|
||||
"policy evaluation unavailable: {e}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_api_group(resource_type: &str) -> String {
|
||||
match resource_type {
|
||||
"deployments" | "deployment" | "deploy" | "replicasets" | "replicaset" | "rs"
|
||||
| "statefulsets" | "statefulset" | "sts" | "daemonsets" | "daemonset" | "ds" => {
|
||||
"apps".to_string()
|
||||
}
|
||||
"jobs" | "job" | "cronjobs" | "cronjob" | "cj" => "batch".to_string(),
|
||||
"clusterroles" | "clusterrolebindings" | "roles" | "rolebindings" => {
|
||||
"rbac.authorization.k8s.io".to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
49
bascule-gateway/src/filter/response.rs
Normal file
49
bascule-gateway/src/filter/response.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/// Sanitize output before returning to the shell.
|
||||
/// Strips known secret patterns from command output.
|
||||
pub fn sanitize(output: &str) -> String {
|
||||
let mut sanitized = output.to_string();
|
||||
|
||||
// Strip base64-encoded data fields from Kubernetes Secrets
|
||||
// Pattern: data fields in Secret output that might contain credentials
|
||||
let secret_patterns = [
|
||||
"password:",
|
||||
"token:",
|
||||
"secret-key:",
|
||||
"aws-secret-access-key:",
|
||||
"client-secret:",
|
||||
];
|
||||
|
||||
for pattern in &secret_patterns {
|
||||
if let Some(idx) = sanitized.to_lowercase().find(pattern) {
|
||||
// Find the end of the value (next newline or end of string)
|
||||
let value_start = idx + pattern.len();
|
||||
let value_end = sanitized[value_start..]
|
||||
.find('\n')
|
||||
.map(|i| value_start + i)
|
||||
.unwrap_or(sanitized.len());
|
||||
sanitized.replace_range(value_start..value_end, " <REDACTED>");
|
||||
}
|
||||
}
|
||||
|
||||
sanitized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_strips_password_values() {
|
||||
let input = "username: admin\npassword: s3cret123\nhost: localhost";
|
||||
let output = sanitize(input);
|
||||
assert!(output.contains("<REDACTED>"));
|
||||
assert!(!output.contains("s3cret123"));
|
||||
assert!(output.contains("admin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_preserves_non_secret_output() {
|
||||
let input = "NAME READY STATUS RESTARTS AGE\nnginx-abc 1/1 Running 0 5m";
|
||||
assert_eq!(sanitize(input), input);
|
||||
}
|
||||
}
|
||||
28
bascule-gateway/src/filter/session.rs
Normal file
28
bascule-gateway/src/filter/session.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use super::{deny_response, FilterResult, RequestContext};
|
||||
use crate::session_manager::SessionManager;
|
||||
|
||||
/// Look up the session and verify it is still active.
|
||||
pub fn apply(manager: &Arc<SessionManager>, ctx: &mut RequestContext) -> FilterResult {
|
||||
let session_id = match uuid::Uuid::parse_str(&ctx.command.session_id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return FilterResult::Respond(deny_response("invalid session_id format"));
|
||||
}
|
||||
};
|
||||
|
||||
let session = match manager.get_session(&session_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return FilterResult::Respond(deny_response("session not found"));
|
||||
}
|
||||
};
|
||||
|
||||
if !session.is_active() {
|
||||
return FilterResult::Respond(deny_response("session is no longer active"));
|
||||
}
|
||||
|
||||
ctx.session = Some(session);
|
||||
FilterResult::Continue
|
||||
}
|
||||
745
bascule-gateway/src/governance_ceremony.rs
Normal file
745
bascule-gateway/src/governance_ceremony.rs
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
//! Governance Ceremony gRPC service — implements the CeremonyService
|
||||
//! proto for multi-stakeholder approval flows.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bascule_core::ceremony_engine::CeremonyEngine;
|
||||
use bascule_core::ceremony_request::{
|
||||
ApprovalDecision, CeremonySubject, GovernanceCeremonyStatus,
|
||||
};
|
||||
use bascule_core::ceremony_resolution::CeremonyResolution;
|
||||
use bascule_core::ceremony_store::CeremonyStore;
|
||||
use bascule_proto::bascule_v1::*;
|
||||
use tonic::{Request, Response, Status};
|
||||
use tracing::info;
|
||||
|
||||
pub struct GovernanceCeremonyService {
|
||||
store: Arc<dyn CeremonyStore>,
|
||||
}
|
||||
|
||||
impl GovernanceCeremonyService {
|
||||
pub fn new(store: Arc<dyn CeremonyStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_string(status: GovernanceCeremonyStatus) -> String {
|
||||
match status {
|
||||
GovernanceCeremonyStatus::Pending => "pending".to_string(),
|
||||
GovernanceCeremonyStatus::Approved => "approved".to_string(),
|
||||
GovernanceCeremonyStatus::Denied => "denied".to_string(),
|
||||
GovernanceCeremonyStatus::Expired => "expired".to_string(),
|
||||
GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_proto_timestamp(dt: &chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||
prost_types::Timestamp {
|
||||
seconds: dt.timestamp(),
|
||||
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn ceremony_to_response(
|
||||
cer: &bascule_core::ceremony_request::GovernanceCeremonyRequest,
|
||||
) -> GetCeremonyResponse {
|
||||
GetCeremonyResponse {
|
||||
ceremony_id: cer.ceremony_id.clone(),
|
||||
ceremony_type: format!("{:?}", cer.ceremony_type).to_lowercase(),
|
||||
subject: Some(CeremonySubjectMsg {
|
||||
subject_type: match &cer.subject {
|
||||
CeremonySubject::MutationIntent { .. } => "mutation_intent".to_string(),
|
||||
CeremonySubject::PipelineMerge { .. } => "pipeline_merge".to_string(),
|
||||
CeremonySubject::SchematicPublish { .. } => "schematic_publish".to_string(),
|
||||
CeremonySubject::Custom { .. } => "custom".to_string(),
|
||||
CeremonySubject::GitOpsSync { .. } => "gitops_sync".to_string(),
|
||||
},
|
||||
reference_id: match &cer.subject {
|
||||
CeremonySubject::MutationIntent { intent_id, .. } => intent_id.clone(),
|
||||
CeremonySubject::PipelineMerge { run_id, .. } => run_id.clone(),
|
||||
CeremonySubject::SchematicPublish {
|
||||
schematic_name,
|
||||
version,
|
||||
..
|
||||
} => format!("{schematic_name}:{version}"),
|
||||
CeremonySubject::Custom { reference_id, .. } => reference_id.clone(),
|
||||
CeremonySubject::GitOpsSync {
|
||||
resource_name,
|
||||
resource_namespace,
|
||||
..
|
||||
} => format!("{resource_namespace}/{resource_name}"),
|
||||
},
|
||||
description: cer.subject.display_label(),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
}),
|
||||
status: status_to_string(cer.status),
|
||||
required_approvals: cer.required_approvals,
|
||||
current_approvals: cer.approval_count(),
|
||||
approvals: cer
|
||||
.approvals
|
||||
.iter()
|
||||
.map(|a| CeremonyApprovalMsg {
|
||||
approver_identity: a.approver_identity.clone(),
|
||||
approver_role: a.approver_role.clone(),
|
||||
decision: match a.decision {
|
||||
ApprovalDecision::Approve => "approve".to_string(),
|
||||
ApprovalDecision::Deny => "deny".to_string(),
|
||||
},
|
||||
comment: a.comment.clone().unwrap_or_default(),
|
||||
decided_at: Some(to_proto_timestamp(&a.decided_at)),
|
||||
})
|
||||
.collect(),
|
||||
created_at: Some(to_proto_timestamp(&cer.created_at)),
|
||||
expires_at: Some(to_proto_timestamp(&cer.expires_at)),
|
||||
intent_id: cer.intent_id.clone().unwrap_or_default(),
|
||||
run_id: cer.run_id.clone().unwrap_or_default(),
|
||||
pr_number: cer.pr_number.unwrap_or(0),
|
||||
remote_name: cer.remote_name.clone().unwrap_or_default(),
|
||||
error: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ceremony_type(s: &str) -> accord_core::schema::CeremonyType {
|
||||
match s {
|
||||
"self_grant" | "selfgrant" => accord_core::schema::CeremonyType::SelfGrant,
|
||||
"autonomous" => accord_core::schema::CeremonyType::Autonomous,
|
||||
"break_glass" | "breakglass" => accord_core::schema::CeremonyType::BreakGlass,
|
||||
"single_approval" | "singleapproval" => {
|
||||
accord_core::schema::CeremonyType::SingleApproval
|
||||
}
|
||||
"quorum_approval" | "quorumapproval" => {
|
||||
accord_core::schema::CeremonyType::QuorumApproval
|
||||
}
|
||||
"inherit" => accord_core::schema::CeremonyType::Inherit,
|
||||
_ => accord_core::schema::CeremonyType::SingleApproval,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_subject(msg: &CeremonySubjectMsg) -> CeremonySubject {
|
||||
match msg.subject_type.as_str() {
|
||||
"mutation_intent" => CeremonySubject::MutationIntent {
|
||||
intent_id: msg.reference_id.clone(),
|
||||
registry_type: msg.metadata.get("registry_type").cloned().unwrap_or_default(),
|
||||
verb: msg.metadata.get("verb").cloned().unwrap_or_default(),
|
||||
artifact_scope: msg.metadata.get("artifact_scope").cloned().unwrap_or_default(),
|
||||
tenant_id: msg.metadata.get("tenant_id").cloned().unwrap_or_default(),
|
||||
},
|
||||
"pipeline_merge" => CeremonySubject::PipelineMerge {
|
||||
run_id: msg.reference_id.clone(),
|
||||
pipeline_name: msg.metadata.get("pipeline_name").cloned().unwrap_or_default(),
|
||||
branch: msg.metadata.get("branch").cloned().unwrap_or_default(),
|
||||
commit_hash: msg.metadata.get("commit_hash").cloned().unwrap_or_default(),
|
||||
remote_name: msg.metadata.get("remote_name").cloned().unwrap_or_default(),
|
||||
},
|
||||
"schematic_publish" => {
|
||||
let parts: Vec<&str> = msg.reference_id.splitn(2, ':').collect();
|
||||
CeremonySubject::SchematicPublish {
|
||||
schematic_name: parts.first().unwrap_or(&"").to_string(),
|
||||
version: parts.get(1).unwrap_or(&"").to_string(),
|
||||
tree_hash: msg.metadata.get("tree_hash").cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
_ => CeremonySubject::Custom {
|
||||
subject_type: msg.subject_type.clone(),
|
||||
reference_id: msg.reference_id.clone(),
|
||||
description: msg.description.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl ceremony_service_server::CeremonyService for GovernanceCeremonyService {
|
||||
async fn create_ceremony(
|
||||
&self,
|
||||
request: Request<CreateCeremonyRequest>,
|
||||
) -> Result<Response<CreateCeremonyResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let ceremony_type = parse_ceremony_type(&req.ceremony_type);
|
||||
let subject = req
|
||||
.subject
|
||||
.as_ref()
|
||||
.map(parse_subject)
|
||||
.ok_or_else(|| Status::invalid_argument("subject is required"))?;
|
||||
|
||||
let ceremony_id = uuid::Uuid::new_v4().to_string();
|
||||
let ttl = if req.ttl_hours == 0 { 24 } else { req.ttl_hours };
|
||||
|
||||
let reqs = accord_core::schema::CeremonyReqs {
|
||||
approver_roles: if req.approver_roles.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.approver_roles.clone())
|
||||
},
|
||||
quorum: if req.required_approvals > 0 {
|
||||
Some(req.required_approvals)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut ceremony =
|
||||
CeremonyEngine::create_request(ceremony_id, &ceremony_type, &reqs, subject, ttl);
|
||||
|
||||
// Attach optional links
|
||||
if !req.intent_id.is_empty() {
|
||||
ceremony.intent_id = Some(req.intent_id);
|
||||
}
|
||||
if !req.run_id.is_empty() {
|
||||
ceremony.run_id = Some(req.run_id);
|
||||
}
|
||||
if req.pr_number > 0 {
|
||||
ceremony.pr_number = Some(req.pr_number);
|
||||
}
|
||||
if !req.remote_name.is_empty() {
|
||||
ceremony.remote_name = Some(req.remote_name);
|
||||
}
|
||||
|
||||
self.store.create(&ceremony).await.map_err(|e| {
|
||||
Status::internal(format!("failed to create ceremony: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
ceremony_id = %ceremony.ceremony_id,
|
||||
ceremony_type = ?ceremony.ceremony_type,
|
||||
status = ?ceremony.status,
|
||||
"Governance ceremony created"
|
||||
);
|
||||
|
||||
Ok(Response::new(CreateCeremonyResponse {
|
||||
ceremony_id: ceremony.ceremony_id.clone(),
|
||||
status: status_to_string(ceremony.status),
|
||||
expires_at: Some(to_proto_timestamp(&ceremony.expires_at)),
|
||||
error: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn approve_ceremony(
|
||||
&self,
|
||||
request: Request<ApproveCeremonyRequest>,
|
||||
) -> Result<Response<ApproveCeremonyResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let mut ceremony = self
|
||||
.store
|
||||
.get(&req.ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
let comment = if req.comment.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.comment)
|
||||
};
|
||||
|
||||
ceremony
|
||||
.record_decision(
|
||||
&req.approver_identity,
|
||||
&req.approver_role,
|
||||
ApprovalDecision::Approve,
|
||||
comment,
|
||||
)
|
||||
.map_err(|e| Status::failed_precondition(e.to_string()))?;
|
||||
|
||||
// Evaluate if threshold is met
|
||||
CeremonyEngine::evaluate(&mut ceremony);
|
||||
|
||||
self.store.update(&ceremony).await.map_err(|e| {
|
||||
Status::internal(format!("failed to update ceremony: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
ceremony_id = %ceremony.ceremony_id,
|
||||
approver = %req.approver_identity,
|
||||
status = ?ceremony.status,
|
||||
"Ceremony approval recorded"
|
||||
);
|
||||
|
||||
Ok(Response::new(ApproveCeremonyResponse {
|
||||
success: true,
|
||||
status: status_to_string(ceremony.status),
|
||||
error: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn deny_ceremony(
|
||||
&self,
|
||||
request: Request<DenyCeremonyRequest>,
|
||||
) -> Result<Response<DenyCeremonyResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let mut ceremony = self
|
||||
.store
|
||||
.get(&req.ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
let comment = if req.comment.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.comment)
|
||||
};
|
||||
|
||||
ceremony
|
||||
.record_decision(
|
||||
&req.approver_identity,
|
||||
&req.approver_role,
|
||||
ApprovalDecision::Deny,
|
||||
comment,
|
||||
)
|
||||
.map_err(|e| Status::failed_precondition(e.to_string()))?;
|
||||
|
||||
CeremonyEngine::evaluate(&mut ceremony);
|
||||
|
||||
self.store.update(&ceremony).await.map_err(|e| {
|
||||
Status::internal(format!("failed to update ceremony: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
ceremony_id = %ceremony.ceremony_id,
|
||||
denier = %req.approver_identity,
|
||||
"Ceremony denied"
|
||||
);
|
||||
|
||||
Ok(Response::new(DenyCeremonyResponse {
|
||||
success: true,
|
||||
status: status_to_string(ceremony.status),
|
||||
error: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cancel_ceremony(
|
||||
&self,
|
||||
request: Request<CancelCeremonyRequest>,
|
||||
) -> Result<Response<CancelCeremonyResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let mut ceremony = self
|
||||
.store
|
||||
.get(&req.ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
ceremony
|
||||
.cancel()
|
||||
.map_err(|e| Status::failed_precondition(e.to_string()))?;
|
||||
|
||||
self.store.update(&ceremony).await.map_err(|e| {
|
||||
Status::internal(format!("failed to update ceremony: {e}"))
|
||||
})?;
|
||||
|
||||
info!(ceremony_id = %ceremony.ceremony_id, "Ceremony cancelled");
|
||||
|
||||
Ok(Response::new(CancelCeremonyResponse {
|
||||
success: true,
|
||||
error: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_ceremony(
|
||||
&self,
|
||||
request: Request<GetCeremonyRequest>,
|
||||
) -> Result<Response<GetCeremonyResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let ceremony = self
|
||||
.store
|
||||
.get(&req.ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
Ok(Response::new(ceremony_to_response(&ceremony)))
|
||||
}
|
||||
|
||||
async fn list_pending_ceremonies(
|
||||
&self,
|
||||
request: Request<ListPendingCeremoniesRequest>,
|
||||
) -> Result<Response<ListPendingCeremoniesResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let intent_filter = if req.intent_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(req.intent_id.as_str())
|
||||
};
|
||||
|
||||
let ceremonies = self
|
||||
.store
|
||||
.list_pending(intent_filter)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
|
||||
Ok(Response::new(ListPendingCeremoniesResponse {
|
||||
ceremonies: ceremonies.iter().map(ceremony_to_response).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_ceremony_proof(
|
||||
&self,
|
||||
request: Request<GetCeremonyProofRequest>,
|
||||
) -> Result<Response<GetCeremonyProofResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
let ceremony = self
|
||||
.store
|
||||
.get(&req.ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
if !ceremony.status.is_terminal() {
|
||||
return Err(Status::failed_precondition(
|
||||
"ceremony has not been resolved yet",
|
||||
));
|
||||
}
|
||||
|
||||
let resolution = CeremonyResolution::from_ceremony(
|
||||
&ceremony.ceremony_id,
|
||||
ceremony.status,
|
||||
&ceremony.subject,
|
||||
&ceremony.approvals,
|
||||
);
|
||||
|
||||
Ok(Response::new(GetCeremonyProofResponse {
|
||||
ceremony_id: resolution.ceremony_id,
|
||||
status: status_to_string(resolution.status),
|
||||
proof_hash: resolution.proof_hash,
|
||||
approvals: resolution
|
||||
.approvals
|
||||
.iter()
|
||||
.map(|a| CeremonyApprovalMsg {
|
||||
approver_identity: a.approver_identity.clone(),
|
||||
approver_role: a.approver_role.clone(),
|
||||
decision: match a.decision {
|
||||
ApprovalDecision::Approve => "approve".to_string(),
|
||||
ApprovalDecision::Deny => "deny".to_string(),
|
||||
},
|
||||
comment: a.comment.clone().unwrap_or_default(),
|
||||
decided_at: Some(to_proto_timestamp(&a.decided_at)),
|
||||
})
|
||||
.collect(),
|
||||
resolved_at: Some(to_proto_timestamp(&resolution.resolved_at)),
|
||||
error: String::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl GovernanceCeremonyService {
|
||||
/// Expire all pending ceremonies that have passed their `expires_at`.
|
||||
/// Returns the number of ceremonies expired.
|
||||
pub async fn expire_pending(&self) -> usize {
|
||||
let now = chrono::Utc::now();
|
||||
let expired = match self.store.find_expired(now).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to find expired ceremonies");
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let mut count = 0;
|
||||
for mut cer in expired {
|
||||
if CeremonyEngine::evaluate(&mut cer) {
|
||||
if let Err(e) = self.store.update(&cer).await {
|
||||
tracing::error!(
|
||||
ceremony_id = %cer.ceremony_id,
|
||||
error = %e,
|
||||
"Failed to expire ceremony"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
ceremony_id = %cer.ceremony_id,
|
||||
"Ceremony expired"
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bascule_core::ceremony_store::InMemoryCeremonyStore;
|
||||
use bascule_proto::bascule_v1::ceremony_service_server::CeremonyService;
|
||||
|
||||
fn build_service() -> GovernanceCeremonyService {
|
||||
GovernanceCeremonyService::new(Arc::new(InMemoryCeremonyStore::new()))
|
||||
}
|
||||
|
||||
fn merge_subject_msg() -> CeremonySubjectMsg {
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
metadata.insert("pipeline_name".to_string(), "deploy".to_string());
|
||||
metadata.insert("branch".to_string(), "main".to_string());
|
||||
metadata.insert("commit_hash".to_string(), "abc123".to_string());
|
||||
metadata.insert("remote_name".to_string(), "origin".to_string());
|
||||
CeremonySubjectMsg {
|
||||
subject_type: "pipeline_merge".to_string(),
|
||||
reference_id: "run-001".to_string(),
|
||||
description: "merge deploy (main)".to_string(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_get_ceremony() {
|
||||
let svc = build_service();
|
||||
let resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
intent_id: String::new(),
|
||||
run_id: "run-001".to_string(),
|
||||
pr_number: 42,
|
||||
remote_name: "origin".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(resp.status, "pending");
|
||||
assert!(!resp.ceremony_id.is_empty());
|
||||
|
||||
// Get it back
|
||||
let get_resp = svc
|
||||
.get_ceremony(Request::new(GetCeremonyRequest {
|
||||
ceremony_id: resp.ceremony_id.clone(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(get_resp.ceremony_id, resp.ceremony_id);
|
||||
assert_eq!(get_resp.status, "pending");
|
||||
assert_eq!(get_resp.pr_number, 42);
|
||||
assert_eq!(get_resp.run_id, "run-001");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_resolves_single_approval() {
|
||||
let svc = build_service();
|
||||
let create_resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
let approve_resp = svc
|
||||
.approve_ceremony(Request::new(ApproveCeremonyRequest {
|
||||
ceremony_id: create_resp.ceremony_id.clone(),
|
||||
approver_identity: "alice@ops".to_string(),
|
||||
approver_role: "msp-ops".to_string(),
|
||||
comment: "LGTM".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert!(approve_resp.success);
|
||||
assert_eq!(approve_resp.status, "approved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_resolves_ceremony() {
|
||||
let svc = build_service();
|
||||
let create_resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
let deny_resp = svc
|
||||
.deny_ceremony(Request::new(DenyCeremonyRequest {
|
||||
ceremony_id: create_resp.ceremony_id.clone(),
|
||||
approver_identity: "bob@ops".to_string(),
|
||||
approver_role: "msp-ops".to_string(),
|
||||
comment: "unacceptable risk".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert!(deny_resp.success);
|
||||
assert_eq!(deny_resp.status, "denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_pending_ceremony() {
|
||||
let svc = build_service();
|
||||
let create_resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
let cancel_resp = svc
|
||||
.cancel_ceremony(Request::new(CancelCeremonyRequest {
|
||||
ceremony_id: create_resp.ceremony_id.clone(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert!(cancel_resp.success);
|
||||
|
||||
let get_resp = svc
|
||||
.get_ceremony(Request::new(GetCeremonyRequest {
|
||||
ceremony_id: create_resp.ceremony_id,
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(get_resp.status, "cancelled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_pending_ceremonies() {
|
||||
let svc = build_service();
|
||||
|
||||
// Create 2 ceremonies
|
||||
for i in 0..2 {
|
||||
svc.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(CeremonySubjectMsg {
|
||||
subject_type: "custom".to_string(),
|
||||
reference_id: format!("ref-{i}"),
|
||||
description: format!("test {i}"),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
}),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec![],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let list_resp = svc
|
||||
.list_pending_ceremonies(Request::new(ListPendingCeremoniesRequest {
|
||||
intent_id: String::new(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(list_resp.ceremonies.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_grant_auto_approved() {
|
||||
let svc = build_service();
|
||||
let resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "self_grant".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 0,
|
||||
approver_roles: vec![],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(resp.status, "approved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_proof_for_resolved_ceremony() {
|
||||
let svc = build_service();
|
||||
let create_resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
svc.approve_ceremony(Request::new(ApproveCeremonyRequest {
|
||||
ceremony_id: create_resp.ceremony_id.clone(),
|
||||
approver_identity: "alice@ops".to_string(),
|
||||
approver_role: "msp-ops".to_string(),
|
||||
comment: String::new(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let proof = svc
|
||||
.get_ceremony_proof(Request::new(GetCeremonyProofRequest {
|
||||
ceremony_id: create_resp.ceremony_id,
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(proof.status, "approved");
|
||||
assert!(!proof.proof_hash.is_empty());
|
||||
assert_eq!(proof.proof_hash.len(), 64); // SHA-256 hex
|
||||
assert_eq!(proof.approvals.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proof_on_pending_fails() {
|
||||
let svc = build_service();
|
||||
let create_resp = svc
|
||||
.create_ceremony(Request::new(CreateCeremonyRequest {
|
||||
ceremony_type: "single_approval".to_string(),
|
||||
subject: Some(merge_subject_msg()),
|
||||
required_approvals: 1,
|
||||
approver_roles: vec!["msp-ops".to_string()],
|
||||
ttl_hours: 24,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.into_inner();
|
||||
|
||||
let err = svc
|
||||
.get_ceremony_proof(Request::new(GetCeremonyProofRequest {
|
||||
ceremony_id: create_resp.ceremony_id,
|
||||
}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
|
||||
}
|
||||
}
|
||||
495
bascule-gateway/src/http_ceremony.rs
Normal file
495
bascule-gateway/src/http_ceremony.rs
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
//! HTTP endpoints for browser-based ceremony approval.
|
||||
//!
|
||||
//! Provides REST endpoints that complement the gRPC CeremonyService,
|
||||
//! enabling stakeholders to approve/deny ceremonies through a web browser
|
||||
//! or simple HTTP clients.
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! - `GET /ceremonies` — list pending ceremonies
|
||||
//! - `GET /ceremonies/:id` — get ceremony details
|
||||
//! - `POST /ceremonies/:id/approve` — approve a ceremony
|
||||
//! - `POST /ceremonies/:id/deny` — deny a ceremony
|
||||
//! - `GET /health` — health check
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use bascule_core::ceremony_engine::CeremonyEngine;
|
||||
use bascule_core::ceremony_request::{ApprovalDecision, GovernanceCeremonyStatus};
|
||||
use bascule_core::ceremony_resolution::CeremonyResolution;
|
||||
use bascule_core::ceremony_store::CeremonyStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
/// Shared state for HTTP handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct CeremonyHttpState {
|
||||
pub store: Arc<dyn CeremonyStore>,
|
||||
}
|
||||
|
||||
/// Build the axum Router for ceremony HTTP endpoints.
|
||||
pub fn ceremony_router(state: CeremonyHttpState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/ceremonies", get(list_pending))
|
||||
.route("/ceremonies/{id}", get(get_ceremony))
|
||||
.route("/ceremonies/{id}/approve", post(approve_ceremony))
|
||||
.route("/ceremonies/{id}/deny", post(deny_ceremony))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CeremonyListResponse {
|
||||
ceremonies: Vec<CeremonySummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CeremonySummary {
|
||||
ceremony_id: String,
|
||||
ceremony_type: String,
|
||||
subject_label: String,
|
||||
status: String,
|
||||
required_approvals: u32,
|
||||
current_approvals: u32,
|
||||
created_at: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
async fn list_pending(
|
||||
State(state): State<CeremonyHttpState>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let ceremonies = state
|
||||
.store
|
||||
.list_pending(None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let summaries: Vec<CeremonySummary> = ceremonies
|
||||
.iter()
|
||||
.map(|c| CeremonySummary {
|
||||
ceremony_id: c.ceremony_id.clone(),
|
||||
ceremony_type: format!("{:?}", c.ceremony_type).to_lowercase(),
|
||||
subject_label: c.subject.display_label(),
|
||||
status: status_str(c.status),
|
||||
required_approvals: c.required_approvals,
|
||||
current_approvals: c.approval_count(),
|
||||
created_at: c.created_at.to_rfc3339(),
|
||||
expires_at: c.expires_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(CeremonyListResponse {
|
||||
ceremonies: summaries,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CeremonyDetail {
|
||||
ceremony_id: String,
|
||||
ceremony_type: String,
|
||||
subject_label: String,
|
||||
status: String,
|
||||
required_approvals: u32,
|
||||
current_approvals: u32,
|
||||
approvals: Vec<ApprovalSummary>,
|
||||
created_at: String,
|
||||
expires_at: String,
|
||||
intent_id: Option<String>,
|
||||
run_id: Option<String>,
|
||||
pr_number: Option<u64>,
|
||||
remote_name: Option<String>,
|
||||
proof_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ApprovalSummary {
|
||||
approver_identity: String,
|
||||
approver_role: String,
|
||||
decision: String,
|
||||
comment: Option<String>,
|
||||
decided_at: String,
|
||||
}
|
||||
|
||||
async fn get_ceremony(
|
||||
State(state): State<CeremonyHttpState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let ceremony = state
|
||||
.store
|
||||
.get(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
|
||||
|
||||
let proof_hash = if ceremony.status.is_terminal() {
|
||||
let resolution = CeremonyResolution::from_ceremony(
|
||||
&ceremony.ceremony_id,
|
||||
ceremony.status,
|
||||
&ceremony.subject,
|
||||
&ceremony.approvals,
|
||||
);
|
||||
Some(resolution.proof_hash)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let detail = CeremonyDetail {
|
||||
ceremony_id: ceremony.ceremony_id.clone(),
|
||||
ceremony_type: format!("{:?}", ceremony.ceremony_type).to_lowercase(),
|
||||
subject_label: ceremony.subject.display_label(),
|
||||
status: status_str(ceremony.status),
|
||||
required_approvals: ceremony.required_approvals,
|
||||
current_approvals: ceremony.approval_count(),
|
||||
approvals: ceremony
|
||||
.approvals
|
||||
.iter()
|
||||
.map(|a| ApprovalSummary {
|
||||
approver_identity: a.approver_identity.clone(),
|
||||
approver_role: a.approver_role.clone(),
|
||||
decision: match a.decision {
|
||||
ApprovalDecision::Approve => "approve".to_string(),
|
||||
ApprovalDecision::Deny => "deny".to_string(),
|
||||
},
|
||||
comment: a.comment.clone(),
|
||||
decided_at: a.decided_at.to_rfc3339(),
|
||||
})
|
||||
.collect(),
|
||||
created_at: ceremony.created_at.to_rfc3339(),
|
||||
expires_at: ceremony.expires_at.to_rfc3339(),
|
||||
intent_id: ceremony.intent_id.clone(),
|
||||
run_id: ceremony.run_id.clone(),
|
||||
pr_number: ceremony.pr_number,
|
||||
remote_name: ceremony.remote_name.clone(),
|
||||
proof_hash,
|
||||
};
|
||||
|
||||
Ok(Json(detail))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ApproveRequest {
|
||||
approver_identity: String,
|
||||
approver_role: String,
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ApproveResponse {
|
||||
success: bool,
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn approve_ceremony(
|
||||
State(state): State<CeremonyHttpState>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ApproveRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let mut ceremony = state
|
||||
.store
|
||||
.get(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
|
||||
|
||||
if let Err(e) = ceremony.record_decision(
|
||||
&body.approver_identity,
|
||||
&body.approver_role,
|
||||
ApprovalDecision::Approve,
|
||||
body.comment,
|
||||
) {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApproveResponse {
|
||||
success: false,
|
||||
status: status_str(ceremony.status),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
CeremonyEngine::evaluate(&mut ceremony);
|
||||
|
||||
state
|
||||
.store
|
||||
.update(&ceremony)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
info!(
|
||||
ceremony_id = %id,
|
||||
approver = %body.approver_identity,
|
||||
status = ?ceremony.status,
|
||||
"HTTP ceremony approval"
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApproveResponse {
|
||||
success: true,
|
||||
status: status_str(ceremony.status),
|
||||
error: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn deny_ceremony(
|
||||
State(state): State<CeremonyHttpState>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ApproveRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let mut ceremony = state
|
||||
.store
|
||||
.get(&id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "ceremony not found".to_string()))?;
|
||||
|
||||
if let Err(e) = ceremony.record_decision(
|
||||
&body.approver_identity,
|
||||
&body.approver_role,
|
||||
ApprovalDecision::Deny,
|
||||
body.comment,
|
||||
) {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApproveResponse {
|
||||
success: false,
|
||||
status: status_str(ceremony.status),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
CeremonyEngine::evaluate(&mut ceremony);
|
||||
|
||||
state
|
||||
.store
|
||||
.update(&ceremony)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
info!(
|
||||
ceremony_id = %id,
|
||||
denier = %body.approver_identity,
|
||||
"HTTP ceremony denial"
|
||||
);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(ApproveResponse {
|
||||
success: true,
|
||||
status: status_str(ceremony.status),
|
||||
error: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn status_str(status: GovernanceCeremonyStatus) -> String {
|
||||
match status {
|
||||
GovernanceCeremonyStatus::Pending => "pending".to_string(),
|
||||
GovernanceCeremonyStatus::Approved => "approved".to_string(),
|
||||
GovernanceCeremonyStatus::Denied => "denied".to_string(),
|
||||
GovernanceCeremonyStatus::Expired => "expired".to_string(),
|
||||
GovernanceCeremonyStatus::Cancelled => "cancelled".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use bascule_core::ceremony_engine::CeremonyEngine;
|
||||
use bascule_core::ceremony_request::CeremonySubject;
|
||||
use bascule_core::ceremony_store::InMemoryCeremonyStore;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn build_app() -> (Router, Arc<InMemoryCeremonyStore>) {
|
||||
let store = Arc::new(InMemoryCeremonyStore::new());
|
||||
let state = CeremonyHttpState {
|
||||
store: store.clone(),
|
||||
};
|
||||
(ceremony_router(state), store)
|
||||
}
|
||||
|
||||
async fn create_test_ceremony(store: &InMemoryCeremonyStore) -> String {
|
||||
let reqs = accord_core::schema::CeremonyReqs {
|
||||
approver_roles: Some(vec!["msp-ops".to_string()]),
|
||||
quorum: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let ceremony = CeremonyEngine::create_request(
|
||||
"cer-http-001".to_string(),
|
||||
&accord_core::schema::CeremonyType::SingleApproval,
|
||||
&reqs,
|
||||
CeremonySubject::PipelineMerge {
|
||||
run_id: "run-1".to_string(),
|
||||
pipeline_name: "deploy".to_string(),
|
||||
branch: "main".to_string(),
|
||||
commit_hash: "abc".to_string(),
|
||||
remote_name: "origin".to_string(),
|
||||
},
|
||||
24,
|
||||
);
|
||||
let id = ceremony.ceremony_id.clone();
|
||||
store.create(&ceremony).await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_check() {
|
||||
let (app, _) = build_app();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_empty_ceremonies() {
|
||||
let (app, _) = build_app();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/ceremonies")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let list: CeremonyListResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert!(list.ceremonies.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ceremony_detail() {
|
||||
let (app, store) = build_app();
|
||||
let id = create_test_ceremony(&store).await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(&format!("/ceremonies/{id}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let detail: CeremonyDetail = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(detail.ceremony_id, id);
|
||||
assert_eq!(detail.status, "pending");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_via_http() {
|
||||
let (app, store) = build_app();
|
||||
let id = create_test_ceremony(&store).await;
|
||||
|
||||
let body = serde_json::to_string(&ApproveRequest {
|
||||
approver_identity: "alice@ops".to_string(),
|
||||
approver_role: "msp-ops".to_string(),
|
||||
comment: Some("LGTM".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!("/ceremonies/{id}/approve"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.status, "approved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deny_via_http() {
|
||||
let (app, store) = build_app();
|
||||
let id = create_test_ceremony(&store).await;
|
||||
|
||||
let body = serde_json::to_string(&ApproveRequest {
|
||||
approver_identity: "bob@ops".to_string(),
|
||||
approver_role: "msp-ops".to_string(),
|
||||
comment: Some("needs rework".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&format!("/ceremonies/{id}/deny"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let resp_body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: ApproveResponse = serde_json::from_slice(&resp_body).unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.status, "denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_found_ceremony() {
|
||||
let (app, _) = build_app();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/ceremonies/nonexistent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
241
bascule-gateway/src/main.rs
Normal file
241
bascule-gateway/src/main.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
mod audit_pipeline;
|
||||
mod auth;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod executor;
|
||||
mod filter;
|
||||
mod governance_ceremony;
|
||||
mod http_ceremony;
|
||||
mod migrations;
|
||||
mod server;
|
||||
mod session_manager;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::BasculeConfig;
|
||||
use crate::executor::ExecutorRegistry;
|
||||
use crate::filter::FilterChain;
|
||||
use crate::server::BasculeGatewayService;
|
||||
use crate::session_manager::SessionManager;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Workspace has both ring and aws-lc-rs rustls features.
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.json()
|
||||
.init();
|
||||
|
||||
let config = BasculeConfig::from_env()?;
|
||||
tracing::info!(listen_addr = %config.listen_addr, "Starting Bascule Gateway");
|
||||
|
||||
// 1. Load accord
|
||||
let accord = match std::fs::read_to_string(&config.accord_path) {
|
||||
Ok(yaml) => {
|
||||
let accord = accord_core::schema::Accord::load(&yaml)?;
|
||||
tracing::info!(version = %accord.metadata.version, "Accord loaded");
|
||||
Arc::new(accord)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %config.accord_path, error = %e,
|
||||
"Accord file not found — OPA policy will deny all unclassified operations");
|
||||
// Minimal empty accord
|
||||
let empty_yaml = r#"
|
||||
apiVersion: guildhouse.io/v1alpha1
|
||||
kind: Accord
|
||||
metadata:
|
||||
name: empty
|
||||
version: "0.0.0"
|
||||
previousVersionHash: "none"
|
||||
authorizingCeremony: bootstrap
|
||||
effectiveAt: "2025-01-01T00:00:00Z"
|
||||
expiresAt: "2099-01-01T00:00:00Z"
|
||||
spec:
|
||||
trustDomain: guildhouse.local
|
||||
policy:
|
||||
bundleHash: "none"
|
||||
bundlePath: "/policies"
|
||||
classifications: []
|
||||
ceremonies: []
|
||||
ledger:
|
||||
fidelity: always_notarize
|
||||
notarize: []
|
||||
logOnly: []
|
||||
sampled: []
|
||||
sampleRate: 1
|
||||
reconciliation:
|
||||
defaultWindow: "24h"
|
||||
onExpiry: alert
|
||||
driftResponses: []
|
||||
controllers: []
|
||||
roles: []
|
||||
"#;
|
||||
Arc::new(accord_core::schema::Accord::load(empty_yaml)
|
||||
.expect("empty accord must parse"))
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Connect to database (optional — degrade gracefully for dev without PG)
|
||||
let db_pool = match sqlx::PgPool::connect(&config.database_url()).await {
|
||||
Ok(pool) => {
|
||||
tracing::info!("PostgreSQL connected");
|
||||
// 3. Run migrations
|
||||
migrations::run_migrations(&pool).await?;
|
||||
Some(pool)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("PostgreSQL not available ({e}) — running in memory-only mode");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Create OPA client
|
||||
let opa_client = Arc::new(accord_opa::OpaClient::new(&config.opa_url));
|
||||
match opa_client.health_check().await {
|
||||
Ok(true) => tracing::info!("OPA sidecar is healthy"),
|
||||
_ => tracing::warn!("OPA sidecar not available — policy filter will deny all requests"),
|
||||
}
|
||||
|
||||
// 5. Build the Kubernetes client (in-cluster or from kubeconfig)
|
||||
let kube_client = kube::Client::try_default().await?;
|
||||
tracing::info!("Kubernetes client initialized");
|
||||
|
||||
// 6. Session manager (dual-store: DashMap + PG)
|
||||
let session_manager = Arc::new(SessionManager::new(db_pool.clone()));
|
||||
if db_pool.is_some() {
|
||||
let restored = session_manager.restore_from_db().await?;
|
||||
if restored > 0 {
|
||||
tracing::info!(restored, "Restored sessions from database");
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Ceremony manager
|
||||
let ceremony_manager = if let Some(pool) = &db_pool {
|
||||
Some(Arc::new(ceremony::CeremonyManager::new(
|
||||
pool.clone(),
|
||||
config.session_lifetime_secs,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 8. Audit pipeline
|
||||
let audit_pipeline = if let Some(pool) = &db_pool {
|
||||
let pipeline = Arc::new(audit_pipeline::AuditPipeline::new(
|
||||
pool.clone(),
|
||||
config.audit_batch_size,
|
||||
));
|
||||
let _flush_handle = pipeline.clone().start_flush_loop(
|
||||
std::time::Duration::from_secs(config.audit_flush_interval_secs),
|
||||
);
|
||||
pipeline
|
||||
} else {
|
||||
// No PG — create a pipeline with a lazy pool (will error on submit)
|
||||
Arc::new(audit_pipeline::AuditPipeline::new(
|
||||
sqlx::PgPool::connect_lazy("postgresql://unused:unused@localhost/unused")?,
|
||||
config.audit_batch_size,
|
||||
))
|
||||
};
|
||||
|
||||
// 9. Build executor registry
|
||||
let executor_registry = Arc::new(ExecutorRegistry::new(kube_client.clone()));
|
||||
|
||||
// 10. Build filter chain
|
||||
let auth_provider = Arc::new(auth::OidcAuthProvider::new(
|
||||
&config.oidc_issuer,
|
||||
&config.oidc_audience,
|
||||
));
|
||||
|
||||
let filter_chain = Arc::new(FilterChain::new(
|
||||
auth_provider.clone(),
|
||||
session_manager.clone(),
|
||||
executor_registry,
|
||||
opa_client,
|
||||
accord,
|
||||
audit_pipeline,
|
||||
));
|
||||
|
||||
// 11. Spawn background tasks
|
||||
let reaper_manager = session_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
reaper_manager.run_reaper().await;
|
||||
});
|
||||
|
||||
if let Some(cm) = &ceremony_manager {
|
||||
let cm = cm.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
cm.reap_expired_ceremonies().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 12. Governance ceremony service (in-memory store + expiry loop)
|
||||
let gov_ceremony_store: Arc<dyn bascule_core::ceremony_store::CeremonyStore> =
|
||||
Arc::new(bascule_core::ceremony_store::InMemoryCeremonyStore::new());
|
||||
let gov_ceremony_svc = Arc::new(governance_ceremony::GovernanceCeremonyService::new(
|
||||
gov_ceremony_store.clone(),
|
||||
));
|
||||
{
|
||||
let svc = gov_ceremony_svc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let expired = svc.expire_pending().await;
|
||||
if expired > 0 {
|
||||
tracing::info!(expired, "Governance ceremonies expired");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 13. Build gRPC server
|
||||
let service = BasculeGatewayService::new(
|
||||
filter_chain,
|
||||
session_manager,
|
||||
auth_provider,
|
||||
ceremony_manager,
|
||||
);
|
||||
|
||||
let addr = config.listen_addr.parse()?;
|
||||
tracing::info!(%addr, "Bascule Gateway listening");
|
||||
|
||||
// Spawn HTTP ceremony server on separate port
|
||||
let http_state = http_ceremony::CeremonyHttpState {
|
||||
store: gov_ceremony_store,
|
||||
};
|
||||
let http_app = http_ceremony::ceremony_router(http_state);
|
||||
let http_addr: std::net::SocketAddr = config
|
||||
.http_listen_addr()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| "0.0.0.0:8443".parse().unwrap());
|
||||
tracing::info!(%http_addr, "Ceremony HTTP server listening");
|
||||
tokio::spawn(async move {
|
||||
let listener = tokio::net::TcpListener::bind(http_addr).await.unwrap();
|
||||
axum::serve(listener, http_app).await.unwrap();
|
||||
});
|
||||
|
||||
tonic::transport::Server::builder()
|
||||
.add_service(
|
||||
bascule_proto::bascule_v1::bascule_gateway_server::BasculeGatewayServer::new(service),
|
||||
)
|
||||
.add_service(
|
||||
bascule_proto::bascule_v1::ceremony_service_server::CeremonyServiceServer::from_arc(
|
||||
gov_ceremony_svc,
|
||||
),
|
||||
)
|
||||
.serve(addr)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
122
bascule-gateway/src/migrations.rs
Normal file
122
bascule-gateway/src/migrations.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use sqlx::PgPool;
|
||||
|
||||
/// Run database migrations for the bascule schema.
|
||||
/// Creates schema and tables if they don't exist.
|
||||
pub async fn run_migrations(pool: &PgPool) -> anyhow::Result<()> {
|
||||
tracing::info!("Running bascule schema migrations");
|
||||
|
||||
// Create schema
|
||||
sqlx::query("CREATE SCHEMA IF NOT EXISTS bascule")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Ceremonies table (must exist before sessions due to FK)
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS bascule.ceremonies (
|
||||
ceremony_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ceremony_type TEXT NOT NULL,
|
||||
requestor_sub TEXT NOT NULL,
|
||||
requestor_email TEXT NOT NULL,
|
||||
requested_scope JSONB NOT NULL,
|
||||
granted_scope JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
accord_version TEXT NOT NULL,
|
||||
evidence JSONB DEFAULT '[]',
|
||||
approvers JSONB DEFAULT '[]',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
timeout_at TIMESTAMPTZ NOT NULL,
|
||||
merkle_leaf BYTEA
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Sessions table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS bascule.sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ceremony_id UUID NOT NULL REFERENCES bascule.ceremonies(ceremony_id),
|
||||
operator_sub TEXT NOT NULL,
|
||||
operator_email TEXT NOT NULL,
|
||||
scope JSONB NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'active',
|
||||
mutations_used INTEGER NOT NULL DEFAULT 0,
|
||||
mutation_budget INTEGER,
|
||||
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
terminated_at TIMESTAMPTZ,
|
||||
merkle_leaf BYTEA
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Session indexes
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_operator ON bascule.sessions (operator_sub, state)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_active ON bascule.sessions (state) WHERE state = 'active'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Audit events table
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS bascule.audit_events (
|
||||
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
event_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL,
|
||||
operator_identity TEXT NOT NULL,
|
||||
command JSONB NOT NULL,
|
||||
classification TEXT NOT NULL,
|
||||
policy_decision JSONB NOT NULL,
|
||||
execution_result JSONB NOT NULL,
|
||||
target_resources JSONB DEFAULT '[]',
|
||||
target_profile_hash TEXT,
|
||||
notarized BOOLEAN NOT NULL DEFAULT false,
|
||||
anchor_id UUID,
|
||||
leaf_index INTEGER,
|
||||
merkle_leaf BYTEA
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Try to create hypertable (only works if TimescaleDB is available)
|
||||
match sqlx::query(
|
||||
"SELECT create_hypertable('bascule.audit_events', 'time', if_not_exists => true)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => tracing::info!("audit_events hypertable created"),
|
||||
Err(e) => tracing::warn!("TimescaleDB not available, skipping hypertable: {e}"),
|
||||
}
|
||||
|
||||
// Audit event indexes
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_session ON bascule.audit_events (session_id, time DESC)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_operator ON bascule.audit_events (operator_identity, time DESC)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Bascule schema migrations complete");
|
||||
Ok(())
|
||||
}
|
||||
499
bascule-gateway/src/server.rs
Normal file
499
bascule-gateway/src/server.rs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use bascule_core::ceremony::CeremonyType;
|
||||
use bascule_core::command::builtin_commands;
|
||||
use bascule_core::scope::{
|
||||
ChangePathway, GlobalScope, NamespaceScope, ScopeRule, SessionScope, Verb,
|
||||
};
|
||||
use tonic::{Request, Response, Status};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::OidcAuthProvider;
|
||||
use crate::ceremony::{CeremonyManager, CeremonyResponse};
|
||||
use crate::filter::FilterChain;
|
||||
use crate::session_manager::SessionManager;
|
||||
|
||||
pub struct BasculeGatewayService {
|
||||
filter_chain: Arc<FilterChain>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
auth_provider: Arc<OidcAuthProvider>,
|
||||
ceremony_manager: Option<Arc<CeremonyManager>>,
|
||||
}
|
||||
|
||||
impl BasculeGatewayService {
|
||||
pub fn new(
|
||||
filter_chain: Arc<FilterChain>,
|
||||
session_manager: Arc<SessionManager>,
|
||||
auth_provider: Arc<OidcAuthProvider>,
|
||||
ceremony_manager: Option<Arc<CeremonyManager>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
filter_chain,
|
||||
session_manager,
|
||||
auth_provider,
|
||||
ceremony_manager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl bascule_proto::bascule_v1::bascule_gateway_server::BasculeGateway for BasculeGatewayService {
|
||||
async fn request_session(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::RequestSessionRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::RequestSessionResponse>, Status> {
|
||||
let token = extract_bearer_token(request.metadata())
|
||||
.ok_or_else(|| Status::unauthenticated("missing authorization header"))?;
|
||||
|
||||
let identity = self
|
||||
.auth_provider
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.map_err(|e| Status::unauthenticated(e.to_string()))?;
|
||||
|
||||
let inner = request.into_inner();
|
||||
|
||||
let scope = inner
|
||||
.requested_scope
|
||||
.as_ref()
|
||||
.map(proto_scope_to_core)
|
||||
.unwrap_or_else(|| SessionManager::default_read_scope(&["default".into()]));
|
||||
|
||||
if let Some(cm) = &self.ceremony_manager {
|
||||
let response = match inner.ceremony_type.as_str() {
|
||||
"self_grant" => cm
|
||||
.process_self_grant(&identity, &scope, "1.0.0")
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?,
|
||||
"single_approval" => cm
|
||||
.process_single_approval(&identity, &scope, "1.0.0")
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?,
|
||||
"emergency_access" | "break_glass" => {
|
||||
let evidence: Vec<bascule_core::ceremony::Evidence> = inner
|
||||
.evidence
|
||||
.iter()
|
||||
.map(|e| bascule_core::ceremony::Evidence {
|
||||
evidence_type: parse_evidence_type(&e.evidence_type),
|
||||
reference: e.reference.clone(),
|
||||
verified: false,
|
||||
verified_at: None,
|
||||
})
|
||||
.collect();
|
||||
cm.process_break_glass(&identity, &scope, &evidence, "1.0.0")
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
}
|
||||
other => {
|
||||
return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Denied(
|
||||
bascule_proto::bascule_v1::CeremonyDenied {
|
||||
reason: format!("unsupported ceremony type: {other}"),
|
||||
},
|
||||
),
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match response {
|
||||
CeremonyResponse::Granted(grant) => {
|
||||
let session = self
|
||||
.session_manager
|
||||
.create_session(&grant)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Granted(
|
||||
bascule_proto::bascule_v1::SessionGranted {
|
||||
session_id: session.session_id.to_string(),
|
||||
granted_scope: Some(core_scope_to_proto(&session.scope)),
|
||||
expires_at: Some(to_proto_timestamp(&session.expires_at)),
|
||||
ceremony_id: session.ceremony_id.to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
}))
|
||||
}
|
||||
CeremonyResponse::Pending {
|
||||
ceremony_id,
|
||||
timeout_at,
|
||||
} => Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Pending(
|
||||
bascule_proto::bascule_v1::CeremonyPending {
|
||||
ceremony_id: ceremony_id.to_string(),
|
||||
message: "Awaiting approval".to_string(),
|
||||
timeout_at: Some(to_proto_timestamp(&timeout_at)),
|
||||
},
|
||||
),
|
||||
),
|
||||
})),
|
||||
CeremonyResponse::Denied(reason) => {
|
||||
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Denied(
|
||||
bascule_proto::bascule_v1::CeremonyDenied { reason },
|
||||
),
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No ceremony manager (memory-only mode)
|
||||
if inner.ceremony_type != "self_grant" {
|
||||
return Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Denied(
|
||||
bascule_proto::bascule_v1::CeremonyDenied {
|
||||
reason: format!(
|
||||
"ceremony type '{}' requires database (not available)",
|
||||
inner.ceremony_type
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
let grant = bascule_core::ceremony::CeremonyGrant {
|
||||
ceremony_id: Uuid::new_v4(),
|
||||
ceremony_type: CeremonyType::SelfGrant,
|
||||
requestor: identity,
|
||||
approvers: vec![],
|
||||
granted_scope: scope,
|
||||
accord_version: "none".into(),
|
||||
evidence: vec![],
|
||||
granted_at: chrono::Utc::now(),
|
||||
session_lifetime: chrono::Duration::hours(8),
|
||||
};
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.create_session(&grant)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::RequestSessionResponse {
|
||||
result: Some(
|
||||
bascule_proto::bascule_v1::request_session_response::Result::Granted(
|
||||
bascule_proto::bascule_v1::SessionGranted {
|
||||
session_id: session.session_id.to_string(),
|
||||
granted_scope: Some(core_scope_to_proto(&session.scope)),
|
||||
expires_at: Some(to_proto_timestamp(&session.expires_at)),
|
||||
ceremony_id: session.ceremony_id.to_string(),
|
||||
},
|
||||
),
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_session_status(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::GetSessionStatusRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::GetSessionStatusResponse>, Status> {
|
||||
let inner = request.into_inner();
|
||||
let session_id: Uuid = inner
|
||||
.session_id
|
||||
.parse()
|
||||
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.get_session(&session_id)
|
||||
.ok_or_else(|| Status::not_found("session not found"))?;
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::GetSessionStatusResponse {
|
||||
session_id: session.session_id.to_string(),
|
||||
state: format!("{:?}", session.state).to_lowercase(),
|
||||
scope: Some(core_scope_to_proto(&session.scope)),
|
||||
expires_at: Some(to_proto_timestamp(&session.expires_at)),
|
||||
mutations_used: session.mutations_used,
|
||||
mutation_budget: session.scope.mutation_budget,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn end_session(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::EndSessionRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::EndSessionResponse>, Status> {
|
||||
let inner = request.into_inner();
|
||||
let session_id: Uuid = inner
|
||||
.session_id
|
||||
.parse()
|
||||
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.end_session(&session_id)
|
||||
.await
|
||||
.ok_or_else(|| Status::not_found("session not found"))?;
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::EndSessionResponse {
|
||||
success: true,
|
||||
total_commands: 0,
|
||||
total_mutations: session.mutations_used,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_ceremony_status(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::GetCeremonyStatusRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::GetCeremonyStatusResponse>, Status> {
|
||||
let cm = self
|
||||
.ceremony_manager
|
||||
.as_ref()
|
||||
.ok_or_else(|| Status::unimplemented("ceremony manager not available"))?;
|
||||
|
||||
let inner = request.into_inner();
|
||||
let ceremony_id: Uuid = inner
|
||||
.ceremony_id
|
||||
.parse()
|
||||
.map_err(|_| Status::invalid_argument("invalid ceremony_id"))?;
|
||||
|
||||
let status = cm
|
||||
.get_ceremony_status(ceremony_id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?
|
||||
.ok_or_else(|| Status::not_found("ceremony not found"))?;
|
||||
|
||||
match status {
|
||||
crate::ceremony::CeremonyStatus::Pending {
|
||||
ceremony_id,
|
||||
..
|
||||
} => Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
|
||||
ceremony_id: ceremony_id.to_string(),
|
||||
status: "pending".to_string(),
|
||||
session: None,
|
||||
})),
|
||||
crate::ceremony::CeremonyStatus::Approved { grant } => {
|
||||
let session = self
|
||||
.session_manager
|
||||
.create_session(&grant)
|
||||
.await
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
|
||||
ceremony_id: grant.ceremony_id.to_string(),
|
||||
status: "approved".to_string(),
|
||||
session: Some(bascule_proto::bascule_v1::SessionGranted {
|
||||
session_id: session.session_id.to_string(),
|
||||
granted_scope: Some(core_scope_to_proto(&session.scope)),
|
||||
expires_at: Some(to_proto_timestamp(&session.expires_at)),
|
||||
ceremony_id: grant.ceremony_id.to_string(),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
crate::ceremony::CeremonyStatus::Denied { reason } => {
|
||||
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
|
||||
ceremony_id: ceremony_id.to_string(),
|
||||
status: format!("denied: {reason}"),
|
||||
session: None,
|
||||
}))
|
||||
}
|
||||
crate::ceremony::CeremonyStatus::Expired => {
|
||||
Ok(Response::new(bascule_proto::bascule_v1::GetCeremonyStatusResponse {
|
||||
ceremony_id: ceremony_id.to_string(),
|
||||
status: "expired".to_string(),
|
||||
session: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_command(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::ExecuteCommandRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::ExecuteCommandResponse>, Status> {
|
||||
let bearer_token = extract_bearer_token(request.metadata());
|
||||
let command = request.into_inner();
|
||||
|
||||
let response = self.filter_chain.execute(bearer_token, command).await;
|
||||
Ok(Response::new(response))
|
||||
}
|
||||
|
||||
type StreamCommandStream =
|
||||
tokio_stream::wrappers::ReceiverStream<Result<bascule_proto::bascule_v1::CommandStreamChunk, Status>>;
|
||||
|
||||
async fn stream_command(
|
||||
&self,
|
||||
_request: Request<bascule_proto::bascule_v1::ExecuteCommandRequest>,
|
||||
) -> Result<Response<Self::StreamCommandStream>, Status> {
|
||||
Err(Status::unimplemented(
|
||||
"streaming commands not implemented in Phase 2",
|
||||
))
|
||||
}
|
||||
|
||||
async fn discover_commands(
|
||||
&self,
|
||||
request: Request<bascule_proto::bascule_v1::DiscoverCommandsRequest>,
|
||||
) -> Result<Response<bascule_proto::bascule_v1::DiscoverCommandsResponse>, Status> {
|
||||
let inner = request.into_inner();
|
||||
let session_id: Uuid = inner
|
||||
.session_id
|
||||
.parse()
|
||||
.map_err(|_| Status::invalid_argument("invalid session_id"))?;
|
||||
|
||||
let _session = self
|
||||
.session_manager
|
||||
.get_session(&session_id)
|
||||
.filter(|s| s.is_active())
|
||||
.ok_or_else(|| Status::not_found("session not found or expired"))?;
|
||||
|
||||
let commands = builtin_commands()
|
||||
.into_iter()
|
||||
.map(|cmd| bascule_proto::bascule_v1::CommandDescriptor {
|
||||
verb: cmd.verb,
|
||||
description: cmd.description,
|
||||
classification: format!("{:?}", cmd.classification).to_lowercase(),
|
||||
parameters: vec![],
|
||||
requires_namespace: cmd.requires_namespace,
|
||||
requires_resource: cmd.requires_resource,
|
||||
streaming: cmd.streaming,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Response::new(bascule_proto::bascule_v1::DiscoverCommandsResponse {
|
||||
commands,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
fn extract_bearer_token(metadata: &tonic::metadata::MetadataMap) -> Option<String> {
|
||||
metadata
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn to_proto_timestamp(dt: &chrono::DateTime<chrono::Utc>) -> prost_types::Timestamp {
|
||||
prost_types::Timestamp {
|
||||
seconds: dt.timestamp(),
|
||||
nanos: dt.timestamp_subsec_nanos() as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_evidence_type(s: &str) -> bascule_core::ceremony::EvidenceType {
|
||||
match s {
|
||||
"jira_ticket" => bascule_core::ceremony::EvidenceType::JiraTicket,
|
||||
"github_issue" => bascule_core::ceremony::EvidenceType::GitHubIssue,
|
||||
"slack_thread" => bascule_core::ceremony::EvidenceType::SlackThread,
|
||||
"pagerduty_incident" => bascule_core::ceremony::EvidenceType::PagerDutyIncident,
|
||||
_ => bascule_core::ceremony::EvidenceType::Manual,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scope conversion: proto ↔ core ---
|
||||
|
||||
fn proto_scope_to_core(proto: &bascule_proto::bascule_v1::SessionScope) -> SessionScope {
|
||||
SessionScope {
|
||||
namespaces: proto.namespaces.iter().map(proto_ns_to_core).collect(),
|
||||
global: proto
|
||||
.global
|
||||
.as_ref()
|
||||
.map(proto_global_to_core)
|
||||
.unwrap_or_default(),
|
||||
pathways: proto.pathways.iter().map(|p| parse_pathway(p)).collect(),
|
||||
mutation_budget: proto.mutation_budget,
|
||||
can_delegate: proto.can_delegate,
|
||||
}
|
||||
}
|
||||
|
||||
fn proto_ns_to_core(proto: &bascule_proto::bascule_v1::NamespaceScope) -> NamespaceScope {
|
||||
NamespaceScope {
|
||||
namespace: proto.namespace.clone(),
|
||||
rules: proto
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| ScopeRule {
|
||||
api_groups: r.api_groups.clone(),
|
||||
resources: r.resources.clone(),
|
||||
verbs: r.verbs.iter().filter_map(|v| parse_verb(v)).collect(),
|
||||
})
|
||||
.collect(),
|
||||
workload_profiles: proto.workload_profiles.clone(),
|
||||
denied_capabilities: proto.denied_capabilities.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proto_global_to_core(proto: &bascule_proto::bascule_v1::GlobalScope) -> GlobalScope {
|
||||
GlobalScope {
|
||||
can_view_audit_trail: proto.can_view_audit_trail,
|
||||
can_view_profiles: proto.can_view_profiles,
|
||||
can_view_topology: proto.can_view_topology,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pathway(s: &str) -> ChangePathway {
|
||||
match s {
|
||||
"workspace" => ChangePathway::Workspace,
|
||||
"dry_run_only" => ChangePathway::DryRunOnly,
|
||||
_ => ChangePathway::Direct,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_verb(s: &str) -> Option<Verb> {
|
||||
match s {
|
||||
"get" => Some(Verb::Get),
|
||||
"list" => Some(Verb::List),
|
||||
"watch" => Some(Verb::Watch),
|
||||
"create" => Some(Verb::Create),
|
||||
"update" => Some(Verb::Update),
|
||||
"patch" => Some(Verb::Patch),
|
||||
"delete" => Some(Verb::Delete),
|
||||
"exec" => Some(Verb::Exec),
|
||||
"logs" => Some(Verb::Logs),
|
||||
"scale" => Some(Verb::Scale),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn core_scope_to_proto(core: &SessionScope) -> bascule_proto::bascule_v1::SessionScope {
|
||||
bascule_proto::bascule_v1::SessionScope {
|
||||
namespaces: core.namespaces.iter().map(core_ns_to_proto).collect(),
|
||||
global: Some(core_global_to_proto(&core.global)),
|
||||
pathways: core
|
||||
.pathways
|
||||
.iter()
|
||||
.map(|p| format!("{p:?}").to_lowercase())
|
||||
.collect(),
|
||||
mutation_budget: core.mutation_budget,
|
||||
can_delegate: core.can_delegate,
|
||||
}
|
||||
}
|
||||
|
||||
fn core_ns_to_proto(core: &NamespaceScope) -> bascule_proto::bascule_v1::NamespaceScope {
|
||||
bascule_proto::bascule_v1::NamespaceScope {
|
||||
namespace: core.namespace.clone(),
|
||||
rules: core
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| bascule_proto::bascule_v1::ScopeRule {
|
||||
api_groups: r.api_groups.clone(),
|
||||
resources: r.resources.clone(),
|
||||
verbs: r
|
||||
.verbs
|
||||
.iter()
|
||||
.map(|v| format!("{v:?}").to_lowercase())
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
workload_profiles: core.workload_profiles.clone(),
|
||||
denied_capabilities: core.denied_capabilities.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn core_global_to_proto(core: &GlobalScope) -> bascule_proto::bascule_v1::GlobalScope {
|
||||
bascule_proto::bascule_v1::GlobalScope {
|
||||
can_view_audit_trail: core.can_view_audit_trail,
|
||||
can_view_profiles: core.can_view_profiles,
|
||||
can_view_topology: core.can_view_topology,
|
||||
}
|
||||
}
|
||||
251
bascule-gateway/src/session_manager.rs
Normal file
251
bascule-gateway/src/session_manager.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
use bascule_core::ceremony::CeremonyGrant;
|
||||
use bascule_core::scope::SessionScope;
|
||||
use bascule_core::session::{OperatorIdentity, Session, SessionState};
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Dual-store session manager: DashMap (hot cache) + PostgreSQL (persistence).
|
||||
pub struct SessionManager {
|
||||
sessions: DashMap<Uuid, Session>,
|
||||
db_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
/// Create a new session manager. Pass None for db_pool in tests or Phase 1 mode.
|
||||
pub fn new(db_pool: Option<PgPool>) -> Self {
|
||||
Self {
|
||||
sessions: DashMap::new(),
|
||||
db_pool,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a session from a ceremony grant.
|
||||
pub async fn create_session(&self, grant: &CeremonyGrant) -> anyhow::Result<Session> {
|
||||
let session_id = Uuid::new_v4();
|
||||
let now = Utc::now();
|
||||
|
||||
let session = Session {
|
||||
session_id,
|
||||
ceremony_id: grant.ceremony_id,
|
||||
identity: grant.requestor.clone(),
|
||||
scope: grant.granted_scope.clone(),
|
||||
state: SessionState::Active,
|
||||
mutations_used: 0,
|
||||
valid_from: now,
|
||||
expires_at: now + grant.session_lifetime,
|
||||
};
|
||||
|
||||
// Insert into hot cache
|
||||
self.sessions.insert(session_id, session.clone());
|
||||
|
||||
// Persist to PG
|
||||
if let Some(pool) = &self.db_pool {
|
||||
let (sub, email) = identity_parts(&grant.requestor);
|
||||
let scope_json = serde_json::to_value(&grant.granted_scope)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bascule.sessions
|
||||
(session_id, ceremony_id, operator_sub, operator_email,
|
||||
scope, state, mutations_used, mutation_budget,
|
||||
valid_from, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', 0, $6, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(grant.ceremony_id)
|
||||
.bind(&sub)
|
||||
.bind(&email)
|
||||
.bind(&scope_json)
|
||||
.bind(grant.granted_scope.mutation_budget.map(|b| b as i32))
|
||||
.bind(session.valid_from)
|
||||
.bind(session.expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
session_id = %session_id,
|
||||
ceremony_id = %grant.ceremony_id,
|
||||
"Session created"
|
||||
);
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Look up a session by ID. Checks DashMap first, then PG.
|
||||
pub fn get_session(&self, session_id: &Uuid) -> Option<Session> {
|
||||
self.sessions.get(session_id).map(|s| s.clone())
|
||||
}
|
||||
|
||||
/// End a session explicitly. Updates both stores.
|
||||
pub async fn end_session(&self, session_id: &Uuid) -> Option<Session> {
|
||||
let session = self.sessions.get_mut(session_id).map(|mut s| {
|
||||
s.state = SessionState::Terminated;
|
||||
s.clone()
|
||||
});
|
||||
|
||||
if session.is_some() {
|
||||
if let Some(pool) = &self.db_pool {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.sessions SET state = 'terminated', terminated_at = NOW() WHERE session_id = $1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
tracing::info!(%session_id, "Session terminated");
|
||||
}
|
||||
|
||||
session
|
||||
}
|
||||
|
||||
/// Record a mutation against a session. Updates both stores.
|
||||
/// Returns the new mutation count.
|
||||
pub async fn record_mutation(&self, session_id: &Uuid) -> Option<u32> {
|
||||
let count = self.sessions.get_mut(session_id).map(|mut s| {
|
||||
s.mutations_used += 1;
|
||||
s.mutations_used
|
||||
});
|
||||
|
||||
if let (Some(count), Some(pool)) = (count, &self.db_pool) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.sessions SET mutations_used = $2 WHERE session_id = $1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(count as i32)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Restore active sessions from PG into DashMap (crash recovery).
|
||||
pub async fn restore_from_db(&self) -> anyhow::Result<usize> {
|
||||
let pool = match &self.db_pool {
|
||||
Some(p) => p,
|
||||
None => return Ok(0),
|
||||
};
|
||||
|
||||
let rows = sqlx::query_as::<_, SessionRow>(
|
||||
r#"
|
||||
SELECT session_id, ceremony_id, operator_sub, operator_email,
|
||||
scope, state, mutations_used, mutation_budget,
|
||||
valid_from, expires_at
|
||||
FROM bascule.sessions
|
||||
WHERE state = 'active' AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let count = rows.len();
|
||||
for row in rows {
|
||||
let scope: SessionScope = serde_json::from_value(row.scope)?;
|
||||
let session = Session {
|
||||
session_id: row.session_id,
|
||||
ceremony_id: row.ceremony_id,
|
||||
identity: OperatorIdentity::Oidc {
|
||||
issuer: String::new(),
|
||||
subject: row.operator_sub,
|
||||
email: row.operator_email,
|
||||
},
|
||||
scope,
|
||||
state: SessionState::Active,
|
||||
mutations_used: row.mutations_used as u32,
|
||||
valid_from: row.valid_from,
|
||||
expires_at: row.expires_at,
|
||||
};
|
||||
self.sessions.insert(session.session_id, session);
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
tracing::info!(count, "Restored active sessions from database");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Background reaper for expired sessions (runs every 30 seconds).
|
||||
pub async fn run_reaper(&self) {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = Utc::now();
|
||||
let mut expired = 0u32;
|
||||
for mut entry in self.sessions.iter_mut() {
|
||||
if entry.state == SessionState::Active && now >= entry.expires_at {
|
||||
entry.state = SessionState::Expired;
|
||||
expired += 1;
|
||||
}
|
||||
}
|
||||
if expired > 0 {
|
||||
tracing::info!(expired, "Reaped expired sessions");
|
||||
// Update PG for expired sessions
|
||||
if let Some(pool) = &self.db_pool {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE bascule.sessions SET state = 'expired' WHERE state = 'active' AND expires_at < NOW()",
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a default read-only scope for the given namespaces.
|
||||
pub fn default_read_scope(namespaces: &[String]) -> SessionScope {
|
||||
use bascule_core::scope::{
|
||||
ChangePathway, GlobalScope, NamespaceScope, ScopeRule, Verb,
|
||||
};
|
||||
|
||||
let ns_scopes = namespaces
|
||||
.iter()
|
||||
.map(|ns| NamespaceScope {
|
||||
namespace: ns.clone(),
|
||||
rules: vec![ScopeRule {
|
||||
api_groups: vec!["".into(), "apps".into(), "batch".into()],
|
||||
resources: vec!["*".into()],
|
||||
verbs: vec![Verb::Get, Verb::List, Verb::Logs],
|
||||
}],
|
||||
workload_profiles: vec![],
|
||||
denied_capabilities: vec![],
|
||||
})
|
||||
.collect();
|
||||
|
||||
SessionScope {
|
||||
namespaces: ns_scopes,
|
||||
global: GlobalScope::default(),
|
||||
pathways: vec![ChangePathway::DryRunOnly],
|
||||
mutation_budget: Some(0),
|
||||
can_delegate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SessionRow {
|
||||
session_id: Uuid,
|
||||
ceremony_id: Uuid,
|
||||
operator_sub: String,
|
||||
operator_email: String,
|
||||
scope: serde_json::Value,
|
||||
#[allow(dead_code)]
|
||||
state: String,
|
||||
mutations_used: i32,
|
||||
#[allow(dead_code)]
|
||||
mutation_budget: Option<i32>,
|
||||
valid_from: chrono::DateTime<chrono::Utc>,
|
||||
expires_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
fn identity_parts(identity: &OperatorIdentity) -> (String, String) {
|
||||
match identity {
|
||||
OperatorIdentity::Oidc {
|
||||
subject, email, ..
|
||||
} => (subject.clone(), email.clone()),
|
||||
OperatorIdentity::Spiffe { svid_uri } => (svid_uri.clone(), String::new()),
|
||||
}
|
||||
}
|
||||
190
bascule-gateway/tests/fixtures/test-accord.yaml
vendored
Normal file
190
bascule-gateway/tests/fixtures/test-accord.yaml
vendored
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
apiVersion: guildhouse.io/v1alpha1
|
||||
kind: Accord
|
||||
metadata:
|
||||
name: genesis-accord
|
||||
version: "1.0.0"
|
||||
previousVersionHash: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
authorizingCeremony: bootstrap
|
||||
effectiveAt: "2025-06-01T00:00:00Z"
|
||||
expiresAt: "2027-06-01T00:00:00Z"
|
||||
spec:
|
||||
trustDomain: guildhouse.local
|
||||
policy:
|
||||
bundleHash: "sha256:genesis"
|
||||
bundlePath: ".guildhouse/policies/"
|
||||
classifications:
|
||||
- name: read-access
|
||||
description: Read-only
|
||||
pathways: [imperative, declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- name: workload-scaling
|
||||
description: Scale workload replicas
|
||||
pathways: [imperative, declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "statefulsets"]
|
||||
fields: ["spec.replicas"]
|
||||
verbs: ["patch", "update"]
|
||||
- name: workload-deployment
|
||||
description: Deploy and update workloads
|
||||
pathways: [imperative, declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "statefulsets", "daemonsets"]
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
- name: rbac-modification
|
||||
description: Modify RBAC resources
|
||||
pathways: [declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"]
|
||||
verbs: ["*"]
|
||||
- name: network-policy
|
||||
description: Manage network policies
|
||||
pathways: [declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["networking.k8s.io"]
|
||||
resources: ["networkpolicies"]
|
||||
verbs: ["*"]
|
||||
- name: secret-management
|
||||
description: Manage secrets
|
||||
pathways: [declarative]
|
||||
resourceSelectors:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "update", "patch", "delete"]
|
||||
- name: emergency-access
|
||||
description: Emergency break-glass access
|
||||
pathways: [imperative]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["delete"]
|
||||
- name: accord-change
|
||||
description: Changes to the accord document
|
||||
pathways: [declarative]
|
||||
resourceSelectors:
|
||||
- paths:
|
||||
- ".guildhouse/accord.yaml"
|
||||
- ".guildhouse/policies/**"
|
||||
- name: workspace-merge
|
||||
description: Merge workspace changes
|
||||
pathways: [declarative]
|
||||
resourceSelectors:
|
||||
- paths:
|
||||
- "namespaces/**"
|
||||
- name: reconciliation-merge
|
||||
description: Automated reconciliation
|
||||
pathways: [autonomous]
|
||||
resourceSelectors:
|
||||
- apiGroups: ["*"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
ceremonies:
|
||||
- classification: read-access
|
||||
type: self_grant
|
||||
requirements:
|
||||
maxDuration: "8h"
|
||||
scopeConstraints:
|
||||
verbs: ["get", "list", "watch"]
|
||||
- classification: workload-scaling
|
||||
type: single_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
maxDuration: "4h"
|
||||
mutationBudget: 10
|
||||
requiresCapabilityProfile: true
|
||||
- classification: workload-deployment
|
||||
type: single_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
maxDuration: "4h"
|
||||
mutationBudget: 5
|
||||
requiresTwinValidation: true
|
||||
- classification: rbac-modification
|
||||
type: quorum_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
quorum: 2
|
||||
maxDuration: "2h"
|
||||
mutationBudget: 3
|
||||
- classification: network-policy
|
||||
type: single_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
maxDuration: "4h"
|
||||
- classification: secret-management
|
||||
type: quorum_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
quorum: 2
|
||||
maxDuration: "2h"
|
||||
mutationBudget: 3
|
||||
- classification: emergency-access
|
||||
type: break_glass
|
||||
requirements:
|
||||
maxDuration: "30m"
|
||||
mandatoryPostIncidentReview: true
|
||||
externalEvidence:
|
||||
type: jira_ticket
|
||||
project: INCIDENT
|
||||
status: ["Active", "In Progress"]
|
||||
- classification: accord-change
|
||||
type: quorum_approval
|
||||
requirements:
|
||||
quorum: 2
|
||||
requiresRegoTestsPass: true
|
||||
requiresSchemaValidation: true
|
||||
- classification: workspace-merge
|
||||
type: single_approval
|
||||
requirements:
|
||||
approverRoles: ["namespace-admin"]
|
||||
- classification: reconciliation-merge
|
||||
type: autonomous
|
||||
requirements:
|
||||
controllerSvidMatch: "spiffe://guildhouse.local/ns/*/sa/reconciler"
|
||||
ledger:
|
||||
alwaysNotarize:
|
||||
- ceremony_completion
|
||||
- session_creation
|
||||
- mutation_applied
|
||||
logOnly:
|
||||
- read_access
|
||||
- session_heartbeat
|
||||
sampled:
|
||||
events:
|
||||
- health_check
|
||||
sampleRate: 100
|
||||
reconciliation:
|
||||
defaultWindow: "24h"
|
||||
onExpiry: alert
|
||||
driftCheckInterval: "5m"
|
||||
driftResponses:
|
||||
- resourceSelector:
|
||||
apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
action: alert
|
||||
- resourceSelector:
|
||||
apiGroups: ["apps"]
|
||||
resources: ["deployments"]
|
||||
action: auto_reconcile
|
||||
controllers:
|
||||
- svid: "spiffe://guildhouse.local/ns/argocd/sa/argocd-application-controller"
|
||||
classification: workload-deployment
|
||||
permittedMutations:
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "statefulsets"]
|
||||
verbs: ["create", "update", "patch"]
|
||||
ledgerFidelity: full
|
||||
roles:
|
||||
- name: namespace-admin
|
||||
members:
|
||||
- identity: "spiffe://guildhouse.local/ns/capstone/sa/admin"
|
||||
- identity: "oidc:tking@guildhouse.local"
|
||||
namespaces: ["capstone", "quartermaster"]
|
||||
- name: cluster-admin
|
||||
members:
|
||||
- identity: "oidc:tking@guildhouse.local"
|
||||
39
bascule-node-agent/Cargo.toml
Normal file
39
bascule-node-agent/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[package]
|
||||
name = "bascule-node-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bascule node agent — DaemonSet for per-node shell admission and webhook emission"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule-node-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Kubernetes
|
||||
kube = { workspace = true }
|
||||
k8s-openapi = { workspace = true }
|
||||
|
||||
# HTTP (webhook emission)
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = "0.7"
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Observability
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Common
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# TLS (required by kube)
|
||||
rustls = { workspace = true }
|
||||
140
bascule-node-agent/src/admission.rs
Normal file
140
bascule-node-agent/src/admission.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
use tokio::net::UnixListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::events::NodeEvent;
|
||||
|
||||
/// Listens on a Unix socket for CNI ADD notifications from substrate-cni.
|
||||
///
|
||||
/// **substrate-cni does not exist yet.** This is a stub listener that
|
||||
/// accepts connections, reads a JSON payload, and emits a `ShellBound`
|
||||
/// event when valid data arrives.
|
||||
///
|
||||
/// Expected CNI ADD payload (future):
|
||||
/// ```json
|
||||
/// {"pod_uid": "...", "namespace": "...", "cgroup_id": 12345,
|
||||
/// "container_id": "...", "did": "did:substrate:..."}
|
||||
/// ```
|
||||
pub struct AdmissionListener {
|
||||
socket_path: String,
|
||||
event_tx: mpsc::Sender<NodeEvent>,
|
||||
node_name: String,
|
||||
}
|
||||
|
||||
impl AdmissionListener {
|
||||
pub fn new(
|
||||
socket_path: String,
|
||||
event_tx: mpsc::Sender<NodeEvent>,
|
||||
node_name: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_path,
|
||||
event_tx,
|
||||
node_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the admission listener. Never returns under normal operation.
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
// Clean up stale socket file
|
||||
let _ = std::fs::remove_file(&self.socket_path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = std::path::Path::new(&self.socket_path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&self.socket_path)?;
|
||||
|
||||
// Restrict socket permissions — only bascule-node-agent should connect.
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
&self.socket_path,
|
||||
std::fs::Permissions::from_mode(0o660),
|
||||
)?;
|
||||
|
||||
tracing::info!(path = %self.socket_path, "Admission listener started");
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _addr)) => {
|
||||
let tx = self.event_tx.clone();
|
||||
let node = self.node_name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, &tx, &node).await {
|
||||
tracing::debug!(error = %e, "Admission connection error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to accept admission connection");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single CNI ADD connection.
|
||||
async fn handle_connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
event_tx: &mpsc::Sender<NodeEvent>,
|
||||
node_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut stream = stream;
|
||||
let n = stream.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_slice(&buf[..n])?;
|
||||
|
||||
let pod_uid = payload
|
||||
.get("pod_uid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let namespace = payload
|
||||
.get("namespace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let cgroup_id = payload
|
||||
.get("cgroup_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or_default();
|
||||
let did = payload
|
||||
.get("did")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("did:substrate:unknown")
|
||||
.to_string();
|
||||
|
||||
if pod_uid.is_empty() || namespace.is_empty() || cgroup_id == 0 {
|
||||
tracing::warn!("Incomplete CNI ADD payload: {payload}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!(
|
||||
pod_uid = %pod_uid,
|
||||
namespace = %namespace,
|
||||
cgroup_id,
|
||||
"CNI admission received"
|
||||
);
|
||||
|
||||
let event = NodeEvent::ShellBound {
|
||||
session_id,
|
||||
did,
|
||||
cgroup_id,
|
||||
namespace,
|
||||
tenant: None, // Tenant resolved by the webhook handler
|
||||
node: node_name.to_string(),
|
||||
pod_uid,
|
||||
};
|
||||
|
||||
event_tx.send(event).await?;
|
||||
Ok(())
|
||||
}
|
||||
47
bascule-node-agent/src/bpf_manager.rs
Normal file
47
bascule-node-agent/src/bpf_manager.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/// Shell BPF map manager.
|
||||
///
|
||||
/// Programs the `shell_map` eBPF map when a pod is admitted with a
|
||||
/// known `cgroup_id`.
|
||||
///
|
||||
/// **STUB**: Real implementation requires `aya` and the shell BPF
|
||||
/// programs from `substrate/shell/ebpf/`. The interface is defined
|
||||
/// here; implementation is deferred until substrate-cni and the shell
|
||||
/// BPF programs are available.
|
||||
pub struct BpfManager;
|
||||
|
||||
impl BpfManager {
|
||||
pub fn new() -> Self {
|
||||
BpfManager
|
||||
}
|
||||
|
||||
/// Program `shell_map` for a new pod.
|
||||
///
|
||||
/// STUB: logs the cgroup_id and returns Ok.
|
||||
/// Real implementation will write a `shell_state` entry to the
|
||||
/// BPF hash map keyed by cgroup_id.
|
||||
pub async fn admit_pod(
|
||||
&self,
|
||||
cgroup_id: u64,
|
||||
namespace: &str,
|
||||
tenant: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
cgroup_id,
|
||||
namespace,
|
||||
tenant,
|
||||
"BpfManager::admit_pod (stub — shell_map programming not yet implemented)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove `shell_map` entry for a terminated pod.
|
||||
///
|
||||
/// STUB: logs and returns Ok.
|
||||
pub async fn terminate_pod(&self, cgroup_id: u64) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
cgroup_id,
|
||||
"BpfManager::terminate_pod (stub — shell_map cleanup not yet implemented)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
69
bascule-node-agent/src/config.rs
Normal file
69
bascule-node-agent/src/config.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/// Node agent configuration, loaded from environment variables.
|
||||
///
|
||||
/// Required: `NODE_NAME` (from Kubernetes Downward API).
|
||||
/// All other values have sensible defaults for development.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BasculeNodeConfig {
|
||||
/// This node's name (from Downward API: `spec.nodeName`).
|
||||
pub node_name: String,
|
||||
|
||||
/// Dashboard webhook URL.
|
||||
pub dashboard_webhook_url: String,
|
||||
|
||||
/// Dashboard webhook shared secret (Bearer token).
|
||||
pub dashboard_webhook_secret: String,
|
||||
|
||||
/// Unix socket path for CNI admission notifications.
|
||||
pub admission_socket: String,
|
||||
|
||||
/// Namespace label used for tenant resolution.
|
||||
pub tenant_label: String,
|
||||
|
||||
/// Max events per webhook POST batch.
|
||||
pub webhook_batch_size: usize,
|
||||
|
||||
/// Seconds between webhook flushes.
|
||||
pub webhook_flush_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl BasculeNodeConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let node_name = std::env::var("NODE_NAME")
|
||||
.map_err(|_| anyhow::anyhow!("NODE_NAME environment variable is required (set via Kubernetes Downward API)"))?;
|
||||
|
||||
Ok(Self {
|
||||
node_name,
|
||||
dashboard_webhook_url: std::env::var("BASCULE_DASHBOARD_WEBHOOK_URL")
|
||||
.unwrap_or_else(|_| "http://guildhouse-dashboard:8000/api/v1/governance/bascule/events/".into()),
|
||||
dashboard_webhook_secret: std::env::var("BASCULE_DASHBOARD_WEBHOOK_SECRET")
|
||||
.unwrap_or_default(),
|
||||
admission_socket: std::env::var("BASCULE_ADMISSION_SOCKET")
|
||||
.unwrap_or_else(|_| "/run/bascule/admission.sock".into()),
|
||||
tenant_label: std::env::var("BASCULE_TENANT_LABEL")
|
||||
.unwrap_or_else(|_| "guildhouse.dev/tenant".into()),
|
||||
webhook_batch_size: std::env::var("BASCULE_WEBHOOK_BATCH_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
webhook_flush_interval_secs: std::env::var("BASCULE_WEBHOOK_FLUSH_INTERVAL_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.dashboard_webhook_secret.is_empty() {
|
||||
anyhow::bail!(
|
||||
"BASCULE_DASHBOARD_WEBHOOK_SECRET must not be empty"
|
||||
);
|
||||
}
|
||||
if self.dashboard_webhook_secret == "dev-secret-change-in-production" {
|
||||
tracing::warn!(
|
||||
"Using default dev webhook secret. \
|
||||
Set BASCULE_DASHBOARD_WEBHOOK_SECRET in production."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
99
bascule-node-agent/src/events.rs
Normal file
99
bascule-node-agent/src/events.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/// Node-level events emitted by the pod watcher and admission listener.
|
||||
///
|
||||
/// Each variant maps to an AuditBridge wire-format event that the dashboard
|
||||
/// webhook expects.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum NodeEvent {
|
||||
PodAdmitted {
|
||||
pod_uid: String,
|
||||
pod_name: String,
|
||||
namespace: String,
|
||||
tenant: Option<String>,
|
||||
node: String,
|
||||
cgroup_id: Option<u64>,
|
||||
},
|
||||
PodTerminated {
|
||||
pod_uid: String,
|
||||
namespace: String,
|
||||
tenant: Option<String>,
|
||||
node: String,
|
||||
},
|
||||
ShellBound {
|
||||
session_id: String,
|
||||
did: String,
|
||||
cgroup_id: u64,
|
||||
namespace: String,
|
||||
tenant: Option<String>,
|
||||
node: String,
|
||||
pod_uid: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl NodeEvent {
|
||||
/// The AuditBridge event_kind string for the dashboard webhook.
|
||||
pub fn event_kind(&self) -> &str {
|
||||
match self {
|
||||
NodeEvent::PodAdmitted { .. } => "PodAdmitted",
|
||||
NodeEvent::PodTerminated { .. } => "SessionTerminated",
|
||||
NodeEvent::ShellBound { .. } => "SessionEstablished",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to the pipe-delimited AuditBridge payload format
|
||||
/// that the dashboard webhook parser expects.
|
||||
pub fn to_payload(&self) -> String {
|
||||
match self {
|
||||
NodeEvent::PodAdmitted {
|
||||
pod_uid,
|
||||
pod_name,
|
||||
namespace,
|
||||
tenant,
|
||||
node,
|
||||
cgroup_id,
|
||||
} => {
|
||||
let mut s = format!(
|
||||
"SessionEstablished|session={pod_uid} did=pod:{namespace}/{pod_name} node={node}"
|
||||
);
|
||||
if let Some(t) = tenant {
|
||||
s.push_str(&format!(" tenant={t}"));
|
||||
}
|
||||
if let Some(cg) = cgroup_id {
|
||||
s.push_str(&format!(" cgroup_id={cg}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
NodeEvent::PodTerminated {
|
||||
pod_uid,
|
||||
namespace,
|
||||
tenant,
|
||||
node,
|
||||
} => {
|
||||
let mut s = format!(
|
||||
"SessionTerminated|session={pod_uid} reason=pod_deleted namespace={namespace} node={node}"
|
||||
);
|
||||
if let Some(t) = tenant {
|
||||
s.push_str(&format!(" tenant={t}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
NodeEvent::ShellBound {
|
||||
session_id,
|
||||
did,
|
||||
cgroup_id,
|
||||
namespace,
|
||||
tenant,
|
||||
node,
|
||||
pod_uid,
|
||||
} => {
|
||||
let mut s = format!(
|
||||
"SessionEstablished|session={session_id} did={did} cgroup_id={cgroup_id} namespace={namespace} node={node} pod_uid={pod_uid}"
|
||||
);
|
||||
if let Some(t) = tenant {
|
||||
s.push_str(&format!(" tenant={t}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
bascule-node-agent/src/main.rs
Normal file
99
bascule-node-agent/src/main.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Bascule node agent — per-node DaemonSet for shell admission and webhook emission.
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! - Watch pods on this node (via Kubernetes API)
|
||||
//! - Accept CNI ADD notifications (via Unix socket)
|
||||
//! - Emit admission events to the dashboard webhook
|
||||
//! - Program shell BPF maps (stub — deferred to substrate-cni)
|
||||
//!
|
||||
//! This is NOT bascule-gateway. No gRPC server, no ceremony engine,
|
||||
//! no PostgreSQL, no session manager.
|
||||
|
||||
mod admission;
|
||||
mod bpf_manager;
|
||||
mod config;
|
||||
mod events;
|
||||
mod pod_watcher;
|
||||
mod tenant_resolver;
|
||||
mod webhook_emitter;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::admission::AdmissionListener;
|
||||
use crate::config::BasculeNodeConfig;
|
||||
use crate::pod_watcher::PodWatcher;
|
||||
use crate::tenant_resolver::TenantResolver;
|
||||
use crate::webhook_emitter::WebhookEmitter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// rustls provider must be installed before kube::Client.
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.ok();
|
||||
|
||||
// JSON tracing output for Kubernetes log aggregation.
|
||||
tracing_subscriber::fmt()
|
||||
.json()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cfg = BasculeNodeConfig::from_env()?;
|
||||
cfg.validate()?;
|
||||
tracing::info!(node = %cfg.node_name, "bascule-node-agent starting");
|
||||
|
||||
// Kubernetes client (in-cluster or kubeconfig fallback).
|
||||
let kube_client = kube::Client::try_default().await?;
|
||||
tracing::info!("Kubernetes client connected");
|
||||
|
||||
// Event channel: pod_watcher + admission_listener → webhook_emitter
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<events::NodeEvent>(256);
|
||||
|
||||
// Shared tenant resolver
|
||||
let tenant_resolver = Arc::new(TenantResolver::new(
|
||||
kube_client.clone(),
|
||||
cfg.tenant_label.clone(),
|
||||
));
|
||||
|
||||
// Pod watcher (Kubernetes API)
|
||||
let pod_watcher = PodWatcher::new(
|
||||
kube_client.clone(),
|
||||
cfg.node_name.clone(),
|
||||
tx.clone(),
|
||||
tenant_resolver,
|
||||
);
|
||||
|
||||
// CNI admission listener (Unix socket, stub until substrate-cni exists)
|
||||
let admission_listener = AdmissionListener::new(
|
||||
cfg.admission_socket.clone(),
|
||||
tx,
|
||||
cfg.node_name.clone(),
|
||||
);
|
||||
|
||||
// Webhook emitter (HTTP POST to dashboard)
|
||||
let webhook_emitter = WebhookEmitter::new(&cfg, rx);
|
||||
|
||||
tracing::info!(
|
||||
webhook_url = %cfg.dashboard_webhook_url,
|
||||
admission_socket = %cfg.admission_socket,
|
||||
"All components initialized, starting event loop"
|
||||
);
|
||||
|
||||
// Run all tasks. If any exits, the agent restarts (crash-loop).
|
||||
tokio::select! {
|
||||
res = pod_watcher.run() => {
|
||||
tracing::error!(?res, "Pod watcher exited");
|
||||
}
|
||||
res = admission_listener.run() => {
|
||||
tracing::error!(?res, "Admission listener exited");
|
||||
}
|
||||
res = webhook_emitter.run() => {
|
||||
tracing::error!(?res, "Webhook emitter exited");
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("A core task exited — agent should be restarted by Kubernetes")
|
||||
}
|
||||
128
bascule-node-agent/src/pod_watcher.rs
Normal file
128
bascule-node-agent/src/pod_watcher.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use kube::runtime::watcher;
|
||||
use kube::runtime::WatchStreamExt;
|
||||
use kube::Api;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::events::NodeEvent;
|
||||
use crate::tenant_resolver::TenantResolver;
|
||||
|
||||
/// Watches pods on this node and emits admission/termination events.
|
||||
///
|
||||
/// Uses a Kubernetes field selector (`spec.nodeName`) to limit the watch
|
||||
/// to pods scheduled on the current node.
|
||||
pub struct PodWatcher {
|
||||
client: kube::Client,
|
||||
node_name: String,
|
||||
event_tx: mpsc::Sender<NodeEvent>,
|
||||
tenant_resolver: Arc<TenantResolver>,
|
||||
}
|
||||
|
||||
impl PodWatcher {
|
||||
pub fn new(
|
||||
client: kube::Client,
|
||||
node_name: String,
|
||||
event_tx: mpsc::Sender<NodeEvent>,
|
||||
tenant_resolver: Arc<TenantResolver>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
node_name,
|
||||
event_tx,
|
||||
tenant_resolver,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the pod watcher loop. Reconnects on error (never returns Ok).
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
use futures::StreamExt;
|
||||
|
||||
let pods: Api<k8s_openapi::api::core::v1::Pod> = Api::all(self.client.clone());
|
||||
|
||||
let watcher_config = watcher::Config::default()
|
||||
.fields(&format!("spec.nodeName={}", self.node_name));
|
||||
|
||||
tracing::info!(node = %self.node_name, "Starting pod watcher");
|
||||
|
||||
// watcher automatically reconnects on 410 Gone.
|
||||
let mut stream =
|
||||
std::pin::pin!(watcher::watcher(pods, watcher_config).applied_objects());
|
||||
|
||||
// We track known UIDs to distinguish ADDED vs MODIFIED
|
||||
let mut known_uids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(pod)) => {
|
||||
self.handle_pod_event(&pod, &mut known_uids).await;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::error!(error = %e, "Pod watcher error, will reconnect");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Pod watcher stream ended, restarting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return error to trigger restart in main select!
|
||||
anyhow::bail!("Pod watcher stream ended")
|
||||
}
|
||||
|
||||
async fn handle_pod_event(
|
||||
&self,
|
||||
pod: &k8s_openapi::api::core::v1::Pod,
|
||||
known_uids: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
let meta = &pod.metadata;
|
||||
let uid = match meta.uid.as_deref() {
|
||||
Some(u) => u.to_string(),
|
||||
None => return,
|
||||
};
|
||||
let name = meta.name.clone().unwrap_or_default();
|
||||
let namespace = meta.namespace.clone().unwrap_or_else(|| "default".into());
|
||||
|
||||
// Check if pod is being deleted
|
||||
if meta.deletion_timestamp.is_some() {
|
||||
if known_uids.remove(&uid) {
|
||||
let tenant = self.tenant_resolver.resolve(&namespace).await;
|
||||
let event = NodeEvent::PodTerminated {
|
||||
pod_uid: uid,
|
||||
namespace,
|
||||
tenant,
|
||||
node: self.node_name.clone(),
|
||||
};
|
||||
if let Err(e) = self.event_tx.send(event).await {
|
||||
tracing::error!(error = %e, "Failed to send PodTerminated event");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// New pod — only emit PodAdmitted when phase is Running
|
||||
let phase = pod
|
||||
.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.phase.as_deref())
|
||||
.unwrap_or("Unknown");
|
||||
|
||||
if known_uids.insert(uid.clone()) && phase == "Running" {
|
||||
let tenant = self.tenant_resolver.resolve(&namespace).await;
|
||||
let event = NodeEvent::PodAdmitted {
|
||||
pod_uid: uid,
|
||||
pod_name: name,
|
||||
namespace,
|
||||
tenant,
|
||||
node: self.node_name.clone(),
|
||||
cgroup_id: None, // Populated later by CNI admission
|
||||
};
|
||||
if let Err(e) = self.event_tx.send(event).await {
|
||||
tracing::error!(error = %e, "Failed to send PodAdmitted event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
bascule-node-agent/src/tenant_resolver.rs
Normal file
68
bascule-node-agent/src/tenant_resolver.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Resolves Kubernetes namespace → tenant slug via namespace labels.
|
||||
///
|
||||
/// Reads the label specified by `tenant_label` (default: `guildhouse.dev/tenant`)
|
||||
/// from the Namespace object. Results are cached to avoid repeated API calls.
|
||||
pub struct TenantResolver {
|
||||
client: kube::Client,
|
||||
tenant_label: String,
|
||||
cache: Arc<RwLock<HashMap<String, Option<String>>>>,
|
||||
}
|
||||
|
||||
impl TenantResolver {
|
||||
pub fn new(client: kube::Client, tenant_label: String) -> Self {
|
||||
Self {
|
||||
client,
|
||||
tenant_label,
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the tenant slug for a namespace.
|
||||
///
|
||||
/// Returns `Some(slug)` if the namespace has the tenant label,
|
||||
/// `None` otherwise. Results (including `None`) are cached.
|
||||
pub async fn resolve(&self, namespace: &str) -> Option<String> {
|
||||
// 1. Check cache
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(cached) = cache.get(namespace) {
|
||||
return cached.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch namespace from K8s API
|
||||
let result = self.fetch_tenant_label(namespace).await;
|
||||
|
||||
// 3. Cache (even None, to avoid repeated lookups for unlabelled namespaces)
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.insert(namespace.to_string(), result.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn fetch_tenant_label(&self, namespace: &str) -> Option<String> {
|
||||
use kube::Api;
|
||||
|
||||
let ns_api: Api<k8s_openapi::api::core::v1::Namespace> =
|
||||
Api::all(self.client.clone());
|
||||
|
||||
match ns_api.get(namespace).await {
|
||||
Ok(ns) => ns
|
||||
.metadata
|
||||
.labels
|
||||
.as_ref()
|
||||
.and_then(|labels| labels.get(&self.tenant_label).cloned()),
|
||||
Err(e) => {
|
||||
tracing::warn!(namespace, error = %e, "Failed to fetch namespace for tenant resolution");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
bascule-node-agent/src/webhook_emitter.rs
Normal file
116
bascule-node-agent/src/webhook_emitter.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::config::BasculeNodeConfig;
|
||||
use crate::events::NodeEvent;
|
||||
|
||||
/// Batches node events and POSTs them to the dashboard webhook.
|
||||
///
|
||||
/// Collects events until `batch_size` or `flush_interval`, whichever comes
|
||||
/// first. Never crashes on HTTP errors — logs and continues.
|
||||
pub struct WebhookEmitter {
|
||||
client: reqwest::Client,
|
||||
webhook_url: String,
|
||||
webhook_secret: String,
|
||||
batch_size: usize,
|
||||
flush_interval: Duration,
|
||||
rx: mpsc::Receiver<NodeEvent>,
|
||||
}
|
||||
|
||||
impl WebhookEmitter {
|
||||
pub fn new(config: &BasculeNodeConfig, rx: mpsc::Receiver<NodeEvent>) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
webhook_url: config.dashboard_webhook_url.clone(),
|
||||
webhook_secret: config.dashboard_webhook_secret.clone(),
|
||||
batch_size: config.webhook_batch_size,
|
||||
flush_interval: Duration::from_secs(config.webhook_flush_interval_secs),
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Main event loop. Runs until the channel is closed.
|
||||
pub async fn run(mut self) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
url = %self.webhook_url,
|
||||
batch_size = self.batch_size,
|
||||
flush_interval_secs = self.flush_interval.as_secs(),
|
||||
"Webhook emitter started"
|
||||
);
|
||||
|
||||
let mut batch: Vec<NodeEvent> = Vec::with_capacity(self.batch_size);
|
||||
let mut flush_timer = tokio::time::interval(self.flush_interval);
|
||||
// First tick completes immediately — skip it.
|
||||
flush_timer.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = self.rx.recv() => {
|
||||
match event {
|
||||
Some(evt) => {
|
||||
batch.push(evt);
|
||||
if batch.len() >= self.batch_size {
|
||||
self.post_batch(&mut batch).await;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Channel closed — flush remaining and exit.
|
||||
if !batch.is_empty() {
|
||||
self.post_batch(&mut batch).await;
|
||||
}
|
||||
tracing::info!("Event channel closed, webhook emitter shutting down");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = flush_timer.tick() => {
|
||||
if !batch.is_empty() {
|
||||
self.post_batch(&mut batch).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_batch(&self, batch: &mut Vec<NodeEvent>) {
|
||||
let events: Vec<serde_json::Value> = batch
|
||||
.drain(..)
|
||||
.map(|evt| {
|
||||
serde_json::json!({
|
||||
"event_kind": evt.event_kind(),
|
||||
"payload": evt.to_payload(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = events.len();
|
||||
let body = serde_json::json!({ "events": events });
|
||||
|
||||
match self
|
||||
.client
|
||||
.post(&self.webhook_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", self.webhook_secret),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() || status.as_u16() == 202 {
|
||||
tracing::debug!(count, status = %status, "Webhook batch posted");
|
||||
} else {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(count, status = %status, body = %text, "Webhook batch rejected");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(count, error = %e, "Webhook POST failed (will retry on next flush)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
bascule-proto/Cargo.toml
Normal file
13
bascule-proto/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "bascule-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Generated gRPC stubs for Bascule protocol services"
|
||||
|
||||
[dependencies]
|
||||
tonic = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
prost-types = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.12"
|
||||
21
bascule-proto/build.rs
Normal file
21
bascule-proto/build.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Proto files live at workspace root: ../proto/
|
||||
let proto_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("proto");
|
||||
|
||||
tonic_build::configure()
|
||||
.build_server(true)
|
||||
.build_client(true)
|
||||
.compile_protos(
|
||||
&[
|
||||
proto_root.join("bascule/v1/gateway.proto"),
|
||||
proto_root.join("bascule/v1/session.proto"),
|
||||
proto_root.join("bascule/v1/command.proto"),
|
||||
proto_root.join("bascule/v1/ceremony.proto"),
|
||||
],
|
||||
&[&proto_root],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
8
bascule-proto/src/lib.rs
Normal file
8
bascule-proto/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub mod bascule {
|
||||
pub mod v1 {
|
||||
tonic::include_proto!("bascule.v1");
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-export for convenient access.
|
||||
pub use bascule::v1 as bascule_v1;
|
||||
62
bascule-shell/Cargo.toml
Normal file
62
bascule-shell/Cargo.toml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[package]
|
||||
name = "bascule-shell"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Bascule governance shell — CLI for governed cluster access"
|
||||
|
||||
[lib]
|
||||
name = "bascule_shell"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "bascule"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bascule-core = { workspace = true }
|
||||
bascule-proto = { workspace = true }
|
||||
|
||||
# Cross-workspace path deps — Guildhouse services.
|
||||
# Future: extract to standalone crates.
|
||||
registry-protocol = { path = "../../guildhouse/services/registry-protocol" }
|
||||
# workspace::v1 proto for attach command (workspace-controller gRPC)
|
||||
guildhouse-proto = { path = "../../guildhouse/services/guildhouse-proto" }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
# Command module discovery
|
||||
which = { workspace = true }
|
||||
|
||||
# gRPC
|
||||
tonic = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# HTTP (for OIDC token exchange)
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
|
||||
# Observability
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# SSH
|
||||
russh = { workspace = true }
|
||||
russh-keys = { workspace = true }
|
||||
ssh-key = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
# Common
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
198
bascule-shell/src/attach.rs
Normal file
198
bascule-shell/src/attach.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! `bascule attach workspace/{name}` — attach to a governed filesystem workspace.
|
||||
//!
|
||||
//! Resolves the workspace DID from the DID registry, calls workspace-controller
|
||||
//! to create a SessionWorkspace (git worktree), then exec's a shell with the
|
||||
//! worktree path as cwd.
|
||||
|
||||
use guildhouse_proto::workspace::v1::{
|
||||
workspace_controller_client::WorkspaceControllerClient, CreateWorkspaceRequest,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Configuration for the attach command.
|
||||
pub struct AttachContext {
|
||||
pub workspace_name: String,
|
||||
pub domain: String,
|
||||
pub registry_url: String,
|
||||
pub workspace_controller_addr: String,
|
||||
pub caller_name: String,
|
||||
pub caller_email: String,
|
||||
}
|
||||
|
||||
impl AttachContext {
|
||||
/// Build from environment variables with sensible defaults.
|
||||
pub fn from_env(workspace_name: String) -> Self {
|
||||
Self {
|
||||
workspace_name,
|
||||
domain: std::env::var("BASCULE_DID_DOMAIN")
|
||||
.unwrap_or_else(|_| "guildhouse.local".into()),
|
||||
registry_url: std::env::var("DID_REGISTRY_URL")
|
||||
.unwrap_or_else(|_| "http://did-registry.guildhouse-system.svc:3000".into()),
|
||||
workspace_controller_addr: std::env::var("WORKSPACE_CONTROLLER_ADDR")
|
||||
.unwrap_or_else(|_| "http://localhost:50057".into()),
|
||||
caller_name: std::env::var("BASCULE_AUTHOR_NAME").unwrap_or_else(|_| {
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "bascule-user".into())
|
||||
}),
|
||||
caller_email: std::env::var("BASCULE_AUTHOR_EMAIL").unwrap_or_else(|_| {
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "bascule@guildhouse.local".into())
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_attach(ctx: AttachContext) -> anyhow::Result<()> {
|
||||
// Step 1: Resolve workspace DID from did-registry
|
||||
let did_uri = format!("did:web:{}:workspace:{}", ctx.domain, ctx.workspace_name);
|
||||
println!("Resolving workspace DID: {did_uri}");
|
||||
|
||||
let did_url = format!(
|
||||
"{}/did/web/{}/workspace/{}",
|
||||
ctx.registry_url, ctx.domain, ctx.workspace_name,
|
||||
);
|
||||
|
||||
let resp = reqwest::get(&did_url).await.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"DID registry unreachable at {}: {}. Is did-registry running?",
|
||||
ctx.registry_url,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
anyhow::bail!(
|
||||
"Workspace '{}' not found in DID registry. Has it been registered?",
|
||||
ctx.workspace_name
|
||||
);
|
||||
}
|
||||
|
||||
let doc: serde_json::Value = resp.json().await.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to parse DID document: {e}")
|
||||
})?;
|
||||
|
||||
// Step 2: Determine read_only from verification methods.
|
||||
// Phase A: any DID document with verification methods = read-write.
|
||||
// Missing/empty verification methods = read-only (safe default).
|
||||
let read_only = determine_read_only(&doc);
|
||||
|
||||
// Step 3: Log file targets (enforcement pending Q5 kernel work).
|
||||
log_file_targets(&doc);
|
||||
|
||||
// Step 4: Call workspace-controller gRPC to create SessionWorkspace
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let channel = tonic::transport::Channel::from_shared(ctx.workspace_controller_addr.clone())?
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Could not connect to workspace-controller at {}: {}. Is it running?",
|
||||
ctx.workspace_controller_addr,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut client = WorkspaceControllerClient::new(channel);
|
||||
|
||||
let resp = client
|
||||
.create_workspace(tonic::Request::new(CreateWorkspaceRequest {
|
||||
session_id: session_id.clone(),
|
||||
base_branch: "main".to_string(),
|
||||
author_name: ctx.caller_name.clone(),
|
||||
author_email: ctx.caller_email.clone(),
|
||||
read_only,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Could not create workspace session: {e}. Is workspace-controller running?"
|
||||
)
|
||||
})?
|
||||
.into_inner();
|
||||
|
||||
let workspace_path = resp.workspace_path;
|
||||
let branch_name = resp.branch_name;
|
||||
let base_commit = resp.base_commit;
|
||||
|
||||
// Step 5: Print summary
|
||||
println!();
|
||||
println!("Workspace attached:");
|
||||
println!(" Path: {workspace_path}");
|
||||
println!(
|
||||
" Branch: {}",
|
||||
if branch_name.is_empty() {
|
||||
"(read-only)".to_string()
|
||||
} else {
|
||||
branch_name
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" Mode: {}",
|
||||
if read_only { "read-only" } else { "read-write" }
|
||||
);
|
||||
println!(
|
||||
" Base: {}",
|
||||
&base_commit[..8.min(base_commit.len())]
|
||||
);
|
||||
println!();
|
||||
|
||||
// Step 6: Exec $SHELL in worktree (replaces current process)
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into());
|
||||
|
||||
let err = std::process::Command::new(&shell)
|
||||
.current_dir(&workspace_path)
|
||||
.env("BASCULE_WORKSPACE", &workspace_path)
|
||||
.env("BASCULE_SESSION_ID", &session_id)
|
||||
.env("PS1", format!("bascule:{}$ ", ctx.workspace_name))
|
||||
.exec();
|
||||
|
||||
// exec() only returns on error
|
||||
anyhow::bail!("Failed to exec {}: {}", shell, err)
|
||||
}
|
||||
|
||||
/// Phase A: any DID document with at least one verification method = read-write.
|
||||
/// No verification methods or missing field = read-only (safe default).
|
||||
fn determine_read_only(doc: &serde_json::Value) -> bool {
|
||||
doc.get("verificationMethod")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|vms| vms.is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Log file: targets from verification methods at debug level.
|
||||
/// Enforcement is not yet implemented (SPEC-DID-0001 §12 Q5).
|
||||
fn log_file_targets(doc: &serde_json::Value) {
|
||||
let vms = doc.get("verificationMethod").and_then(|v| v.as_array());
|
||||
|
||||
if let Some(vms) = vms {
|
||||
for vm in vms {
|
||||
if let Some(targets) = vm.get("substrate:targets").and_then(|t| t.as_array()) {
|
||||
let file_targets: Vec<&str> = targets
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.filter(|t| t.starts_with("file:"))
|
||||
.collect();
|
||||
|
||||
if !file_targets.is_empty() {
|
||||
tracing::debug!(
|
||||
targets = ?file_targets,
|
||||
"Workspace file targets (enforcement pending Q5)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
155
bascule-shell/src/auth.rs
Normal file
155
bascule-shell/src/auth.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
use clap::Args;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::TokenStore;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ConnectArgs {
|
||||
/// OIDC issuer URL (e.g., http://localhost:8080/realms/demo)
|
||||
#[arg(long)]
|
||||
pub issuer: String,
|
||||
|
||||
/// OIDC client ID
|
||||
#[arg(long, default_value = "bascule")]
|
||||
pub client_id: String,
|
||||
|
||||
/// Skip OIDC flow and use this token directly (development only)
|
||||
#[arg(long)]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn connect(args: ConnectArgs, gateway: &str) -> anyhow::Result<()> {
|
||||
if let Some(token) = args.token {
|
||||
let store = TokenStore {
|
||||
gateway_endpoint: gateway.to_string(),
|
||||
id_token: token.clone(),
|
||||
access_token: token,
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
session_id: None,
|
||||
};
|
||||
store.save()?;
|
||||
println!("Token saved. Connected to {gateway}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// OIDC Authorization Code flow
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let redirect_uri = format!("http://localhost:{port}/callback");
|
||||
|
||||
let auth_url = format!(
|
||||
"{}/protocol/openid-connect/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid+email+profile",
|
||||
args.issuer.trim_end_matches('/'),
|
||||
args.client_id,
|
||||
encode_uri_component(&redirect_uri),
|
||||
);
|
||||
|
||||
println!("Open this URL in your browser to authenticate:\n");
|
||||
println!(" {auth_url}\n");
|
||||
println!("Waiting for callback on port {port}...");
|
||||
|
||||
// Accept the OAuth callback
|
||||
let (mut stream, _) = listener.accept()?;
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
|
||||
// Extract the authorization code from GET /callback?code=...&...
|
||||
let code = extract_code_from_request(&request)
|
||||
.ok_or_else(|| anyhow::anyhow!("no authorization code in callback"))?;
|
||||
|
||||
// Send a success page back to the browser
|
||||
let html = "<html><body><h1>Authentication successful!</h1><p>You can close this tab.</p></body></html>";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
|
||||
html.len(),
|
||||
html
|
||||
);
|
||||
stream.write_all(response.as_bytes())?;
|
||||
drop(stream);
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
let token_url = format!(
|
||||
"{}/protocol/openid-connect/token",
|
||||
args.issuer.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", args.client_id.as_str()),
|
||||
("code", code.as_str()),
|
||||
("redirect_uri", redirect_uri.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await?;
|
||||
anyhow::bail!("Token exchange failed: {body}");
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = resp.json().await?;
|
||||
|
||||
let store = TokenStore {
|
||||
gateway_endpoint: gateway.to_string(),
|
||||
id_token: token_resp.id_token.unwrap_or_default(),
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: token_resp
|
||||
.expires_in
|
||||
.map(|secs| chrono::Utc::now() + chrono::Duration::seconds(secs)),
|
||||
session_id: None,
|
||||
};
|
||||
store.save()?;
|
||||
|
||||
println!("Authenticated successfully. Connected to {gateway}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
id_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
/// Extract the `code` query parameter from an HTTP request line.
|
||||
fn extract_code_from_request(request: &str) -> Option<String> {
|
||||
let first_line = request.lines().next()?;
|
||||
let path = first_line.split_whitespace().nth(1)?;
|
||||
let query_start = path.find('?')? + 1;
|
||||
let query = &path[query_start..];
|
||||
|
||||
for param in query.split('&') {
|
||||
if let Some(value) = param.strip_prefix("code=") {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Minimal percent-encoding for URI components.
|
||||
fn encode_uri_component(s: &str) -> String {
|
||||
let mut encoded = String::with_capacity(s.len() * 2);
|
||||
for byte in s.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z'
|
||||
| b'a'..=b'z'
|
||||
| b'0'..=b'9'
|
||||
| b'-'
|
||||
| b'_'
|
||||
| b'.'
|
||||
| b'~' => encoded.push(byte as char),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
96
bascule-shell/src/commands.rs
Normal file
96
bascule-shell/src/commands.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use clap::Args;
|
||||
|
||||
use crate::config::TokenStore;
|
||||
use crate::output;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ExecArgs {
|
||||
/// Command verb (get, describe, logs, status, etc.)
|
||||
pub verb: String,
|
||||
|
||||
/// Resource type (pods, deployments, services, etc.)
|
||||
pub resource_type: Option<String>,
|
||||
|
||||
/// Resource name
|
||||
pub resource_name: Option<String>,
|
||||
|
||||
/// Namespace
|
||||
#[arg(short, long)]
|
||||
pub namespace: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn exec(args: ExecArgs, gateway: &str, output_format: &str) -> anyhow::Result<()> {
|
||||
let store = TokenStore::load()?;
|
||||
let session_id = store
|
||||
.session_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No active session. Run `bascule session request` first."))?;
|
||||
|
||||
let mut client = crate::session::connect_gateway(gateway).await?;
|
||||
|
||||
let request = crate::session::authenticated_request(
|
||||
bascule_proto::bascule_v1::ExecuteCommandRequest {
|
||||
session_id: session_id.clone(),
|
||||
verb: args.verb,
|
||||
namespace: args.namespace,
|
||||
resource_type: args.resource_type,
|
||||
resource_name: args.resource_name,
|
||||
parameters: None,
|
||||
output_format: output_format.to_string(),
|
||||
},
|
||||
&store.access_token,
|
||||
);
|
||||
|
||||
let response = client.execute_command(request).await?;
|
||||
output::print_command_response(&response.into_inner(), output_format);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a REPL input line into command components.
|
||||
pub struct ParsedCommand {
|
||||
pub verb: String,
|
||||
pub namespace: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_name: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_line(input: &str) -> Option<ParsedCommand> {
|
||||
let parts: Vec<&str> = input.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let verb = parts[0].to_string();
|
||||
let mut namespace = None;
|
||||
let mut resource_type = None;
|
||||
let mut resource_name = None;
|
||||
let mut i = 1;
|
||||
|
||||
while i < parts.len() {
|
||||
match parts[i] {
|
||||
"-n" | "--namespace" => {
|
||||
if i + 1 < parts.len() {
|
||||
namespace = Some(parts[i + 1].to_string());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
arg => {
|
||||
if resource_type.is_none() {
|
||||
resource_type = Some(arg.to_string());
|
||||
} else if resource_name.is_none() {
|
||||
resource_name = Some(arg.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some(ParsedCommand {
|
||||
verb,
|
||||
namespace,
|
||||
resource_type,
|
||||
resource_name,
|
||||
})
|
||||
}
|
||||
50
bascule-shell/src/config.rs
Normal file
50
bascule-shell/src/config.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stored OIDC tokens and gateway connection info.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TokenStore {
|
||||
pub gateway_endpoint: String,
|
||||
pub id_token: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// Active session ID (set after RequestSession succeeds).
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenStore {
|
||||
pub fn config_dir() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("bascule")
|
||||
}
|
||||
|
||||
pub fn token_path() -> PathBuf {
|
||||
Self::config_dir().join("tokens.json")
|
||||
}
|
||||
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let dir = Self::config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = Self::token_path();
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(&path, &json)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let path = Self::token_path();
|
||||
let json = std::fs::read_to_string(&path)
|
||||
.map_err(|_| anyhow::anyhow!("Not connected. Run `bascule connect` first."))?;
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
}
|
||||
234
bascule-shell/src/governed/command_trait.rs
Normal file
234
bascule-shell/src/governed/command_trait.rs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
//! Shell command trait and supporting types.
|
||||
//!
|
||||
//! Every shell command implements [`ShellCommand`]. Commands are loaded from the
|
||||
//! container image at startup and dispatched by the command registry.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::session::GovernedSession;
|
||||
|
||||
/// Every shell command implements this trait.
|
||||
#[async_trait]
|
||||
pub trait ShellCommand: Send + Sync {
|
||||
/// Primary command name (e.g., "query", "void", "deploy").
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Alternative names (e.g., ["q"] for "query").
|
||||
fn aliases(&self) -> Vec<&str> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// What access tier this command belongs to.
|
||||
fn tier(&self) -> CommandTier;
|
||||
|
||||
/// What SAT scope is required to execute this command.
|
||||
fn required_scope(&self) -> RequiredScope;
|
||||
|
||||
/// Human-readable description for help output.
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// Usage string (e.g., "query <registry> <artifact_id>").
|
||||
fn usage(&self) -> &str;
|
||||
|
||||
/// Execute the command with the given arguments and session context.
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError>;
|
||||
|
||||
/// Tab completion hints for arguments.
|
||||
fn completions(&self, _args: &[String], _session: &GovernedSession) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Which image tier this command belongs to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum CommandTier {
|
||||
/// Read-only operations — always available if the command is in the image.
|
||||
Analyst,
|
||||
/// Application-level mutations — require elevation.
|
||||
Administrator,
|
||||
/// Infrastructure-level operations — require elevation + stronger ceremony.
|
||||
Engineer,
|
||||
}
|
||||
|
||||
impl fmt::Display for CommandTier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CommandTier::Analyst => write!(f, "analyst"),
|
||||
CommandTier::Administrator => write!(f, "administrator"),
|
||||
CommandTier::Engineer => write!(f, "engineer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandTier {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"analyst" => Some(CommandTier::Analyst),
|
||||
"administrator" => Some(CommandTier::Administrator),
|
||||
"engineer" => Some(CommandTier::Engineer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What SAT scope is required to execute a command.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RequiredScope {
|
||||
/// Any valid (non-expired) session SAT.
|
||||
ReadOnly,
|
||||
/// Requires elevated SAT with specific registry+verb scope.
|
||||
Elevated { registry: String, verb: String },
|
||||
}
|
||||
|
||||
/// Result of checking scope authorization.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScopeCheck {
|
||||
Authorized,
|
||||
/// Base SAT expired (session should end).
|
||||
Expired,
|
||||
/// Elevation required for this operation.
|
||||
ElevationRequired { registry: String, verb: String },
|
||||
/// Was elevated but TTL ran out.
|
||||
ElevationExpired,
|
||||
}
|
||||
|
||||
/// Output from command execution.
|
||||
#[derive(Debug)]
|
||||
pub struct CommandOutput {
|
||||
pub lines: Vec<OutputLine>,
|
||||
}
|
||||
|
||||
impl CommandOutput {
|
||||
pub fn text(s: impl Into<String>) -> Self {
|
||||
Self {
|
||||
lines: vec![OutputLine::Text(s.into())],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(label: impl Into<String>, value: impl Into<String>, color: OutputColor) -> Self {
|
||||
Self {
|
||||
lines: vec![OutputLine::Status {
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
color,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self { lines: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
/// A single line of structured output.
|
||||
#[derive(Debug)]
|
||||
pub enum OutputLine {
|
||||
Text(String),
|
||||
Table {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
},
|
||||
Status {
|
||||
label: String,
|
||||
value: String,
|
||||
color: OutputColor,
|
||||
},
|
||||
Separator,
|
||||
Blank,
|
||||
}
|
||||
|
||||
/// ANSI color hint for terminal output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputColor {
|
||||
Default,
|
||||
Green,
|
||||
Yellow,
|
||||
Red,
|
||||
Cyan,
|
||||
}
|
||||
|
||||
/// Errors from command execution.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CommandError {
|
||||
#[error("Unknown command: {0}")]
|
||||
Unknown(String),
|
||||
#[error("Insufficient scope: {required} required, current scope is read-only")]
|
||||
InsufficientScope { required: String },
|
||||
#[error("Elevation required: {registry}:{verb}")]
|
||||
ElevationRequired { registry: String, verb: String },
|
||||
#[error("No tenant selected. Use: use <tenant_id>")]
|
||||
NoTenantContext,
|
||||
#[error("Invalid arguments: {0}")]
|
||||
InvalidArgs(String),
|
||||
#[error("Service unavailable: {0}")]
|
||||
ServiceUnavailable(String),
|
||||
#[error("Governance error: {0}")]
|
||||
GovernanceError(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_tier_display() {
|
||||
assert_eq!(CommandTier::Analyst.to_string(), "analyst");
|
||||
assert_eq!(CommandTier::Administrator.to_string(), "administrator");
|
||||
assert_eq!(CommandTier::Engineer.to_string(), "engineer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_tier_from_str() {
|
||||
assert_eq!(CommandTier::from_str("analyst"), Some(CommandTier::Analyst));
|
||||
assert_eq!(
|
||||
CommandTier::from_str("administrator"),
|
||||
Some(CommandTier::Administrator)
|
||||
);
|
||||
assert_eq!(
|
||||
CommandTier::from_str("engineer"),
|
||||
Some(CommandTier::Engineer)
|
||||
);
|
||||
assert_eq!(CommandTier::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_tier_ordering() {
|
||||
assert!(CommandTier::Analyst < CommandTier::Administrator);
|
||||
assert!(CommandTier::Administrator < CommandTier::Engineer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_text() {
|
||||
let output = CommandOutput::text("hello");
|
||||
assert_eq!(output.lines.len(), 1);
|
||||
match &output.lines[0] {
|
||||
OutputLine::Text(s) => assert_eq!(s, "hello"),
|
||||
_ => panic!("expected Text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_error_display() {
|
||||
let err = CommandError::Unknown("foo".to_string());
|
||||
assert_eq!(err.to_string(), "Unknown command: foo");
|
||||
|
||||
let err = CommandError::ElevationRequired {
|
||||
registry: "invoice".to_string(),
|
||||
verb: "void".to_string(),
|
||||
};
|
||||
assert_eq!(err.to_string(), "Elevation required: invoice:void");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_check_equality() {
|
||||
assert_eq!(ScopeCheck::Authorized, ScopeCheck::Authorized);
|
||||
assert_eq!(ScopeCheck::Expired, ScopeCheck::Expired);
|
||||
assert_ne!(ScopeCheck::Authorized, ScopeCheck::Expired);
|
||||
}
|
||||
}
|
||||
76
bascule-shell/src/governed/commands/approve.rs
Normal file
76
bascule-shell/src/governed/commands/approve.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct ApproveCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for ApproveCommand {
|
||||
fn name(&self) -> &str { "approve" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
// Approval itself is read-only — it's the TARGET operation that
|
||||
// requires elevation. The approver's identity comes from the session.
|
||||
RequiredScope::ReadOnly
|
||||
}
|
||||
fn description(&self) -> &str { "Approve a pending ceremony" }
|
||||
fn usage(&self) -> &str { "approve <ceremony_id> [comment]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if args.is_empty() {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: approve <ceremony_id> [comment]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let ceremony_id = &args[0];
|
||||
let comment = if args.len() > 1 {
|
||||
Some(args[1..].join(" "))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Phase 1: stub — reports what would happen.
|
||||
// Production: calls CeremonyService.ApproveCeremony with session identity.
|
||||
let identity = session.identity().display_name.clone();
|
||||
let role = session.identity().roles.first().cloned().unwrap_or_default();
|
||||
|
||||
let mut lines = vec![
|
||||
OutputLine::Status {
|
||||
label: "Ceremony".to_string(),
|
||||
value: ceremony_id.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Approver".to_string(),
|
||||
value: format!("{} ({})", identity, role),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Decision".to_string(),
|
||||
value: "APPROVED".to_string(),
|
||||
color: OutputColor::Green,
|
||||
},
|
||||
];
|
||||
|
||||
if let Some(c) = &comment {
|
||||
lines.push(OutputLine::Status {
|
||||
label: "Comment".to_string(),
|
||||
value: c.clone(),
|
||||
color: OutputColor::Default,
|
||||
});
|
||||
}
|
||||
|
||||
lines.push(OutputLine::Blank);
|
||||
lines.push(OutputLine::Text(
|
||||
"(stub: CeremonyService.ApproveCeremony not connected)".to_string(),
|
||||
));
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
43
bascule-shell/src/governed/commands/ceremonies.rs
Normal file
43
bascule-shell/src/governed/commands/ceremonies.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::GovernedSession;
|
||||
|
||||
pub struct CeremoniesCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for CeremoniesCommand {
|
||||
fn name(&self) -> &str { "ceremonies" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["cer"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "List pending governance ceremonies" }
|
||||
fn usage(&self) -> &str { "ceremonies [--all]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
// Phase 1: stub — shows placeholder ceremony list.
|
||||
// Production: calls CeremonyService.ListPendingCeremonies.
|
||||
let lines = vec![
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"CEREMONY".to_string(),
|
||||
"TYPE".to_string(),
|
||||
"SUBJECT".to_string(),
|
||||
"APPROVALS".to_string(),
|
||||
"DEADLINE".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["(no ceremonies loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
|
||||
],
|
||||
},
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text("Use 'approve <ceremony_id>' or 'deny <ceremony_id>' to act.".to_string()),
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
38
bascule-shell/src/governed/commands/deescalate.rs
Normal file
38
bascule-shell/src/governed/commands/deescalate.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct DeescalateCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for DeescalateCommand {
|
||||
fn name(&self) -> &str { "deescalate" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Drop elevation to read-only scope" }
|
||||
fn usage(&self) -> &str { "deescalate" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if !session.is_elevated() {
|
||||
return Ok(CommandOutput::text("Not currently elevated."));
|
||||
}
|
||||
|
||||
session.deescalate();
|
||||
|
||||
Ok(CommandOutput {
|
||||
lines: vec![
|
||||
OutputLine::Status {
|
||||
label: "Scope".to_string(),
|
||||
value: "read-only".to_string(),
|
||||
color: OutputColor::Green,
|
||||
},
|
||||
OutputLine::Text("Elevation dropped. Returned to read-only scope.".to_string()),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
65
bascule-shell/src/governed/commands/deny.rs
Normal file
65
bascule-shell/src/governed/commands/deny.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct DenyCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for DenyCommand {
|
||||
fn name(&self) -> &str { "deny" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Deny a pending ceremony" }
|
||||
fn usage(&self) -> &str { "deny <ceremony_id> [reason]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if args.is_empty() {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: deny <ceremony_id> [reason]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let ceremony_id = &args[0];
|
||||
let reason = if args.len() > 1 {
|
||||
args[1..].join(" ")
|
||||
} else {
|
||||
"No reason given".to_string()
|
||||
};
|
||||
|
||||
let identity = session.identity().display_name.clone();
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Status {
|
||||
label: "Ceremony".to_string(),
|
||||
value: ceremony_id.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Denier".to_string(),
|
||||
value: identity,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Decision".to_string(),
|
||||
value: "DENIED".to_string(),
|
||||
color: OutputColor::Red,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Reason".to_string(),
|
||||
value: reason,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text(
|
||||
"(stub: CeremonyService.DenyCeremony not connected)".to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
71
bascule-shell/src/governed/commands/deploy.rs
Normal file
71
bascule-shell/src/governed/commands/deploy.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct DeployCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for DeployCommand {
|
||||
fn name(&self) -> &str { "deploy" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Engineer }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
RequiredScope::Elevated {
|
||||
registry: "schematic".to_string(),
|
||||
verb: "deploy".to_string(),
|
||||
}
|
||||
}
|
||||
fn description(&self) -> &str { "Deploy a schematic version" }
|
||||
fn usage(&self) -> &str { "deploy <schematic_name> <version> [environment]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: deploy <schematic_name> <version> [environment]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let schematic_name = &args[0];
|
||||
let version = &args[1];
|
||||
let environment = args.get(2).map(|s| s.as_str()).unwrap_or("default");
|
||||
let tenant = session.tenant_context().unwrap_or("*").to_string();
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!(
|
||||
"Deploying: {} v{} -> {} (tenant: {})",
|
||||
schematic_name, version, environment, tenant
|
||||
)),
|
||||
OutputLine::Separator,
|
||||
OutputLine::Status {
|
||||
label: "Schematic".to_string(),
|
||||
value: schematic_name.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Version".to_string(),
|
||||
value: version.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Environment".to_string(),
|
||||
value: environment.to_string(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Status".to_string(),
|
||||
value: "(stub: deployment not connected)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
73
bascule-shell/src/governed/commands/elevate.rs
Normal file
73
bascule-shell/src/governed/commands/elevate.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct ElevateCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for ElevateCommand {
|
||||
fn name(&self) -> &str { "elevate" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Request scope elevation" }
|
||||
fn usage(&self) -> &str { "elevate <registry> <verb> [ttl_minutes]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: elevate <registry> <verb> [ttl_minutes]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = &args[0];
|
||||
let verb = &args[1];
|
||||
let ttl = args.get(2).and_then(|s| s.parse::<u64>().ok()).unwrap_or(15);
|
||||
let tenant = session.tenant_context().unwrap_or("*").to_string();
|
||||
|
||||
// Phase 1: stub — shows what would happen.
|
||||
// Production: creates ceremony via CeremonyService, polls for approval.
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!(
|
||||
"Requesting elevation: {}:{} on {} ({}m TTL)",
|
||||
registry, verb, tenant, ttl
|
||||
)),
|
||||
OutputLine::Separator,
|
||||
OutputLine::Status {
|
||||
label: "Registry".to_string(),
|
||||
value: registry.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Verb".to_string(),
|
||||
value: verb.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Tenant".to_string(),
|
||||
value: tenant,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "TTL".to_string(),
|
||||
value: format!("{} minutes", ttl),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Status".to_string(),
|
||||
value: "(stub: ceremony creation not connected)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
26
bascule-shell/src/governed/commands/exit.rs
Normal file
26
bascule-shell/src/governed/commands/exit.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::GovernedSession;
|
||||
|
||||
pub struct ExitCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for ExitCommand {
|
||||
fn name(&self) -> &str { "exit" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["quit"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Exit the shell" }
|
||||
fn usage(&self) -> &str { "exit" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
// The executor handles exit by detecting the command name.
|
||||
// This execute is called if it gets through.
|
||||
Ok(CommandOutput::text("Goodbye."))
|
||||
}
|
||||
}
|
||||
58
bascule-shell/src/governed/commands/help.rs
Normal file
58
bascule-shell/src/governed/commands/help.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::GovernedSession;
|
||||
|
||||
pub struct HelpCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for HelpCommand {
|
||||
fn name(&self) -> &str { "help" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["?"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Show available commands" }
|
||||
fn usage(&self) -> &str { "help [command]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if let Some(cmd_name) = args.first() {
|
||||
// Detailed help for a specific command is handled by the executor
|
||||
// since it needs access to the registry. Return a hint.
|
||||
Ok(CommandOutput::text(format!("Use 'help' to see all commands. Requested help for: {}", cmd_name)))
|
||||
} else {
|
||||
let lines = vec![
|
||||
OutputLine::Text("Available commands:".to_string()),
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text("Analyst (read-only):".to_string()),
|
||||
OutputLine::Text(" help Show available commands".to_string()),
|
||||
OutputLine::Text(" status Session and connection status".to_string()),
|
||||
OutputLine::Text(" whoami Show identity and SAT info".to_string()),
|
||||
OutputLine::Text(" tenants List accessible tenants".to_string()),
|
||||
OutputLine::Text(" use Select tenant context".to_string()),
|
||||
OutputLine::Text(" registries List registries for current tenant".to_string()),
|
||||
OutputLine::Text(" query Query a registry artifact".to_string()),
|
||||
OutputLine::Text(" history Show mutation history".to_string()),
|
||||
OutputLine::Text(" verify Verify merkle proof for an artifact".to_string()),
|
||||
OutputLine::Text(" ceremonies List pending governance ceremonies".to_string()),
|
||||
OutputLine::Text(" exit Exit the shell".to_string()),
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text("Administrator (require elevation):".to_string()),
|
||||
OutputLine::Text(" approve Approve a pending ceremony".to_string()),
|
||||
OutputLine::Text(" deny Deny a pending ceremony".to_string()),
|
||||
OutputLine::Text(" elevate Request scope elevation".to_string()),
|
||||
OutputLine::Text(" deescalate Drop elevation to read-only".to_string()),
|
||||
OutputLine::Text(" void Void a registry artifact".to_string()),
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text("Engineer (require elevation):".to_string()),
|
||||
OutputLine::Text(" deploy Deploy a schematic version".to_string()),
|
||||
OutputLine::Text(" pipeline Pipeline management".to_string()),
|
||||
OutputLine::Text(" schematic Schematic management".to_string()),
|
||||
];
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
}
|
||||
58
bascule-shell/src/governed/commands/history.rs
Normal file
58
bascule-shell/src/governed/commands/history.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct HistoryCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for HistoryCommand {
|
||||
fn name(&self) -> &str { "history" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Show mutation history for a registry" }
|
||||
fn usage(&self) -> &str { "history <registry> [artifact_id]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.is_empty() {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: history <registry> [artifact_id]".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = &args[0];
|
||||
let artifact_id = args.get(1);
|
||||
|
||||
// Phase 1: stub — shows placeholder history.
|
||||
let scope = if let Some(id) = artifact_id {
|
||||
format!("{}:{}", registry, id)
|
||||
} else {
|
||||
registry.clone()
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!("Mutation history for: {}", scope)),
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"TIMESTAMP".to_string(),
|
||||
"VERB".to_string(),
|
||||
"ACTOR".to_string(),
|
||||
"CEREMONY".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["(no history loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
58
bascule-shell/src/governed/commands/mod.rs
Normal file
58
bascule-shell/src/governed/commands/mod.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Built-in shell commands.
|
||||
|
||||
pub mod approve;
|
||||
pub mod ceremonies;
|
||||
pub mod deescalate;
|
||||
pub mod deny;
|
||||
pub mod deploy;
|
||||
pub mod elevate;
|
||||
pub mod exit;
|
||||
pub mod help;
|
||||
pub mod history;
|
||||
pub mod pipeline;
|
||||
pub mod query;
|
||||
pub mod registries;
|
||||
pub mod schematic;
|
||||
pub mod status;
|
||||
pub mod tenants;
|
||||
pub mod use_tenant;
|
||||
pub mod verify;
|
||||
pub mod void;
|
||||
pub mod whoami;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::command_trait::ShellCommand;
|
||||
use super::registry::CommandRegistry;
|
||||
|
||||
/// Register all built-in commands into the registry.
|
||||
pub fn register_all_builtins(registry: &mut CommandRegistry) {
|
||||
let builtins: Vec<Arc<dyn ShellCommand>> = vec![
|
||||
// Analyst tier
|
||||
Arc::new(help::HelpCommand),
|
||||
Arc::new(status::StatusCommand),
|
||||
Arc::new(tenants::TenantsCommand),
|
||||
Arc::new(use_tenant::UseTenantCommand),
|
||||
Arc::new(registries::RegistriesCommand),
|
||||
Arc::new(query::QueryCommand),
|
||||
Arc::new(history::HistoryCommand),
|
||||
Arc::new(verify::VerifyCommand),
|
||||
Arc::new(ceremonies::CeremoniesCommand),
|
||||
Arc::new(whoami::WhoamiCommand),
|
||||
Arc::new(exit::ExitCommand),
|
||||
// Administrator tier
|
||||
Arc::new(approve::ApproveCommand),
|
||||
Arc::new(deny::DenyCommand),
|
||||
Arc::new(elevate::ElevateCommand),
|
||||
Arc::new(deescalate::DeescalateCommand),
|
||||
Arc::new(void::VoidCommand),
|
||||
// Engineer tier
|
||||
Arc::new(deploy::DeployCommand),
|
||||
Arc::new(pipeline::PipelineCommand),
|
||||
Arc::new(schematic::SchematicCommand),
|
||||
];
|
||||
|
||||
for cmd in builtins {
|
||||
registry.register(cmd);
|
||||
}
|
||||
}
|
||||
69
bascule-shell/src/governed/commands/pipeline.rs
Normal file
69
bascule-shell/src/governed/commands/pipeline.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct PipelineCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for PipelineCommand {
|
||||
fn name(&self) -> &str { "pipeline" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["pl"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Engineer }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
// Base command is read-only; subcommands like "trigger" require elevation.
|
||||
RequiredScope::ReadOnly
|
||||
}
|
||||
fn description(&self) -> &str { "Pipeline management (list, show, trigger)" }
|
||||
fn usage(&self) -> &str { "pipeline <list|show|trigger> [args...]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list");
|
||||
|
||||
match subcommand {
|
||||
"list" | "ls" => {
|
||||
let lines = vec![
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"RUN ID".to_string(),
|
||||
"PIPELINE".to_string(),
|
||||
"STATUS".to_string(),
|
||||
"BRANCH".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["(no runs loaded)".to_string(), "-".to_string(), "-".to_string(), "-".to_string()],
|
||||
],
|
||||
},
|
||||
];
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
"show" => {
|
||||
let run_id = args.get(1).ok_or_else(|| {
|
||||
CommandError::InvalidArgs("Usage: pipeline show <run_id>".to_string())
|
||||
})?;
|
||||
Ok(CommandOutput::text(format!(
|
||||
"Pipeline run: {} (stub: not connected to runner-controller)",
|
||||
run_id
|
||||
)))
|
||||
}
|
||||
"trigger" => {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
// Trigger requires elevation.
|
||||
Err(CommandError::ElevationRequired {
|
||||
registry: "pipeline".to_string(),
|
||||
verb: "trigger".to_string(),
|
||||
})
|
||||
}
|
||||
_ => Err(CommandError::InvalidArgs(format!(
|
||||
"Unknown subcommand: {}. Use: list, show, trigger",
|
||||
subcommand
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
65
bascule-shell/src/governed/commands/query.rs
Normal file
65
bascule-shell/src/governed/commands/query.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct QueryCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for QueryCommand {
|
||||
fn name(&self) -> &str { "query" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["q"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Query a registry artifact" }
|
||||
fn usage(&self) -> &str { "query <registry> <artifact_id>" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: query <registry> <artifact_id>".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = &args[0];
|
||||
let artifact_id = &args[1];
|
||||
|
||||
// Phase 1: stub — shows placeholder artifact info.
|
||||
// Production: calls the appropriate registry gRPC service.
|
||||
let tenant = session.tenant_context().unwrap_or("*");
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!("Artifact: {}/{}/{}", tenant, registry, artifact_id)),
|
||||
OutputLine::Separator,
|
||||
OutputLine::Status {
|
||||
label: "Registry".to_string(),
|
||||
value: registry.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Artifact ID".to_string(),
|
||||
value: artifact_id.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Tenant".to_string(),
|
||||
value: tenant.to_string(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Status".to_string(),
|
||||
value: "(not connected to live service)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
49
bascule-shell/src/governed/commands/registries.rs
Normal file
49
bascule-shell/src/governed/commands/registries.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct RegistriesCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for RegistriesCommand {
|
||||
fn name(&self) -> &str { "registries" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["regs"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "List registries for current tenant" }
|
||||
fn usage(&self) -> &str { "registries" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
// Phase 1: returns the known registry types.
|
||||
// Production: queries actual registry services for artifact counts.
|
||||
let lines = vec![
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"REGISTRY".to_string(),
|
||||
"TYPE".to_string(),
|
||||
"DESCRIPTION".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["credential".to_string(), "governed".to_string(), "Service credentials".to_string()],
|
||||
vec!["invoice".to_string(), "governed".to_string(), "Financial invoices".to_string()],
|
||||
vec!["pipeline-result".to_string(), "governed".to_string(), "Pipeline attestations".to_string()],
|
||||
vec!["capability-profile".to_string(), "governed".to_string(), "Workload capabilities".to_string()],
|
||||
vec!["sync-event".to_string(), "governed".to_string(), "Git sync events".to_string()],
|
||||
vec!["schematic".to_string(), "governed".to_string(), "Infrastructure schematics".to_string()],
|
||||
vec!["governance-ceremony".to_string(), "governed".to_string(), "Ceremony resolutions".to_string()],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
71
bascule-shell/src/governed/commands/schematic.rs
Normal file
71
bascule-shell/src/governed/commands/schematic.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct SchematicCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for SchematicCommand {
|
||||
fn name(&self) -> &str { "schematic" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["sch"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Engineer }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
// Base command is read-only; mutation subcommands require elevation.
|
||||
RequiredScope::ReadOnly
|
||||
}
|
||||
fn description(&self) -> &str { "Schematic management (list, show, validate, approve)" }
|
||||
fn usage(&self) -> &str { "schematic <list|show|validate|approve> [args...]" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
let subcommand = args.first().map(|s| s.as_str()).unwrap_or("list");
|
||||
|
||||
match subcommand {
|
||||
"list" | "ls" => {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"NAME".to_string(),
|
||||
"VERSION".to_string(),
|
||||
"STATUS".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["(no schematics loaded)".to_string(), "-".to_string(), "-".to_string()],
|
||||
],
|
||||
},
|
||||
];
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
"show" => {
|
||||
let name = args.get(1).ok_or_else(|| {
|
||||
CommandError::InvalidArgs("Usage: schematic show <name> [version]".to_string())
|
||||
})?;
|
||||
Ok(CommandOutput::text(format!(
|
||||
"Schematic: {} (stub: not connected)",
|
||||
name
|
||||
)))
|
||||
}
|
||||
"validate" | "approve" => {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
Err(CommandError::ElevationRequired {
|
||||
registry: "schematic".to_string(),
|
||||
verb: subcommand.to_string(),
|
||||
})
|
||||
}
|
||||
_ => Err(CommandError::InvalidArgs(format!(
|
||||
"Unknown subcommand: {}. Use: list, show, validate, approve",
|
||||
subcommand
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
90
bascule-shell/src/governed/commands/status.rs
Normal file
90
bascule-shell/src/governed/commands/status.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct StatusCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for StatusCommand {
|
||||
fn name(&self) -> &str { "status" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Show session and connection status" }
|
||||
fn usage(&self) -> &str { "status" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
let tenant = session.tenant_context().unwrap_or("* (none selected)");
|
||||
let scope = if session.is_elevated() { "elevated" } else { "read-only" };
|
||||
let elevation = if let Some(e) = &session.elevated_sat {
|
||||
let remaining = (e.expires_at - chrono::Utc::now()).num_seconds().max(0);
|
||||
let mins = remaining / 60;
|
||||
let secs = remaining % 60;
|
||||
format!("{}m {}s remaining", mins, secs)
|
||||
} else {
|
||||
"none".to_string()
|
||||
};
|
||||
|
||||
let connected = session.connected_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||
let session_remaining = {
|
||||
let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0);
|
||||
let hours = remaining / 3600;
|
||||
let mins = (remaining % 3600) / 60;
|
||||
format!("{}h {}m", hours, mins)
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Status {
|
||||
label: "Session".to_string(),
|
||||
value: session.session_id().to_string(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "User".to_string(),
|
||||
value: session.identity().display_name.clone(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Roles".to_string(),
|
||||
value: session.identity().roles.join(", "),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Scope".to_string(),
|
||||
value: scope.to_string(),
|
||||
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green },
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Tenant".to_string(),
|
||||
value: tenant.to_string(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Connected".to_string(),
|
||||
value: connected,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Session expires".to_string(),
|
||||
value: session_remaining,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Elevation".to_string(),
|
||||
value: elevation,
|
||||
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Default },
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Commands executed".to_string(),
|
||||
value: session.command_count.to_string(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
41
bascule-shell/src/governed/commands/tenants.rs
Normal file
41
bascule-shell/src/governed/commands/tenants.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::GovernedSession;
|
||||
|
||||
pub struct TenantsCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for TenantsCommand {
|
||||
fn name(&self) -> &str { "tenants" }
|
||||
fn aliases(&self) -> Vec<&str> { vec!["ls-tenants"] }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "List accessible tenants" }
|
||||
fn usage(&self) -> &str { "tenants" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
// Phase 1: stub — returns placeholder tenant list.
|
||||
// Production: calls tenant listing endpoint via gRPC.
|
||||
let lines = vec![
|
||||
OutputLine::Table {
|
||||
headers: vec![
|
||||
"TENANT".to_string(),
|
||||
"STATUS".to_string(),
|
||||
"REGISTRIES".to_string(),
|
||||
],
|
||||
rows: vec![
|
||||
vec!["(no tenants loaded)".to_string(), "-".to_string(), "-".to_string()],
|
||||
],
|
||||
},
|
||||
OutputLine::Blank,
|
||||
OutputLine::Text("Use 'use <tenant_id>' to select a tenant.".to_string()),
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
45
bascule-shell/src/governed/commands/use_tenant.rs
Normal file
45
bascule-shell/src/governed/commands/use_tenant.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct UseTenantCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for UseTenantCommand {
|
||||
fn name(&self) -> &str { "use" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Select tenant context" }
|
||||
fn usage(&self) -> &str { "use <tenant_id>" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
let tenant_id = args.first().ok_or_else(|| {
|
||||
CommandError::InvalidArgs("Usage: use <tenant_id>".to_string())
|
||||
})?;
|
||||
|
||||
// Clear context with "use *" or "use none".
|
||||
if tenant_id == "*" || tenant_id == "none" {
|
||||
session.set_tenant_context(None);
|
||||
return Ok(CommandOutput::text("Tenant context cleared."));
|
||||
}
|
||||
|
||||
// Phase 1: accept any tenant_id (production: validate via gRPC).
|
||||
session.set_tenant_context(Some(tenant_id.clone()));
|
||||
|
||||
Ok(CommandOutput {
|
||||
lines: vec![
|
||||
OutputLine::Status {
|
||||
label: "Tenant".to_string(),
|
||||
value: tenant_id.clone(),
|
||||
color: OutputColor::Green,
|
||||
},
|
||||
OutputLine::Text(format!("Context set to '{}'. Commands will target this tenant.", tenant_id)),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
53
bascule-shell/src/governed/commands/verify.rs
Normal file
53
bascule-shell/src/governed/commands/verify.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct VerifyCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for VerifyCommand {
|
||||
fn name(&self) -> &str { "verify" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Verify merkle proof for an artifact" }
|
||||
fn usage(&self) -> &str { "verify <registry> <artifact_id>" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: verify <registry> <artifact_id>".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = &args[0];
|
||||
let artifact_id = &args[1];
|
||||
|
||||
// Phase 1: stub — shows placeholder verification.
|
||||
// Production: calls NotaryService.VerifyInclusion.
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!("Verifying: {}/{}", registry, artifact_id)),
|
||||
OutputLine::Separator,
|
||||
OutputLine::Status {
|
||||
label: "Anchor".to_string(),
|
||||
value: "(not connected to notary)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Verification".to_string(),
|
||||
value: "unavailable (notary not connected)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
69
bascule-shell/src/governed/commands/void.rs
Normal file
69
bascule-shell/src/governed/commands/void.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{GovernedSession, Governed};
|
||||
|
||||
pub struct VoidCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for VoidCommand {
|
||||
fn name(&self) -> &str { "void" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
RequiredScope::Elevated {
|
||||
registry: "invoice".to_string(),
|
||||
verb: "void".to_string(),
|
||||
}
|
||||
}
|
||||
fn description(&self) -> &str { "Void a registry artifact" }
|
||||
fn usage(&self) -> &str { "void <registry> <artifact_id>" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
if session.tenant_context().is_none() {
|
||||
return Err(CommandError::NoTenantContext);
|
||||
}
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(CommandError::InvalidArgs(
|
||||
"Usage: void <registry> <artifact_id>".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let registry = &args[0];
|
||||
let artifact_id = &args[1];
|
||||
let tenant = session.tenant_context().unwrap_or("*").to_string();
|
||||
|
||||
// Phase 1: stub — shows what would happen.
|
||||
// Production: creates MutationIntent with elevated SAT, executes void.
|
||||
let lines = vec![
|
||||
OutputLine::Text(format!("Voiding: {}/{}/{}", tenant, registry, artifact_id)),
|
||||
OutputLine::Separator,
|
||||
OutputLine::Status {
|
||||
label: "Registry".to_string(),
|
||||
value: registry.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Artifact".to_string(),
|
||||
value: artifact_id.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Tenant".to_string(),
|
||||
value: tenant,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Status".to_string(),
|
||||
value: "(stub: void operation not connected)".to_string(),
|
||||
color: OutputColor::Yellow,
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
64
bascule-shell/src/governed/commands/whoami.rs
Normal file
64
bascule-shell/src/governed/commands/whoami.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{AuthMethod, GovernedSession, Governed};
|
||||
|
||||
pub struct WhoamiCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for WhoamiCommand {
|
||||
fn name(&self) -> &str { "whoami" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Show identity and SAT info" }
|
||||
fn usage(&self) -> &str { "whoami" }
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
let auth = match &session.identity().auth_method {
|
||||
AuthMethod::PublicKey { fingerprint } => format!("public-key ({})", fingerprint),
|
||||
AuthMethod::Token { issuer } => format!("token ({})", issuer),
|
||||
};
|
||||
|
||||
let scope = if session.is_elevated() { "elevated" } else { "read-only" };
|
||||
let tenant_scope = session.tenant_context().unwrap_or("*");
|
||||
|
||||
let remaining = (session.base_sat.expires_at - chrono::Utc::now()).num_seconds().max(0);
|
||||
let hours = remaining / 3600;
|
||||
let mins = (remaining % 3600) / 60;
|
||||
let expires = format!("{}h {}m", hours, mins);
|
||||
|
||||
let lines = vec![
|
||||
OutputLine::Status {
|
||||
label: "Identity".to_string(),
|
||||
value: session.identity().display_name.clone(),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Subject".to_string(),
|
||||
value: session.identity().subject.clone(),
|
||||
color: OutputColor::Cyan,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Roles".to_string(),
|
||||
value: format!("[{}]", session.identity().roles.join(", ")),
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "Auth".to_string(),
|
||||
value: auth,
|
||||
color: OutputColor::Default,
|
||||
},
|
||||
OutputLine::Status {
|
||||
label: "SAT".to_string(),
|
||||
value: format!("{} | Tenant scope: {} | Expires: {}", scope, tenant_scope, expires),
|
||||
color: if session.is_elevated() { OutputColor::Yellow } else { OutputColor::Green },
|
||||
},
|
||||
];
|
||||
|
||||
Ok(CommandOutput { lines })
|
||||
}
|
||||
}
|
||||
319
bascule-shell/src/governed/executor.rs
Normal file
319
bascule-shell/src/governed/executor.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
//! Command executor — the governed REPL loop.
|
||||
//!
|
||||
//! Reads input, routes commands, checks scopes, handles elevation,
|
||||
//! and renders output.
|
||||
|
||||
use super::command_trait::{CommandError, ScopeCheck};
|
||||
use super::registry::CommandRegistry;
|
||||
use super::render::OutputRenderer;
|
||||
use super::session::{GovernedSession, Governed};
|
||||
|
||||
/// Executes commands within a governed shell session.
|
||||
pub struct CommandExecutor {
|
||||
pub registry: CommandRegistry,
|
||||
pub renderer: OutputRenderer,
|
||||
}
|
||||
|
||||
/// Result of processing a single command line.
|
||||
#[derive(Debug)]
|
||||
pub enum ExecResult {
|
||||
/// Command produced output.
|
||||
Output(String),
|
||||
/// Session should end.
|
||||
Exit,
|
||||
/// Elevation is required for this command.
|
||||
ElevationNeeded { registry: String, verb: String },
|
||||
/// Error message.
|
||||
Error(String),
|
||||
/// Session expired.
|
||||
SessionExpired,
|
||||
/// Elevation just expired (informational).
|
||||
ElevationExpired,
|
||||
/// Empty input.
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl CommandExecutor {
|
||||
pub fn new(registry: CommandRegistry) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
renderer: OutputRenderer::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a single input line and return the result.
|
||||
pub async fn process_line(
|
||||
&self,
|
||||
line: &str,
|
||||
session: &mut GovernedSession,
|
||||
) -> ExecResult {
|
||||
// Check elevation expiry before processing.
|
||||
if session.check_elevation_expiry() {
|
||||
return ExecResult::ElevationExpired;
|
||||
}
|
||||
|
||||
// Check base SAT expiry.
|
||||
if session.base_sat_expired() {
|
||||
return ExecResult::SessionExpired;
|
||||
}
|
||||
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return ExecResult::Empty;
|
||||
}
|
||||
|
||||
// Parse command + args.
|
||||
let parts: Vec<String> = split_args(line);
|
||||
let (cmd_name, args) = match parts.split_first() {
|
||||
Some((name, args)) => (name.as_str(), args),
|
||||
None => return ExecResult::Empty,
|
||||
};
|
||||
|
||||
// Look up command.
|
||||
let command = match self.registry.get(cmd_name) {
|
||||
Some(cmd) => cmd.clone(),
|
||||
None => {
|
||||
return ExecResult::Error(format!("Unknown command: {}. Type 'help' for available commands.", cmd_name));
|
||||
}
|
||||
};
|
||||
|
||||
// Check scope BEFORE execution.
|
||||
let scope_check = session.can_execute(&command.required_scope());
|
||||
match scope_check {
|
||||
ScopeCheck::Authorized => {}
|
||||
ScopeCheck::Expired => {
|
||||
return ExecResult::SessionExpired;
|
||||
}
|
||||
ScopeCheck::ElevationRequired { registry, verb } => {
|
||||
return ExecResult::ElevationNeeded { registry, verb };
|
||||
}
|
||||
ScopeCheck::ElevationExpired => {
|
||||
return ExecResult::ElevationExpired;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command.
|
||||
match command.execute(args, session).await {
|
||||
Ok(output) => {
|
||||
session.increment_command_count();
|
||||
let rendered = self.renderer.render(&output);
|
||||
if rendered.is_empty() {
|
||||
ExecResult::Output(String::new())
|
||||
} else {
|
||||
ExecResult::Output(rendered)
|
||||
}
|
||||
}
|
||||
Err(CommandError::ElevationRequired { registry, verb }) => {
|
||||
ExecResult::ElevationNeeded { registry, verb }
|
||||
}
|
||||
Err(e) => ExecResult::Error(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the welcome banner.
|
||||
pub fn welcome_banner(&self, session: &GovernedSession) -> String {
|
||||
let mut banner = String::new();
|
||||
banner.push_str("Guildhouse Governed Shell\n");
|
||||
banner.push_str(&format!(
|
||||
"Session: {} | User: {} | Role: {}\n",
|
||||
session.session_id,
|
||||
session.identity.display_name,
|
||||
session.identity.roles.join(", ")
|
||||
));
|
||||
banner.push_str(&format!(
|
||||
"Commands: {} available | Type 'help' for usage\n",
|
||||
self.registry.len()
|
||||
));
|
||||
banner.push('\n');
|
||||
banner
|
||||
}
|
||||
}
|
||||
|
||||
/// Split an input line into arguments, respecting quoted strings.
|
||||
fn split_args(input: &str) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_quote = false;
|
||||
let mut quote_char = ' ';
|
||||
|
||||
for ch in input.chars() {
|
||||
if in_quote {
|
||||
if ch == quote_char {
|
||||
in_quote = false;
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
} else if ch == '"' || ch == '\'' {
|
||||
in_quote = true;
|
||||
quote_char = ch;
|
||||
} else if ch.is_whitespace() {
|
||||
if !current.is_empty() {
|
||||
args.push(std::mem::take(&mut current));
|
||||
}
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::governed::command_trait::*;
|
||||
use crate::governed::session::{AuthMethod, OperatorIdentity};
|
||||
use async_trait::async_trait;
|
||||
use registry_protocol::sat::SatRef;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct ExitCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for ExitCommand {
|
||||
fn name(&self) -> &str { "exit" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Analyst }
|
||||
fn required_scope(&self) -> RequiredScope { RequiredScope::ReadOnly }
|
||||
fn description(&self) -> &str { "Exit the shell" }
|
||||
fn usage(&self) -> &str { "exit" }
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
Ok(CommandOutput::text("Goodbye!"))
|
||||
}
|
||||
}
|
||||
|
||||
struct ElevatedCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl ShellCommand for ElevatedCommand {
|
||||
fn name(&self) -> &str { "void" }
|
||||
fn tier(&self) -> CommandTier { CommandTier::Administrator }
|
||||
fn required_scope(&self) -> RequiredScope {
|
||||
RequiredScope::Elevated {
|
||||
registry: "invoice".to_string(),
|
||||
verb: "void".to_string(),
|
||||
}
|
||||
}
|
||||
fn description(&self) -> &str { "Void an artifact" }
|
||||
fn usage(&self) -> &str { "void <registry> <id>" }
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: &[String],
|
||||
_session: &mut GovernedSession,
|
||||
) -> Result<CommandOutput, CommandError> {
|
||||
Ok(CommandOutput::text("voided"))
|
||||
}
|
||||
}
|
||||
|
||||
fn test_session() -> GovernedSession {
|
||||
let now = chrono::Utc::now();
|
||||
GovernedSession::new(
|
||||
"sess-test".to_string(),
|
||||
OperatorIdentity {
|
||||
subject: "tyler".to_string(),
|
||||
display_name: "Tyler King".to_string(),
|
||||
roles: vec!["engineer".to_string()],
|
||||
auth_method: AuthMethod::PublicKey {
|
||||
fingerprint: "SHA256:test".to_string(),
|
||||
},
|
||||
},
|
||||
"127.0.0.1:12345".parse().unwrap(),
|
||||
SatRef {
|
||||
sat_hash: [0u8; 32],
|
||||
bearer_svid: "spiffe://test".to_string(),
|
||||
scopes: vec![],
|
||||
issued_at: now,
|
||||
expires_at: now + chrono::Duration::hours(12),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn test_executor() -> CommandExecutor {
|
||||
let mut registry = CommandRegistry::new();
|
||||
registry.register(Arc::new(ExitCommand));
|
||||
registry.register(Arc::new(ElevatedCommand));
|
||||
CommandExecutor {
|
||||
registry,
|
||||
renderer: OutputRenderer { use_color: false, ..Default::default() },
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_routes_known_command() {
|
||||
let executor = test_executor();
|
||||
let mut session = test_session();
|
||||
match executor.process_line("exit", &mut session).await {
|
||||
ExecResult::Output(s) => assert!(s.contains("Goodbye")),
|
||||
other => panic!("expected Output, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_rejects_unknown_command() {
|
||||
let executor = test_executor();
|
||||
let mut session = test_session();
|
||||
match executor.process_line("nonexistent", &mut session).await {
|
||||
ExecResult::Error(s) => assert!(s.contains("Unknown command")),
|
||||
other => panic!("expected Error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_blocks_elevated_command() {
|
||||
let executor = test_executor();
|
||||
let mut session = test_session();
|
||||
match executor.process_line("void invoice INV-001", &mut session).await {
|
||||
ExecResult::ElevationNeeded { registry, verb } => {
|
||||
assert_eq!(registry, "invoice");
|
||||
assert_eq!(verb, "void");
|
||||
}
|
||||
other => panic!("expected ElevationNeeded, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_empty_line() {
|
||||
let executor = test_executor();
|
||||
let mut session = test_session();
|
||||
match executor.process_line("", &mut session).await {
|
||||
ExecResult::Empty => {}
|
||||
other => panic!("expected Empty, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_args_simple() {
|
||||
assert_eq!(split_args("hello world"), vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_args_quoted() {
|
||||
assert_eq!(
|
||||
split_args(r#"void invoice "my reason""#),
|
||||
vec!["void", "invoice", "my reason"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_args_empty() {
|
||||
assert!(split_args("").is_empty());
|
||||
assert!(split_args(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn welcome_banner_includes_session_info() {
|
||||
let session = test_session();
|
||||
let executor = test_executor();
|
||||
let banner = executor.welcome_banner(&session);
|
||||
assert!(banner.contains("Tyler King"));
|
||||
assert!(banner.contains("engineer"));
|
||||
assert!(banner.contains("sess-test"));
|
||||
}
|
||||
}
|
||||
166
bascule-shell/src/governed/manifest.rs
Normal file
166
bascule-shell/src/governed/manifest.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! Command manifest — declares which commands are available in this image.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Parsed from `/opt/bascule/commands/manifest.yaml`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommandManifest {
|
||||
pub image_tier: String,
|
||||
pub org_name: Option<String>,
|
||||
pub commands: Vec<CommandEntry>,
|
||||
}
|
||||
|
||||
/// A single command entry in the manifest.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommandEntry {
|
||||
pub name: String,
|
||||
pub tier: String,
|
||||
pub builtin: bool,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandManifest {
|
||||
/// Load manifest from a YAML file.
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
let manifest: Self = serde_yaml::from_str(&contents)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Load manifest from a YAML string.
|
||||
pub fn from_str(yaml: &str) -> anyhow::Result<Self> {
|
||||
let manifest: Self = serde_yaml::from_str(yaml)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Default analyst manifest with all built-in read commands.
|
||||
pub fn default_analyst() -> Self {
|
||||
Self {
|
||||
image_tier: "analyst".to_string(),
|
||||
org_name: None,
|
||||
commands: vec![
|
||||
cmd("help", "analyst"),
|
||||
cmd("status", "analyst"),
|
||||
cmd("tenants", "analyst"),
|
||||
cmd("use", "analyst"),
|
||||
cmd("registries", "analyst"),
|
||||
cmd("query", "analyst"),
|
||||
cmd("history", "analyst"),
|
||||
cmd("verify", "analyst"),
|
||||
cmd("ceremonies", "analyst"),
|
||||
cmd("whoami", "analyst"),
|
||||
cmd("exit", "analyst"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Default administrator manifest — all analyst + admin commands.
|
||||
pub fn default_administrator() -> Self {
|
||||
let mut m = Self::default_analyst();
|
||||
m.image_tier = "administrator".to_string();
|
||||
m.commands.extend(vec![
|
||||
cmd("approve", "administrator"),
|
||||
cmd("deny", "administrator"),
|
||||
cmd("elevate", "administrator"),
|
||||
cmd("deescalate", "administrator"),
|
||||
cmd("void", "administrator"),
|
||||
]);
|
||||
m
|
||||
}
|
||||
|
||||
/// Default engineer manifest — all admin + engineer commands.
|
||||
pub fn default_engineer() -> Self {
|
||||
let mut m = Self::default_administrator();
|
||||
m.image_tier = "engineer".to_string();
|
||||
m.commands.extend(vec![
|
||||
cmd("deploy", "engineer"),
|
||||
cmd("provision", "engineer"),
|
||||
cmd("pipeline", "engineer"),
|
||||
cmd("schematic", "engineer"),
|
||||
]);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd(name: &str, tier: &str) -> CommandEntry {
|
||||
CommandEntry {
|
||||
name: name.to_string(),
|
||||
tier: tier.to_string(),
|
||||
builtin: true,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_yaml() {
|
||||
let yaml = r#"
|
||||
image_tier: analyst
|
||||
org_name: acme-msp
|
||||
commands:
|
||||
- name: status
|
||||
tier: analyst
|
||||
builtin: true
|
||||
- name: query
|
||||
tier: analyst
|
||||
builtin: true
|
||||
description: "Query registry artifacts"
|
||||
"#;
|
||||
let manifest = CommandManifest::from_str(yaml).unwrap();
|
||||
assert_eq!(manifest.image_tier, "analyst");
|
||||
assert_eq!(manifest.org_name.as_deref(), Some("acme-msp"));
|
||||
assert_eq!(manifest.commands.len(), 2);
|
||||
assert_eq!(manifest.commands[0].name, "status");
|
||||
assert!(manifest.commands[0].builtin);
|
||||
assert_eq!(
|
||||
manifest.commands[1].description.as_deref(),
|
||||
Some("Query registry artifacts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_analyst_has_read_commands() {
|
||||
let manifest = CommandManifest::default_analyst();
|
||||
assert_eq!(manifest.image_tier, "analyst");
|
||||
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.contains(&"status"));
|
||||
assert!(names.contains(&"query"));
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"exit"));
|
||||
assert!(!names.contains(&"approve"));
|
||||
assert!(!names.contains(&"deploy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_administrator_includes_analyst_and_admin() {
|
||||
let manifest = CommandManifest::default_administrator();
|
||||
assert_eq!(manifest.image_tier, "administrator");
|
||||
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.contains(&"status")); // analyst
|
||||
assert!(names.contains(&"approve")); // admin
|
||||
assert!(names.contains(&"elevate")); // admin
|
||||
assert!(!names.contains(&"deploy")); // engineer only
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_engineer_includes_all() {
|
||||
let manifest = CommandManifest::default_engineer();
|
||||
assert_eq!(manifest.image_tier, "engineer");
|
||||
let names: Vec<&str> = manifest.commands.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.contains(&"status")); // analyst
|
||||
assert!(names.contains(&"approve")); // admin
|
||||
assert!(names.contains(&"deploy")); // engineer
|
||||
assert!(names.contains(&"pipeline")); // engineer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_from_file_missing_returns_error() {
|
||||
let result = CommandManifest::from_file(Path::new("/nonexistent/manifest.yaml"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
13
bascule-shell/src/governed/mod.rs
Normal file
13
bascule-shell/src/governed/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Governed SSH shell runtime.
|
||||
//!
|
||||
//! Implements the tiered command model with SAT-based authorization,
|
||||
//! ceremony-gated elevation, and full session audit.
|
||||
|
||||
pub mod command_trait;
|
||||
pub mod commands;
|
||||
pub mod executor;
|
||||
pub mod manifest;
|
||||
pub mod registry;
|
||||
pub mod render;
|
||||
pub mod session;
|
||||
pub mod ssh;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue