1use serde::{Deserialize, Serialize};
7
8use crate::topic::TopicMatcher;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", content = "value", rename_all = "snake_case")]
13pub enum Policy {
14 Public,
16 Authenticated,
18 Role(String),
20 UserId(String),
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AuthRule {
27 pub pattern: String,
29 pub subscribe_policy: Policy,
31 pub publish_policy: Policy,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct AuthContext {
38 pub user_id: Option<String>,
40 pub roles: Vec<String>,
42 pub is_authenticated: bool,
44}
45
46impl AuthContext {
47 pub fn anonymous() -> Self {
49 Self::default()
50 }
51
52 pub fn authenticated(user_id: impl Into<String>, roles: Vec<String>) -> Self {
54 Self {
55 user_id: Some(user_id.into()),
56 roles,
57 is_authenticated: true,
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
69pub struct TopicAuth {
70 rules: Vec<AuthRule>,
71 default_subscribe: Policy,
72 default_publish: Policy,
73}
74
75impl TopicAuth {
76 pub fn with_defaults() -> Self {
83 Self {
84 rules: vec![
85 AuthRule {
86 pattern: "system/#".to_string(),
87 subscribe_policy: Policy::Authenticated,
88 publish_policy: Policy::Role("__system_internal__".to_string()),
91 },
92 AuthRule {
93 pattern: "plugin/#".to_string(),
94 subscribe_policy: Policy::Public,
95 publish_policy: Policy::Authenticated,
96 },
97 AuthRule {
98 pattern: "custom/#".to_string(),
99 subscribe_policy: Policy::Public,
100 publish_policy: Policy::Authenticated,
101 },
102 ],
103 default_subscribe: Policy::Public,
104 default_publish: Policy::Authenticated,
105 }
106 }
107
108 pub fn new() -> Self {
110 Self {
111 rules: Vec::new(),
112 default_subscribe: Policy::Public,
113 default_publish: Policy::Authenticated,
114 }
115 }
116
117 pub fn with_rules(rules: Vec<AuthRule>) -> Self {
119 Self {
120 rules,
121 default_subscribe: Policy::Public,
122 default_publish: Policy::Authenticated,
123 }
124 }
125
126 pub fn set_default_subscribe(&mut self, policy: Policy) {
128 self.default_subscribe = policy;
129 }
130
131 pub fn set_default_publish(&mut self, policy: Policy) {
133 self.default_publish = policy;
134 }
135
136 pub fn add_rule(&mut self, rule: AuthRule) {
138 self.rules.push(rule);
139 }
140
141 pub fn check_subscribe(&self, topic: &str, ctx: &AuthContext) -> bool {
143 let policy = self.find_subscribe_policy(topic);
144 Self::evaluate(policy, ctx)
145 }
146
147 pub fn check_publish(&self, topic: &str, ctx: &AuthContext) -> bool {
149 let policy = self.find_publish_policy(topic);
150 Self::evaluate(policy, ctx)
151 }
152
153 pub fn rules(&self) -> &[AuthRule] {
155 &self.rules
156 }
157
158 fn find_subscribe_policy(&self, topic: &str) -> &Policy {
161 for rule in &self.rules {
162 if TopicMatcher::matches(&rule.pattern, topic) {
163 return &rule.subscribe_policy;
164 }
165 }
166 &self.default_subscribe
167 }
168
169 fn find_publish_policy(&self, topic: &str) -> &Policy {
170 for rule in &self.rules {
171 if TopicMatcher::matches(&rule.pattern, topic) {
172 return &rule.publish_policy;
173 }
174 }
175 &self.default_publish
176 }
177
178 fn evaluate(policy: &Policy, ctx: &AuthContext) -> bool {
179 match policy {
180 Policy::Public => true,
181 Policy::Authenticated => ctx.is_authenticated,
182 Policy::Role(required_role) => ctx.roles.contains(required_role),
183 Policy::UserId(required_id) => ctx.user_id.as_deref() == Some(required_id.as_str()),
184 }
185 }
186}
187
188impl Default for TopicAuth {
189 fn default() -> Self {
190 Self::with_defaults()
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn anonymous() -> AuthContext {
199 AuthContext::anonymous()
200 }
201
202 fn authenticated_user(user_id: &str) -> AuthContext {
203 AuthContext::authenticated(user_id, vec![])
204 }
205
206 fn user_with_role(user_id: &str, role: &str) -> AuthContext {
207 AuthContext::authenticated(user_id, vec![role.to_string()])
208 }
209
210 fn user_with_roles(user_id: &str, roles: Vec<&str>) -> AuthContext {
211 AuthContext::authenticated(user_id, roles.into_iter().map(String::from).collect())
212 }
213
214 #[test]
217 fn public_allows_anonymous() {
218 let auth = TopicAuth::with_rules(vec![AuthRule {
219 pattern: "test/#".to_string(),
220 subscribe_policy: Policy::Public,
221 publish_policy: Policy::Public,
222 }]);
223
224 assert!(auth.check_subscribe("test/foo", &anonymous()));
225 assert!(auth.check_publish("test/foo", &anonymous()));
226 }
227
228 #[test]
229 fn public_allows_authenticated() {
230 let auth = TopicAuth::with_rules(vec![AuthRule {
231 pattern: "test/#".to_string(),
232 subscribe_policy: Policy::Public,
233 publish_policy: Policy::Public,
234 }]);
235
236 let ctx = authenticated_user("alice");
237 assert!(auth.check_subscribe("test/foo", &ctx));
238 assert!(auth.check_publish("test/foo", &ctx));
239 }
240
241 #[test]
244 fn authenticated_denies_anonymous() {
245 let auth = TopicAuth::with_rules(vec![AuthRule {
246 pattern: "test/#".to_string(),
247 subscribe_policy: Policy::Authenticated,
248 publish_policy: Policy::Authenticated,
249 }]);
250
251 assert!(!auth.check_subscribe("test/foo", &anonymous()));
252 assert!(!auth.check_publish("test/foo", &anonymous()));
253 }
254
255 #[test]
256 fn authenticated_allows_logged_in() {
257 let auth = TopicAuth::with_rules(vec![AuthRule {
258 pattern: "test/#".to_string(),
259 subscribe_policy: Policy::Authenticated,
260 publish_policy: Policy::Authenticated,
261 }]);
262
263 let ctx = authenticated_user("bob");
264 assert!(auth.check_subscribe("test/foo", &ctx));
265 assert!(auth.check_publish("test/foo", &ctx));
266 }
267
268 #[test]
271 fn role_denies_wrong_role() {
272 let auth = TopicAuth::with_rules(vec![AuthRule {
273 pattern: "admin/#".to_string(),
274 subscribe_policy: Policy::Role("admin".to_string()),
275 publish_policy: Policy::Role("admin".to_string()),
276 }]);
277
278 let ctx = user_with_role("alice", "viewer");
279 assert!(!auth.check_subscribe("admin/settings", &ctx));
280 assert!(!auth.check_publish("admin/settings", &ctx));
281 }
282
283 #[test]
284 fn role_allows_correct_role() {
285 let auth = TopicAuth::with_rules(vec![AuthRule {
286 pattern: "admin/#".to_string(),
287 subscribe_policy: Policy::Role("admin".to_string()),
288 publish_policy: Policy::Role("admin".to_string()),
289 }]);
290
291 let ctx = user_with_role("alice", "admin");
292 assert!(auth.check_subscribe("admin/settings", &ctx));
293 assert!(auth.check_publish("admin/settings", &ctx));
294 }
295
296 #[test]
297 fn role_denies_anonymous() {
298 let auth = TopicAuth::with_rules(vec![AuthRule {
299 pattern: "admin/#".to_string(),
300 subscribe_policy: Policy::Role("admin".to_string()),
301 publish_policy: Policy::Role("admin".to_string()),
302 }]);
303
304 assert!(!auth.check_subscribe("admin/settings", &anonymous()));
305 }
306
307 #[test]
308 fn role_check_with_multiple_roles() {
309 let auth = TopicAuth::with_rules(vec![AuthRule {
310 pattern: "ops/#".to_string(),
311 subscribe_policy: Policy::Role("operator".to_string()),
312 publish_policy: Policy::Role("operator".to_string()),
313 }]);
314
315 let ctx = user_with_roles("bob", vec!["viewer", "operator"]);
316 assert!(auth.check_subscribe("ops/deploy", &ctx));
317 }
318
319 #[test]
322 fn userid_allows_matching_user() {
323 let auth = TopicAuth::with_rules(vec![AuthRule {
324 pattern: "user/alice/#".to_string(),
325 subscribe_policy: Policy::UserId("alice".to_string()),
326 publish_policy: Policy::UserId("alice".to_string()),
327 }]);
328
329 let ctx = authenticated_user("alice");
330 assert!(auth.check_subscribe("user/alice/inbox", &ctx));
331 assert!(auth.check_publish("user/alice/inbox", &ctx));
332 }
333
334 #[test]
335 fn userid_denies_different_user() {
336 let auth = TopicAuth::with_rules(vec![AuthRule {
337 pattern: "user/alice/#".to_string(),
338 subscribe_policy: Policy::UserId("alice".to_string()),
339 publish_policy: Policy::UserId("alice".to_string()),
340 }]);
341
342 let ctx = authenticated_user("bob");
343 assert!(!auth.check_subscribe("user/alice/inbox", &ctx));
344 assert!(!auth.check_publish("user/alice/inbox", &ctx));
345 }
346
347 #[test]
348 fn userid_denies_anonymous() {
349 let auth = TopicAuth::with_rules(vec![AuthRule {
350 pattern: "user/alice/#".to_string(),
351 subscribe_policy: Policy::UserId("alice".to_string()),
352 publish_policy: Policy::UserId("alice".to_string()),
353 }]);
354
355 assert!(!auth.check_subscribe("user/alice/inbox", &anonymous()));
356 }
357
358 #[test]
361 fn default_system_subscribe_requires_auth() {
362 let auth = TopicAuth::with_defaults();
363 assert!(!auth.check_subscribe("system/deploy", &anonymous()));
364 assert!(auth.check_subscribe("system/deploy", &authenticated_user("u")));
365 }
366
367 #[test]
368 fn default_system_publish_internal_only() {
369 let auth = TopicAuth::with_defaults();
370 assert!(!auth.check_publish("system/deploy", &authenticated_user("u")));
372 assert!(!auth.check_publish("system/health", &user_with_role("u", "admin")));
373 assert!(auth.check_publish(
375 "system/deploy",
376 &user_with_role("internal", "__system_internal__")
377 ));
378 }
379
380 #[test]
381 fn default_plugin_subscribe_public() {
382 let auth = TopicAuth::with_defaults();
383 assert!(auth.check_subscribe("plugin/analytics/events", &anonymous()));
384 }
385
386 #[test]
387 fn default_plugin_publish_requires_auth() {
388 let auth = TopicAuth::with_defaults();
389 assert!(!auth.check_publish("plugin/analytics/events", &anonymous()));
390 assert!(auth.check_publish("plugin/analytics/events", &authenticated_user("u")));
391 }
392
393 #[test]
394 fn default_custom_subscribe_public() {
395 let auth = TopicAuth::with_defaults();
396 assert!(auth.check_subscribe("custom/chat", &anonymous()));
397 }
398
399 #[test]
400 fn default_custom_publish_requires_auth() {
401 let auth = TopicAuth::with_defaults();
402 assert!(!auth.check_publish("custom/chat", &anonymous()));
403 assert!(auth.check_publish("custom/chat", &authenticated_user("u")));
404 }
405
406 #[test]
409 fn unknown_topic_uses_default_subscribe_public() {
410 let auth = TopicAuth::with_defaults();
411 assert!(auth.check_subscribe("unknown/topic", &anonymous()));
412 }
413
414 #[test]
415 fn unknown_topic_uses_default_publish_authenticated() {
416 let auth = TopicAuth::with_defaults();
417 assert!(!auth.check_publish("unknown/topic", &anonymous()));
418 assert!(auth.check_publish("unknown/topic", &authenticated_user("u")));
419 }
420
421 #[test]
424 fn first_matching_rule_wins() {
425 let auth = TopicAuth::with_rules(vec![
426 AuthRule {
427 pattern: "data/secret".to_string(),
428 subscribe_policy: Policy::Role("admin".to_string()),
429 publish_policy: Policy::Role("admin".to_string()),
430 },
431 AuthRule {
432 pattern: "data/#".to_string(),
433 subscribe_policy: Policy::Public,
434 publish_policy: Policy::Public,
435 },
436 ]);
437
438 assert!(!auth.check_subscribe("data/secret", &anonymous()));
440 assert!(auth.check_subscribe("data/secret", &user_with_role("u", "admin")));
441
442 assert!(auth.check_subscribe("data/other", &anonymous()));
444 }
445
446 #[test]
449 fn asymmetric_policies() {
450 let auth = TopicAuth::with_rules(vec![AuthRule {
451 pattern: "broadcast/#".to_string(),
452 subscribe_policy: Policy::Public,
453 publish_policy: Policy::Role("broadcaster".to_string()),
454 }]);
455
456 let viewer = anonymous();
457 let broadcaster = user_with_role("alice", "broadcaster");
458
459 assert!(auth.check_subscribe("broadcast/news", &viewer));
461 assert!(auth.check_subscribe("broadcast/news", &broadcaster));
462
463 assert!(!auth.check_publish("broadcast/news", &viewer));
465 assert!(auth.check_publish("broadcast/news", &broadcaster));
466 }
467
468 #[test]
471 fn add_rule_extends_rules() {
472 let mut auth = TopicAuth::new();
473 auth.add_rule(AuthRule {
474 pattern: "secret/#".to_string(),
475 subscribe_policy: Policy::Authenticated,
476 publish_policy: Policy::Authenticated,
477 });
478
479 assert!(!auth.check_subscribe("secret/data", &anonymous()));
480 assert!(auth.check_subscribe("secret/data", &authenticated_user("u")));
481 }
482
483 #[test]
484 fn set_default_subscribe_changes_fallthrough() {
485 let mut auth = TopicAuth::new();
486 auth.set_default_subscribe(Policy::Authenticated);
487
488 assert!(!auth.check_subscribe("anything", &anonymous()));
489 assert!(auth.check_subscribe("anything", &authenticated_user("u")));
490 }
491
492 #[test]
493 fn set_default_publish_changes_fallthrough() {
494 let mut auth = TopicAuth::new();
495 auth.set_default_publish(Policy::Public);
496
497 assert!(auth.check_publish("anything", &anonymous()));
498 }
499
500 #[test]
503 fn policy_roundtrip() {
504 let policies = vec![
505 Policy::Public,
506 Policy::Authenticated,
507 Policy::Role("admin".to_string()),
508 Policy::UserId("alice".to_string()),
509 ];
510
511 for p in policies {
512 let json_str = serde_json::to_string(&p).unwrap();
513 let deserialized: Policy = serde_json::from_str(&json_str).unwrap();
514 assert_eq!(p, deserialized);
515 }
516 }
517
518 #[test]
519 fn auth_rule_roundtrip() {
520 let rule = AuthRule {
521 pattern: "test/#".to_string(),
522 subscribe_policy: Policy::Public,
523 publish_policy: Policy::Role("admin".to_string()),
524 };
525 let json_str = serde_json::to_string(&rule).unwrap();
526 let deserialized: AuthRule = serde_json::from_str(&json_str).unwrap();
527 assert_eq!(rule.pattern, deserialized.pattern);
528 }
529
530 #[test]
533 fn anonymous_context() {
534 let ctx = AuthContext::anonymous();
535 assert!(ctx.user_id.is_none());
536 assert!(ctx.roles.is_empty());
537 assert!(!ctx.is_authenticated);
538 }
539
540 #[test]
541 fn authenticated_context() {
542 let ctx = AuthContext::authenticated("alice", vec!["admin".to_string()]);
543 assert_eq!(ctx.user_id, Some("alice".to_string()));
544 assert_eq!(ctx.roles, vec!["admin"]);
545 assert!(ctx.is_authenticated);
546 }
547}