1use serde::{Deserialize, Serialize};
9
10use crate::NodeStatus;
11
12pub 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 assert!(matches!(
146 Policy::from_json(r#"{"revoked":{"capabilities":["attach"]}}"#),
147 Err(PolicyError::Parse(_))
148 ));
149 }
150}