Skip to main content

max / makenotwork

7.4 KB · 191 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 let actuate: BTreeSet<String> = actuate
40 .into_iter()
41 .map(|t| t.as_ref().to_string())
42 .collect();
43 let mut observe: BTreeSet<ObserveKind> = observe
44 .into_iter()
45 .map(|t| ObserveKind::from_token(t.as_ref()))
46 .collect();
47 // A host that can `sign` can, by definition, verify the signature (spctl /
48 // Gatekeeper). The publish gate *requires* that verification —
49 // `verify_gatekeeper` dispatches `Observe("gatekeeper")` and
50 // `PublishAuthority::prove` bars every macOS/iOS publish without a
51 // Some(true) verdict. Grant the observe implicitly here, the one chokepoint
52 // all three grant sources (topology executor, agent self-grant, agent
53 // caller allow-list) flow through, so a single missing token in any of them
54 // can't silently dead-end signing.
55 if actuate.contains("sign") {
56 observe.insert(ObserveKind::Custom("gatekeeper".into()));
57 }
58 Self { actuate, observe }
59 }
60
61 /// A set that permits exactly the given actuate actions and no observe.
62 pub fn actuate_only<I>(actions: I) -> Self
63 where
64 I: IntoIterator<Item = Action>,
65 {
66 Self {
67 actuate: actions.into_iter().filter_map(|a| a.token()).collect(),
68 observe: BTreeSet::new(),
69 }
70 }
71
72 /// Does this set permit `action`?
73 pub fn permits(&self, action: &Action) -> bool {
74 match action {
75 Action::Observe(kind) => self.observe.contains(kind),
76 other => other.token().is_some_and(|t| self.actuate.contains(&t)),
77 }
78 }
79
80 pub fn permits_observe(&self, kind: &ObserveKind) -> bool {
81 self.observe.contains(kind)
82 }
83
84 pub fn has_any_observe(&self) -> bool {
85 !self.observe.is_empty()
86 }
87
88 pub fn actuate_tokens(&self) -> impl Iterator<Item = &str> {
89 self.actuate.iter().map(String::as_str)
90 }
91
92 pub fn observe_kinds(&self) -> impl Iterator<Item = &ObserveKind> {
93 self.observe.iter()
94 }
95
96 /// The agent-side enforcement primitive: the effective grant is the
97 /// intersection of what the caller's identity is allowed and what this host
98 /// locally grants. Neither side can widen the other.
99 #[must_use]
100 pub fn intersect(&self, other: &CapabilitySet) -> CapabilitySet {
101 CapabilitySet {
102 actuate: self.actuate.intersection(&other.actuate).cloned().collect(),
103 observe: self.observe.intersection(&other.observe).cloned().collect(),
104 }
105 }
106 }
107
108 /// Returned (boxed into `anyhow::Error`) when an executor is asked to run an
109 /// action outside its grant. Carries enough to audit-log the denial.
110 #[derive(Debug, Clone, thiserror::Error)]
111 #[error("capability denied: host `{host}` is not granted action `{action}`")]
112 pub struct CapabilityDenied {
113 pub host: String,
114 pub action: String,
115 }
116
117 impl CapabilityDenied {
118 pub fn new(host: impl Into<String>, action: &Action) -> Self {
119 Self {
120 host: host.into(),
121 action: match action {
122 Action::Observe(k) => format!("observe:{}", k.token()),
123 other => other.token().unwrap_or_else(|| "unknown".into()),
124 },
125 }
126 }
127 }
128
129 #[cfg(test)]
130 mod tests {
131 use super::*;
132
133 #[test]
134 fn permits_granted_actuate_only() {
135 let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["health"]);
136 assert!(caps.permits(&Action::Deploy));
137 assert!(caps.permits(&Action::Restart));
138 assert!(!caps.permits(&Action::Rollback));
139 assert!(!caps.permits(&Action::Sign));
140 }
141
142 #[test]
143 fn permits_observe_by_kind() {
144 let caps = CapabilitySet::from_tokens(["deploy"], ["journal", "health"]);
145 assert!(caps.permits(&Action::Observe(ObserveKind::Health)));
146 assert!(caps.permits(&Action::Observe(ObserveKind::Journal)));
147 assert!(!caps.permits(&Action::Observe(ObserveKind::Metrics)));
148 }
149
150 #[test]
151 fn sign_grant_implies_gatekeeper_observe() {
152 // A sign host can run verify_gatekeeper without declaring the observe
153 // token — otherwise every macOS/iOS publish dead-ends at the gate.
154 let caps =
155 CapabilitySet::from_tokens(["build", "sign", "notarize", "staple"], ["build-log"]);
156 assert!(caps.permits(&Action::Observe(ObserveKind::Custom("gatekeeper".into()))));
157 // A non-signing host gets no such implicit grant.
158 let plain = CapabilitySet::from_tokens(["build"], ["build-log"]);
159 assert!(!plain.permits(&Action::Observe(ObserveKind::Custom("gatekeeper".into()))));
160 }
161
162 #[test]
163 fn custom_action_needs_exact_grant() {
164 let caps = CapabilitySet::from_tokens(["smoke-test"], Vec::<&str>::new());
165 assert!(caps.permits(&Action::Custom("smoke-test".into())));
166 assert!(!caps.permits(&Action::Custom("rm-rf".into())));
167 }
168
169 #[test]
170 fn intersect_is_the_floor_of_both() {
171 // Caller identity may deploy+restart+sign; the prod agent grants only
172 // observe. Intersection: nothing actuates, only the shared observe.
173 let caller =
174 CapabilitySet::from_tokens(["deploy", "restart", "sign"], ["health", "journal"]);
175 let agent = CapabilitySet::from_tokens(Vec::<&str>::new(), ["health"]);
176 let eff = caller.intersect(&agent);
177 assert!(!eff.permits(&Action::Deploy));
178 assert!(eff.permits(&Action::Observe(ObserveKind::Health)));
179 assert!(!eff.permits(&Action::Observe(ObserveKind::Journal)));
180 }
181
182 #[test]
183 fn denied_error_renders_action() {
184 let d = CapabilityDenied::new("prod", &Action::Sign);
185 assert!(d.to_string().contains("prod"));
186 assert!(d.to_string().contains("sign"));
187 let d2 = CapabilityDenied::new("prod", &Action::Observe(ObserveKind::Metrics));
188 assert!(d2.to_string().contains("observe:metrics"));
189 }
190 }
191