1use alloc::string::{String, ToString};
18use alloc::vec::Vec;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Decision {
23 Permit,
25 #[default]
27 Deny,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Operation {
34 Publish,
36 Subscribe,
38 Admin,
40 Read,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Rule {
47 pub effect: Decision,
49 pub topic_glob: String,
52 pub operations: Vec<Operation>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct Permissions {
59 pub subject_name: String,
62 pub default: Decision,
64 pub rules: Vec<Rule>,
66}
67
68impl Permissions {
69 #[must_use]
76 pub fn evaluate(&self, op: Operation, topic: &str) -> Decision {
77 let mut found_permit = false;
78 for r in &self.rules {
79 if !r.operations.contains(&op) {
80 continue;
81 }
82 if !match_glob(&r.topic_glob, topic) {
83 continue;
84 }
85 match r.effect {
86 Decision::Deny => return Decision::Deny,
87 Decision::Permit => found_permit = true,
88 }
89 }
90 if found_permit {
91 Decision::Permit
92 } else {
93 self.default
94 }
95 }
96}
97
98fn match_glob(pattern: &str, topic: &str) -> bool {
101 if pattern == "*" {
102 return true;
103 }
104 if let Some(rest) = pattern.strip_prefix('*') {
105 return topic.ends_with(rest);
106 }
107 if let Some(rest) = pattern.strip_suffix('*') {
108 return topic.starts_with(rest);
109 }
110 pattern == topic
111}
112
113impl Rule {
114 #[must_use]
116 pub fn allow(topic_glob: &str, operations: Vec<Operation>) -> Self {
117 Self {
118 effect: Decision::Permit,
119 topic_glob: topic_glob.to_string(),
120 operations,
121 }
122 }
123
124 #[must_use]
126 pub fn deny(topic_glob: &str, operations: Vec<Operation>) -> Self {
127 Self {
128 effect: Decision::Deny,
129 topic_glob: topic_glob.to_string(),
130 operations,
131 }
132 }
133}
134
135#[cfg(test)]
136#[allow(clippy::expect_used)]
137mod tests {
138 use super::*;
139
140 fn ops_all() -> Vec<Operation> {
141 alloc::vec![
142 Operation::Publish,
143 Operation::Subscribe,
144 Operation::Admin,
145 Operation::Read,
146 ]
147 }
148
149 #[test]
150 fn empty_permissions_returns_default_deny() {
151 let p = Permissions {
152 subject_name: "Alice".to_string(),
153 default: Decision::Deny,
154 rules: Vec::new(),
155 };
156 assert_eq!(p.evaluate(Operation::Publish, "Sensor"), Decision::Deny);
157 }
158
159 #[test]
160 fn allow_rule_grants_permit() {
161 let p = Permissions {
162 subject_name: "Alice".to_string(),
163 default: Decision::Deny,
164 rules: alloc::vec![Rule::allow("Sensor", alloc::vec![Operation::Publish])],
165 };
166 assert_eq!(p.evaluate(Operation::Publish, "Sensor"), Decision::Permit);
167 }
168
169 #[test]
170 fn deny_rule_overrides_allow_when_listed_first() {
171 let p = Permissions {
172 subject_name: "Alice".to_string(),
173 default: Decision::Permit,
174 rules: alloc::vec![
175 Rule::deny("SecretTopic", alloc::vec![Operation::Subscribe]),
176 Rule::allow("*", ops_all()),
177 ],
178 };
179 assert_eq!(
180 p.evaluate(Operation::Subscribe, "SecretTopic"),
181 Decision::Deny
182 );
183 assert_eq!(
184 p.evaluate(Operation::Subscribe, "PublicTopic"),
185 Decision::Permit
186 );
187 }
188
189 #[test]
190 fn glob_prefix_pattern_matches() {
191 let p = Permissions {
192 subject_name: "Alice".to_string(),
193 default: Decision::Deny,
194 rules: alloc::vec![Rule::allow("Sensor*", alloc::vec![Operation::Subscribe])],
195 };
196 assert_eq!(
197 p.evaluate(Operation::Subscribe, "SensorTemperature"),
198 Decision::Permit
199 );
200 assert_eq!(p.evaluate(Operation::Subscribe, "Other"), Decision::Deny);
201 }
202
203 #[test]
204 fn glob_suffix_pattern_matches() {
205 let p = Permissions {
206 subject_name: "Alice".to_string(),
207 default: Decision::Deny,
208 rules: alloc::vec![Rule::allow("*Result", alloc::vec![Operation::Read])],
209 };
210 assert_eq!(
211 p.evaluate(Operation::Read, "TrackingResult"),
212 Decision::Permit
213 );
214 assert_eq!(p.evaluate(Operation::Read, "Other"), Decision::Deny);
215 }
216
217 #[test]
218 fn operation_mismatch_falls_through_to_default() {
219 let p = Permissions {
220 subject_name: "Alice".to_string(),
221 default: Decision::Deny,
222 rules: alloc::vec![Rule::allow("*", alloc::vec![Operation::Publish])],
223 };
224 assert_eq!(p.evaluate(Operation::Subscribe, "Anything"), Decision::Deny);
225 }
226
227 #[test]
228 fn first_matching_deny_wins_over_later_allow() {
229 let p = Permissions {
230 subject_name: "Alice".to_string(),
231 default: Decision::Permit,
232 rules: alloc::vec![
233 Rule::deny("Restricted", alloc::vec![Operation::Admin]),
234 Rule::allow("*", alloc::vec![Operation::Admin]),
235 ],
236 };
237 assert_eq!(p.evaluate(Operation::Admin, "Restricted"), Decision::Deny);
238 assert_eq!(p.evaluate(Operation::Admin, "Other"), Decision::Permit);
239 }
240}