//! The capability model — the "trusted" in trusted executor. //! //! An [`crate::Executor`] is built for a host from a declared [`CapabilitySet`] //! and refuses any action outside it. Enforcement is *double*: //! //! 1. **Caller side** — the executor rejects an ungranted action before //! dispatch (this module; fail fast, fully audit-loggable). //! 2. **Agent side** — `ops-agent` independently enforces its *own* configured //! grant by [intersecting](CapabilitySet::intersect) the caller-implied //! request with its local grant, so a compromised or buggy daemon cannot //! make a prod agent actuate when the agent's local config is observe-only. use crate::step::{Action, ObserveKind}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; /// What an executor (or an agent) is allowed to do on one host. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilitySet { /// Actuating actions, by token (`deploy`, `sign`, …). Stored as tokens so /// `Custom` actions round-trip and the set is cheap to compare/serialize. #[serde(default)] actuate: BTreeSet, /// Observe kinds this host may be read for. #[serde(default)] observe: BTreeSet, } impl CapabilitySet { /// Build from the two token lists a topology config carries: /// `actuate = ["deploy", "restart"]`, `observe = ["health"]`. pub fn from_tokens(actuate: A, observe: O) -> Self where A: IntoIterator, A::Item: AsRef, O: IntoIterator, O::Item: AsRef, { Self { actuate: actuate.into_iter().map(|t| t.as_ref().to_string()).collect(), observe: observe.into_iter().map(|t| ObserveKind::from_token(t.as_ref())).collect(), } } /// A set that permits exactly the given actuate actions and no observe. pub fn actuate_only(actions: I) -> Self where I: IntoIterator, { Self { actuate: actions.into_iter().filter_map(|a| a.token()).collect(), observe: BTreeSet::new(), } } /// Does this set permit `action`? pub fn permits(&self, action: &Action) -> bool { match action { Action::Observe(kind) => self.observe.contains(kind), other => other.token().is_some_and(|t| self.actuate.contains(&t)), } } pub fn permits_observe(&self, kind: &ObserveKind) -> bool { self.observe.contains(kind) } pub fn has_any_observe(&self) -> bool { !self.observe.is_empty() } pub fn actuate_tokens(&self) -> impl Iterator { self.actuate.iter().map(String::as_str) } pub fn observe_kinds(&self) -> impl Iterator { self.observe.iter() } /// The agent-side enforcement primitive: the effective grant is the /// intersection of what the caller's identity is allowed and what this host /// locally grants. Neither side can widen the other. pub fn intersect(&self, other: &CapabilitySet) -> CapabilitySet { CapabilitySet { actuate: self.actuate.intersection(&other.actuate).cloned().collect(), observe: self.observe.intersection(&other.observe).cloned().collect(), } } } /// Returned (boxed into `anyhow::Error`) when an executor is asked to run an /// action outside its grant. Carries enough to audit-log the denial. #[derive(Debug, Clone, thiserror::Error)] #[error("capability denied: host `{host}` is not granted action `{action}`")] pub struct CapabilityDenied { pub host: String, pub action: String, } impl CapabilityDenied { pub fn new(host: impl Into, action: &Action) -> Self { Self { host: host.into(), action: match action { Action::Observe(k) => format!("observe:{}", k.token()), other => other.token().unwrap_or_else(|| "unknown".into()), }, } } } #[cfg(test)] mod tests { use super::*; #[test] fn permits_granted_actuate_only() { let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["health"]); assert!(caps.permits(&Action::Deploy)); assert!(caps.permits(&Action::Restart)); assert!(!caps.permits(&Action::Rollback)); assert!(!caps.permits(&Action::Sign)); } #[test] fn permits_observe_by_kind() { let caps = CapabilitySet::from_tokens(["deploy"], ["journal", "health"]); assert!(caps.permits(&Action::Observe(ObserveKind::Health))); assert!(caps.permits(&Action::Observe(ObserveKind::Journal))); assert!(!caps.permits(&Action::Observe(ObserveKind::Metrics))); } #[test] fn custom_action_needs_exact_grant() { let caps = CapabilitySet::from_tokens(["smoke-test"], Vec::<&str>::new()); assert!(caps.permits(&Action::Custom("smoke-test".into()))); assert!(!caps.permits(&Action::Custom("rm-rf".into()))); } #[test] fn intersect_is_the_floor_of_both() { // Caller identity may deploy+restart+sign; the prod agent grants only // observe. Intersection: nothing actuates, only the shared observe. let caller = CapabilitySet::from_tokens(["deploy", "restart", "sign"], ["health", "journal"]); let agent = CapabilitySet::from_tokens(Vec::<&str>::new(), ["health"]); let eff = caller.intersect(&agent); assert!(!eff.permits(&Action::Deploy)); assert!(eff.permits(&Action::Observe(ObserveKind::Health))); assert!(!eff.permits(&Action::Observe(ObserveKind::Journal))); } #[test] fn denied_error_renders_action() { let d = CapabilityDenied::new("prod", &Action::Sign); assert!(d.to_string().contains("prod")); assert!(d.to_string().contains("sign")); let d2 = CapabilityDenied::new("prod", &Action::Observe(ObserveKind::Metrics)); assert!(d2.to_string().contains("observe:metrics")); } }