From aa447f151ef97acfe164da519add073f99198d87eb93e8e504a98bca1e4700b1 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Wed, 15 Apr 2026 15:16:24 -0400 Subject: [PATCH] feat(bascule-core): add DelegationScope for Infrastructure shell pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DelegationScope is orthogonal to ShellClass — an Application session can have delegation authority to orchestrate System operations on remote targets (the Infrastructure shell pattern for Ansible/Terraform). TargetSelector supports: None, Hosts (explicit list), LabelSelector (deferred to K8s API), TrustDomain (all hosts). Default: denied (fail-closed). DelegationDecision: Permitted, Denied (with reason), Deferred (for async label resolution). Added delegation field to SessionScope with #[serde(default)] for backward-compatible deserialization. 7 unit tests for delegation scope checking. Signed-off-by: Tyler King Signed-off-by: Tyler J King --- bascule-core/src/delegation.rs | 202 +++++++++++++++++++++++++++++++++ bascule-core/src/lib.rs | 2 + bascule-core/src/scope.rs | 7 ++ 3 files changed, 211 insertions(+) create mode 100644 bascule-core/src/delegation.rs diff --git a/bascule-core/src/delegation.rs b/bascule-core/src/delegation.rs new file mode 100644 index 0000000..b192bf1 --- /dev/null +++ b/bascule-core/src/delegation.rs @@ -0,0 +1,202 @@ +// Copyright 2026 Guildhouse Dev +// SPDX-License-Identifier: Apache-2.0 + +//! Delegation authority for governed shell sessions. +//! +//! Controls whether a session can dispatch governed operations to remote +//! targets. Orthogonal to [`ShellClass`] — an Application session can +//! have delegation authority (the "Infrastructure shell" pattern for +//! Ansible/Terraform orchestrators). + +use crate::shell_class::ShellClass; +use serde::{Deserialize, Serialize}; + +/// Delegation authority for a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegationScope { + /// Whether delegation is permitted at all. + pub permitted: bool, + + /// Target hosts this session may delegate to. + pub target_selector: TargetSelector, + + /// Maximum shell class that can be delegated. + pub max_delegated_class: ShellClass, +} + +/// Selector for delegation targets. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetSelector { + /// No targets — delegation denied regardless of `permitted` flag. + None, + /// Specific hosts by name. + Hosts(Vec), + /// Kubernetes label selector (resolved at dispatch time). + LabelSelector(String), + /// All hosts in the trust domain. + TrustDomain, +} + +impl Default for DelegationScope { + fn default() -> Self { + Self { + permitted: false, + target_selector: TargetSelector::None, + max_delegated_class: ShellClass::Application, + } + } +} + +/// Result of a delegation scope check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DelegationDecision { + /// Delegation is allowed. + Permitted, + /// Delegation is denied with a reason. + Denied { reason: String }, + /// Delegation check requires async resolution (label selector). + Deferred { reason: String }, +} + +impl DelegationScope { + /// Check whether this scope permits delegation to a specific target + /// for a specific operation class. + pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision { + if !self.permitted { + return DelegationDecision::Denied { + reason: "session does not have delegation authority".into(), + }; + } + + let target_allowed = match &self.target_selector { + TargetSelector::None => false, + TargetSelector::Hosts(hosts) => hosts.iter().any(|h| h == target_host), + TargetSelector::LabelSelector(_) => { + return DelegationDecision::Deferred { + reason: "label selector requires K8s API resolution".into(), + }; + } + TargetSelector::TrustDomain => true, + }; + + if !target_allowed { + return DelegationDecision::Denied { + reason: format!("target '{target_host}' is not in delegation scope"), + }; + } + + if !self.max_delegated_class.satisfies(required_class) { + return DelegationDecision::Denied { + reason: format!( + "delegation permits {} operations, but {} required", + self.max_delegated_class, required_class + ), + }; + } + + DelegationDecision::Permitted + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_denies_delegation() { + let scope = DelegationScope::default(); + assert!(!scope.permitted); + let d = scope.permits("any-host", ShellClass::Application); + assert_eq!( + d, + DelegationDecision::Denied { + reason: "session does not have delegation authority".into() + } + ); + } + + #[test] + fn permitted_with_host_match() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::Hosts(vec!["worker-1".into(), "worker-2".into()]), + max_delegated_class: ShellClass::System, + }; + assert_eq!( + scope.permits("worker-1", ShellClass::Application), + DelegationDecision::Permitted + ); + assert_eq!( + scope.permits("worker-1", ShellClass::System), + DelegationDecision::Permitted + ); + } + + #[test] + fn permitted_host_not_in_scope() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::Hosts(vec!["worker-1".into()]), + max_delegated_class: ShellClass::System, + }; + let d = scope.permits("worker-99", ShellClass::Application); + assert!(matches!(d, DelegationDecision::Denied { .. })); + } + + #[test] + fn trust_domain_allows_any_host() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::TrustDomain, + max_delegated_class: ShellClass::System, + }; + assert_eq!( + scope.permits("any-host", ShellClass::System), + DelegationDecision::Permitted + ); + } + + #[test] + fn label_selector_defers() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::LabelSelector("role=worker".into()), + max_delegated_class: ShellClass::System, + }; + assert!(matches!( + scope.permits("worker-1", ShellClass::Application), + DelegationDecision::Deferred { .. } + )); + } + + #[test] + fn class_ceiling_enforced() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::TrustDomain, + max_delegated_class: ShellClass::Application, // ceiling + }; + assert_eq!( + scope.permits("worker-1", ShellClass::Application), + DelegationDecision::Permitted + ); + assert!(matches!( + scope.permits("worker-1", ShellClass::System), + DelegationDecision::Denied { .. } + )); + } + + #[test] + fn none_target_selector_denies() { + let scope = DelegationScope { + permitted: true, + target_selector: TargetSelector::None, + max_delegated_class: ShellClass::System, + }; + assert!(matches!( + scope.permits("any-host", ShellClass::Application), + DelegationDecision::Denied { .. } + )); + } +} diff --git a/bascule-core/src/lib.rs b/bascule-core/src/lib.rs index d69a97e..1f0e6fb 100644 --- a/bascule-core/src/lib.rs +++ b/bascule-core/src/lib.rs @@ -1,10 +1,12 @@ pub mod audit; pub mod ceremony; pub mod command; +pub mod delegation; pub mod scope; pub mod session; pub mod shell_class; +pub use delegation::{DelegationDecision, DelegationScope, TargetSelector}; pub use shell_class::{derive_shell_class, ShellClass}; // Governance ceremony engine — extracted to ceremony-engine crate. diff --git a/bascule-core/src/scope.rs b/bascule-core/src/scope.rs index 9f0bd0b..b424b0c 100644 --- a/bascule-core/src/scope.rs +++ b/bascule-core/src/scope.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +use crate::delegation::DelegationScope; use crate::shell_class::ShellClass; /// Defines what an operator can do within a session. @@ -22,6 +23,10 @@ pub struct SessionScope { /// Stored for audit (Chronicle) and for posture breach comparison. #[serde(default)] pub posture_level_at_establishment: Option, + + /// Delegation authority — controls remote dispatch to other hosts. + #[serde(default)] + pub delegation: DelegationScope, } /// Per-namespace access rules. @@ -149,6 +154,7 @@ mod tests { can_delegate: false, shell_class: ShellClass::default(), posture_level_at_establishment: None, + delegation: DelegationScope::default(), }; assert!(scope.permits("default", "", "pods", Verb::Get)); @@ -177,6 +183,7 @@ mod tests { can_delegate: false, shell_class: ShellClass::default(), posture_level_at_establishment: None, + delegation: DelegationScope::default(), }; assert!(scope.permits("default", "", "pods", Verb::Get));