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 audit;
|
||||||
pub mod ceremony;
|
pub mod ceremony;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
|
pub mod delegation;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod shell_class;
|
pub mod shell_class;
|
||||||
|
|
||||||
|
pub use delegation::{DelegationDecision, DelegationScope, TargetSelector};
|
||||||
pub use shell_class::{derive_shell_class, ShellClass};
|
pub use shell_class::{derive_shell_class, ShellClass};
|
||||||
|
|
||||||
// Governance ceremony engine — extracted to ceremony-engine crate.
|
// Governance ceremony engine — extracted to ceremony-engine crate.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::delegation::DelegationScope;
|
||||||
use crate::shell_class::ShellClass;
|
use crate::shell_class::ShellClass;
|
||||||
|
|
||||||
/// Defines what an operator can do within a session.
|
/// 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.
|
/// Stored for audit (Chronicle) and for posture breach comparison.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub posture_level_at_establishment: Option<u8>,
|
pub posture_level_at_establishment: Option<u8>,
|
||||||
|
|
||||||
|
/// Delegation authority — controls remote dispatch to other hosts.
|
||||||
|
#[serde(default)]
|
||||||
|
pub delegation: DelegationScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-namespace access rules.
|
/// Per-namespace access rules.
|
||||||
|
|
@ -149,6 +154,7 @@ mod tests {
|
||||||
can_delegate: false,
|
can_delegate: false,
|
||||||
shell_class: ShellClass::default(),
|
shell_class: ShellClass::default(),
|
||||||
posture_level_at_establishment: None,
|
posture_level_at_establishment: None,
|
||||||
|
delegation: DelegationScope::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||||
|
|
@ -177,6 +183,7 @@ mod tests {
|
||||||
can_delegate: false,
|
can_delegate: false,
|
||||||
shell_class: ShellClass::default(),
|
shell_class: ShellClass::default(),
|
||||||
posture_level_at_establishment: None,
|
posture_level_at_establishment: None,
|
||||||
|
delegation: DelegationScope::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(scope.permits("default", "", "pods", Verb::Get));
|
assert!(scope.permits("default", "", "pods", Verb::Get));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue