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:
Tyler J King 2026-04-15 15:16:24 -04:00
parent ece4e2349f
commit aa447f151e
3 changed files with 211 additions and 0 deletions

View 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 { .. }
));
}
}

View file

@ -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.

View file

@ -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));