Skip to main content

hatchery_enroll/
policy.rs

1//! Status -> capability policy, shared by C2 (enforcement) and hatchery-server (grants).
2//!
3//! The defaults are the built-in table; an operator can replace the three configurable rows
4//! (neutral, member, trusted) with a JSON file. `suspended` and `revoked` are always empty and
5//! cannot be configured. Unknown fields and unknown capability names are rejected so a typo
6//! never silently widens or narrows access.
7
8use serde::{Deserialize, Serialize};
9
10use crate::NodeStatus;
11
12/// Capabilities the policy can grant. `heartbeat` = may call home and be seen alive,
13/// `attach` = operator may attach sessions and see inventory, `build` = may request a remote build+sign, `tasks`/`tools` = task and tool
14/// access (consumers check them through the C2 operator API), `mail`/`m4a` = messaging.
15pub const KNOWN_CAPABILITIES: &[&str] = &["heartbeat", "attach", "mail", "m4a", "tasks", "tools", "build"];
16
17#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
18#[serde(default, deny_unknown_fields)]
19pub struct Grants {
20    pub mailbox: bool,
21    pub rooms: Vec<String>,
22    pub capabilities: Vec<String>,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(default, deny_unknown_fields)]
27pub struct Policy {
28    pub neutral: Grants,
29    pub member: Grants,
30    pub trusted: Grants,
31}
32
33#[derive(Debug, thiserror::Error, Eq, PartialEq)]
34pub enum PolicyError {
35    #[error("policy file is not valid: {0}")]
36    Parse(String),
37    #[error("unknown capability '{0}' (known: heartbeat, attach, mail, m4a, tasks, tools, build)")]
38    UnknownCapability(String),
39}
40
41fn list(items: &[&str]) -> Vec<String> {
42    items.iter().map(|s| (*s).to_owned()).collect()
43}
44
45impl Default for Policy {
46    fn default() -> Self {
47        Self {
48            neutral: Grants {
49                mailbox: false,
50                rooms: list(&["lobby"]),
51                capabilities: list(&["heartbeat"]),
52            },
53            member: Grants {
54                mailbox: true,
55                rooms: list(&["lobby", "general"]),
56                capabilities: list(&["heartbeat", "attach", "mail", "m4a"]),
57            },
58            trusted: Grants {
59                mailbox: true,
60                rooms: list(&["lobby", "general", "ops"]),
61                capabilities: list(&["heartbeat", "attach", "mail", "m4a", "tasks", "tools", "build"]),
62            },
63        }
64    }
65}
66
67impl Policy {
68    pub fn from_json(text: &str) -> Result<Self, PolicyError> {
69        let policy: Self =
70            serde_json::from_str(text).map_err(|e| PolicyError::Parse(e.to_string()))?;
71        policy.validate()?;
72        Ok(policy)
73    }
74
75    pub fn load(path: &std::path::Path) -> Result<Self, PolicyError> {
76        let text = std::fs::read_to_string(path).map_err(|e| PolicyError::Parse(e.to_string()))?;
77        Self::from_json(&text)
78    }
79
80    pub fn validate(&self) -> Result<(), PolicyError> {
81        for grants in [&self.neutral, &self.member, &self.trusted] {
82            for cap in &grants.capabilities {
83                if !KNOWN_CAPABILITIES.contains(&cap.as_str()) {
84                    return Err(PolicyError::UnknownCapability(cap.clone()));
85                }
86            }
87        }
88        Ok(())
89    }
90
91    pub fn grants(&self, status: NodeStatus) -> Grants {
92        match status {
93            NodeStatus::Neutral => self.neutral.clone(),
94            NodeStatus::Member => self.member.clone(),
95            NodeStatus::Trusted => self.trusted.clone(),
96            NodeStatus::Suspended | NodeStatus::Revoked => Grants::default(),
97        }
98    }
99
100    pub fn allows(&self, status: NodeStatus, capability: &str) -> bool {
101        self.grants(status)
102            .capabilities
103            .iter()
104            .any(|c| c == capability)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn defaults_grow_with_status_and_out_statuses_are_empty() {
114        let p = Policy::default();
115        assert!(
116            p.allows(NodeStatus::Neutral, "heartbeat") && !p.allows(NodeStatus::Neutral, "attach")
117        );
118        assert!(p.allows(NodeStatus::Member, "attach") && !p.allows(NodeStatus::Member, "tasks"));
119        assert!(p.allows(NodeStatus::Trusted, "tasks") && p.allows(NodeStatus::Trusted, "tools"));
120        assert!(p.allows(NodeStatus::Trusted, "build") && !p.allows(NodeStatus::Member, "build"));
121        for s in [NodeStatus::Suspended, NodeStatus::Revoked] {
122            assert_eq!(p.grants(s), Grants::default());
123            assert!(!p.allows(s, "heartbeat"));
124        }
125    }
126
127    #[test]
128    fn file_overrides_rows_and_keeps_defaults_for_the_rest() {
129        let p = Policy::from_json(r#"{"member":{"mailbox":true,"rooms":["general"],"capabilities":["heartbeat","mail"]}}"#).unwrap();
130        assert!(!p.allows(NodeStatus::Member, "attach"));
131        assert_eq!(p.trusted, Policy::default().trusted);
132    }
133
134    #[test]
135    fn typos_and_unknown_capabilities_are_rejected() {
136        assert!(matches!(
137            Policy::from_json(r#"{"membr":{}}"#),
138            Err(PolicyError::Parse(_))
139        ));
140        assert_eq!(
141            Policy::from_json(r#"{"neutral":{"capabilities":["hearbeat"]}}"#),
142            Err(PolicyError::UnknownCapability("hearbeat".into()))
143        );
144        // suspended and revoked cannot be configured
145        assert!(matches!(
146            Policy::from_json(r#"{"revoked":{"capabilities":["attach"]}}"#),
147            Err(PolicyError::Parse(_))
148        ));
149    }
150}