Skip to main content

max / makenotwork

5.9 KB · 161 lines History Blame Raw
1 //! The capability model — the "trusted" in trusted executor.
2 //!
3 //! An [`crate::Executor`] is built for a host from a declared [`CapabilitySet`]
4 //! and refuses any action outside it. Enforcement is *double*:
5 //!
6 //! 1. **Caller side** — the executor rejects an ungranted action before
7 //! dispatch (this module; fail fast, fully audit-loggable).
8 //! 2. **Agent side** — `ops-agent` independently enforces its *own* configured
9 //! grant by [intersecting](CapabilitySet::intersect) the caller-implied
10 //! request with its local grant, so a compromised or buggy daemon cannot
11 //! make a prod agent actuate when the agent's local config is observe-only.
12
13 use crate::step::{Action, ObserveKind};
14 use serde::{Deserialize, Serialize};
15 use std::collections::BTreeSet;
16
17 /// What an executor (or an agent) is allowed to do on one host.
18 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
19 pub struct CapabilitySet {
20 /// Actuating actions, by token (`deploy`, `sign`, …). Stored as tokens so
21 /// `Custom` actions round-trip and the set is cheap to compare/serialize.
22 #[serde(default)]
23 actuate: BTreeSet<String>,
24 /// Observe kinds this host may be read for.
25 #[serde(default)]
26 observe: BTreeSet<ObserveKind>,
27 }
28
29 impl CapabilitySet {
30 /// Build from the two token lists a topology config carries:
31 /// `actuate = ["deploy", "restart"]`, `observe = ["health"]`.
32 pub fn from_tokens<A, O>(actuate: A, observe: O) -> Self
33 where
34 A: IntoIterator,
35 A::Item: AsRef<str>,
36 O: IntoIterator,
37 O::Item: AsRef<str>,
38 {
39 Self {
40 actuate: actuate.into_iter().map(|t| t.as_ref().to_string()).collect(),
41 observe: observe.into_iter().map(|t| ObserveKind::from_token(t.as_ref())).collect(),
42 }
43 }
44
45 /// A set that permits exactly the given actuate actions and no observe.
46 pub fn actuate_only<I>(actions: I) -> Self
47 where
48 I: IntoIterator<Item = Action>,
49 {
50 Self {
51 actuate: actions.into_iter().filter_map(|a| a.token()).collect(),
52 observe: BTreeSet::new(),
53 }
54 }
55
56 /// Does this set permit `action`?
57 pub fn permits(&self, action: &Action) -> bool {
58 match action {
59 Action::Observe(kind) => self.observe.contains(kind),
60 other => other.token().is_some_and(|t| self.actuate.contains(&t)),
61 }
62 }
63
64 pub fn permits_observe(&self, kind: &ObserveKind) -> bool {
65 self.observe.contains(kind)
66 }
67
68 pub fn has_any_observe(&self) -> bool {
69 !self.observe.is_empty()
70 }
71
72 pub fn actuate_tokens(&self) -> impl Iterator<Item = &str> {
73 self.actuate.iter().map(String::as_str)
74 }
75
76 pub fn observe_kinds(&self) -> impl Iterator<Item = &ObserveKind> {
77 self.observe.iter()
78 }
79
80 /// The agent-side enforcement primitive: the effective grant is the
81 /// intersection of what the caller's identity is allowed and what this host
82 /// locally grants. Neither side can widen the other.
83 pub fn intersect(&self, other: &CapabilitySet) -> CapabilitySet {
84 CapabilitySet {
85 actuate: self.actuate.intersection(&other.actuate).cloned().collect(),
86 observe: self.observe.intersection(&other.observe).cloned().collect(),
87 }
88 }
89 }
90
91 /// Returned (boxed into `anyhow::Error`) when an executor is asked to run an
92 /// action outside its grant. Carries enough to audit-log the denial.
93 #[derive(Debug, Clone, thiserror::Error)]
94 #[error("capability denied: host `{host}` is not granted action `{action}`")]
95 pub struct CapabilityDenied {
96 pub host: String,
97 pub action: String,
98 }
99
100 impl CapabilityDenied {
101 pub fn new(host: impl Into<String>, action: &Action) -> Self {
102 Self {
103 host: host.into(),
104 action: match action {
105 Action::Observe(k) => format!("observe:{}", k.token()),
106 other => other.token().unwrap_or_else(|| "unknown".into()),
107 },
108 }
109 }
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn permits_granted_actuate_only() {
118 let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["health"]);
119 assert!(caps.permits(&Action::Deploy));
120 assert!(caps.permits(&Action::Restart));
121 assert!(!caps.permits(&Action::Rollback));
122 assert!(!caps.permits(&Action::Sign));
123 }
124
125 #[test]
126 fn permits_observe_by_kind() {
127 let caps = CapabilitySet::from_tokens(["deploy"], ["journal", "health"]);
128 assert!(caps.permits(&Action::Observe(ObserveKind::Health)));
129 assert!(caps.permits(&Action::Observe(ObserveKind::Journal)));
130 assert!(!caps.permits(&Action::Observe(ObserveKind::Metrics)));
131 }
132
133 #[test]
134 fn custom_action_needs_exact_grant() {
135 let caps = CapabilitySet::from_tokens(["smoke-test"], Vec::<&str>::new());
136 assert!(caps.permits(&Action::Custom("smoke-test".into())));
137 assert!(!caps.permits(&Action::Custom("rm-rf".into())));
138 }
139
140 #[test]
141 fn intersect_is_the_floor_of_both() {
142 // Caller identity may deploy+restart+sign; the prod agent grants only
143 // observe. Intersection: nothing actuates, only the shared observe.
144 let caller = CapabilitySet::from_tokens(["deploy", "restart", "sign"], ["health", "journal"]);
145 let agent = CapabilitySet::from_tokens(Vec::<&str>::new(), ["health"]);
146 let eff = caller.intersect(&agent);
147 assert!(!eff.permits(&Action::Deploy));
148 assert!(eff.permits(&Action::Observe(ObserveKind::Health)));
149 assert!(!eff.permits(&Action::Observe(ObserveKind::Journal)));
150 }
151
152 #[test]
153 fn denied_error_renders_action() {
154 let d = CapabilityDenied::new("prod", &Action::Sign);
155 assert!(d.to_string().contains("prod"));
156 assert!(d.to_string().contains("sign"));
157 let d2 = CapabilityDenied::new("prod", &Action::Observe(ObserveKind::Metrics));
158 assert!(d2.to_string().contains("observe:metrics"));
159 }
160 }
161