feat(bascule-core): add DelegationScope for Infrastructure shell pattern
DelegationScope is orthogonal to ShellClass — an Application session can have delegation authority to orchestrate System operations on remote targets (the Infrastructure shell pattern for Ansible/Terraform). TargetSelector supports: None, Hosts (explicit list), LabelSelector (deferred to K8s API), TrustDomain (all hosts). Default: denied (fail-closed). DelegationDecision: Permitted, Denied (with reason), Deferred (for async label resolution). Added delegation field to SessionScope with #[serde(default)] for backward-compatible deserialization. 7 unit tests for delegation scope checking. Signed-off-by: Tyler King <tking@guildhouse.dev> Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
parent
ece4e2349f
commit
aa447f151e
3 changed files with 211 additions and 0 deletions
202
bascule-core/src/delegation.rs
Normal file
202
bascule-core/src/delegation.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Copyright 2026 Guildhouse Dev
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Delegation authority for governed shell sessions.
|
||||
//!
|
||||
//! Controls whether a session can dispatch governed operations to remote
|
||||
//! targets. Orthogonal to [`ShellClass`] — an Application session can
|
||||
//! have delegation authority (the "Infrastructure shell" pattern for
|
||||
//! Ansible/Terraform orchestrators).
|
||||
|
||||
use crate::shell_class::ShellClass;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Delegation authority for a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationScope {
|
||||
/// Whether delegation is permitted at all.
|
||||
pub permitted: bool,
|
||||
|
||||
/// Target hosts this session may delegate to.
|
||||
pub target_selector: TargetSelector,
|
||||
|
||||
/// Maximum shell class that can be delegated.
|
||||
pub max_delegated_class: ShellClass,
|
||||
}
|
||||
|
||||
/// Selector for delegation targets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TargetSelector {
|
||||
/// No targets — delegation denied regardless of `permitted` flag.
|
||||
None,
|
||||
/// Specific hosts by name.
|
||||
Hosts(Vec<String>),
|
||||
/// Kubernetes label selector (resolved at dispatch time).
|
||||
LabelSelector(String),
|
||||
/// All hosts in the trust domain.
|
||||
TrustDomain,
|
||||
}
|
||||
|
||||
impl Default for DelegationScope {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
permitted: false,
|
||||
target_selector: TargetSelector::None,
|
||||
max_delegated_class: ShellClass::Application,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a delegation scope check.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DelegationDecision {
|
||||
/// Delegation is allowed.
|
||||
Permitted,
|
||||
/// Delegation is denied with a reason.
|
||||
Denied { reason: String },
|
||||
/// Delegation check requires async resolution (label selector).
|
||||
Deferred { reason: String },
|
||||
}
|
||||
|
||||
impl DelegationScope {
|
||||
/// Check whether this scope permits delegation to a specific target
|
||||
/// for a specific operation class.
|
||||
pub fn permits(&self, target_host: &str, required_class: ShellClass) -> DelegationDecision {
|
||||
if !self.permitted {
|
||||
return DelegationDecision::Denied {
|
||||
reason: "session does not have delegation authority".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let target_allowed = match &self.target_selector {
|
||||
TargetSelector::None => false,
|
||||
TargetSelector::Hosts(hosts) => hosts.iter().any(|h| h == target_host),
|
||||
TargetSelector::LabelSelector(_) => {
|
||||
return DelegationDecision::Deferred {
|
||||
reason: "label selector requires K8s API resolution".into(),
|
||||
};
|
||||
}
|
||||
TargetSelector::TrustDomain => true,
|
||||
};
|
||||
|
||||
if !target_allowed {
|
||||
return DelegationDecision::Denied {
|
||||
reason: format!("target '{target_host}' is not in delegation scope"),
|
||||
};
|
||||
}
|
||||
|
||||
if !self.max_delegated_class.satisfies(required_class) {
|
||||
return DelegationDecision::Denied {
|
||||
reason: format!(
|
||||
"delegation permits {} operations, but {} required",
|
||||
self.max_delegated_class, required_class
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
DelegationDecision::Permitted
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_denies_delegation() {
|
||||
let scope = DelegationScope::default();
|
||||
assert!(!scope.permitted);
|
||||
let d = scope.permits("any-host", ShellClass::Application);
|
||||
assert_eq!(
|
||||
d,
|
||||
DelegationDecision::Denied {
|
||||
reason: "session does not have delegation authority".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permitted_with_host_match() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into(), "worker-2".into()]),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::System),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permitted_host_not_in_scope() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::Hosts(vec!["worker-1".into()]),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
let d = scope.permits("worker-99", ShellClass::Application);
|
||||
assert!(matches!(d, DelegationDecision::Denied { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_domain_allows_any_host() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::TrustDomain,
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("any-host", ShellClass::System),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_selector_defers() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::LabelSelector("role=worker".into()),
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert!(matches!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Deferred { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_ceiling_enforced() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::TrustDomain,
|
||||
max_delegated_class: ShellClass::Application, // ceiling
|
||||
};
|
||||
assert_eq!(
|
||||
scope.permits("worker-1", ShellClass::Application),
|
||||
DelegationDecision::Permitted
|
||||
);
|
||||
assert!(matches!(
|
||||
scope.permits("worker-1", ShellClass::System),
|
||||
DelegationDecision::Denied { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_target_selector_denies() {
|
||||
let scope = DelegationScope {
|
||||
permitted: true,
|
||||
target_selector: TargetSelector::None,
|
||||
max_delegated_class: ShellClass::System,
|
||||
};
|
||||
assert!(matches!(
|
||||
scope.permits("any-host", ShellClass::Application),
|
||||
DelegationDecision::Denied { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
|
||||
/// 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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue