Skip to main content

bext_realtime/
auth.rs

1//! Topic-level authorization policies and rules for the realtime pub/sub hub.
2//!
3//! Defines [`Policy`] variants (public, authenticated, role-based, user-specific)
4//! and [`AuthRule`]s that map topic patterns to subscribe/publish policies.
5
6use serde::{Deserialize, Serialize};
7
8use crate::topic::TopicMatcher;
9
10/// Authorization policy for a topic action (subscribe or publish).
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(tag = "type", content = "value", rename_all = "snake_case")]
13pub enum Policy {
14    /// Anyone can perform the action.
15    Public,
16    /// Only authenticated users.
17    Authenticated,
18    /// Only users with a specific role.
19    Role(String),
20    /// Only a specific user.
21    UserId(String),
22}
23
24/// A rule mapping a topic pattern to subscribe/publish policies.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AuthRule {
27    /// Topic pattern (supports wildcards `*` and `#`).
28    pub pattern: String,
29    /// Policy for subscribing to this topic.
30    pub subscribe_policy: Policy,
31    /// Policy for publishing to this topic.
32    pub publish_policy: Policy,
33}
34
35/// Caller-provided identity context for authorization checks.
36#[derive(Debug, Clone, Default)]
37pub struct AuthContext {
38    /// The user's unique ID, if authenticated.
39    pub user_id: Option<String>,
40    /// Roles the user holds.
41    pub roles: Vec<String>,
42    /// Whether the user is authenticated at all.
43    pub is_authenticated: bool,
44}
45
46impl AuthContext {
47    /// Create an unauthenticated context.
48    pub fn anonymous() -> Self {
49        Self::default()
50    }
51
52    /// Create an authenticated context with the given user ID and roles.
53    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/// Topic authorization engine.
63///
64/// Holds a set of rules that map topic patterns to access policies.
65/// When checking access, the *first matching rule* wins. If no rule matches,
66/// a configurable default policy applies (defaults to `Public` for subscribe,
67/// `Authenticated` for publish).
68#[derive(Debug, Clone)]
69pub struct TopicAuth {
70    rules: Vec<AuthRule>,
71    default_subscribe: Policy,
72    default_publish: Policy,
73}
74
75impl TopicAuth {
76    /// Create with default rules for the standard topic namespaces.
77    ///
78    /// Default rules:
79    /// - `system/#` — subscribe: Authenticated, publish: never (internal only)
80    /// - `plugin/#` — subscribe: Public, publish: Authenticated
81    /// - `custom/#` — subscribe: Public, publish: Authenticated
82    pub fn with_defaults() -> Self {
83        Self {
84            rules: vec![
85                AuthRule {
86                    pattern: "system/#".to_string(),
87                    subscribe_policy: Policy::Authenticated,
88                    // We encode "internal only" by requiring a special role
89                    // that no external user should have.
90                    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    /// Create with no rules (everything falls through to defaults).
109    pub fn new() -> Self {
110        Self {
111            rules: Vec::new(),
112            default_subscribe: Policy::Public,
113            default_publish: Policy::Authenticated,
114        }
115    }
116
117    /// Create with custom rules.
118    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    /// Override the default subscribe policy.
127    pub fn set_default_subscribe(&mut self, policy: Policy) {
128        self.default_subscribe = policy;
129    }
130
131    /// Override the default publish policy.
132    pub fn set_default_publish(&mut self, policy: Policy) {
133        self.default_publish = policy;
134    }
135
136    /// Add a rule.
137    pub fn add_rule(&mut self, rule: AuthRule) {
138        self.rules.push(rule);
139    }
140
141    /// Check if the given context is allowed to subscribe to `topic`.
142    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    /// Check if the given context is allowed to publish to `topic`.
148    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    /// Return the list of rules.
154    pub fn rules(&self) -> &[AuthRule] {
155        &self.rules
156    }
157
158    // ── Private ─────────────────────────────────────────────────────
159
160    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    // ── Policy::Public ──────────────────────────────────────────────
215
216    #[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    // ── Policy::Authenticated ───────────────────────────────────────
242
243    #[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    // ── Policy::Role ────────────────────────────────────────────────
269
270    #[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    // ── Policy::UserId ──────────────────────────────────────────────
320
321    #[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    // ── Default rules ───────────────────────────────────────────────
359
360    #[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        // Even authenticated users can't publish to system topics
371        assert!(!auth.check_publish("system/deploy", &authenticated_user("u")));
372        assert!(!auth.check_publish("system/health", &user_with_role("u", "admin")));
373        // Only the special internal role can
374        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    // ── Fallthrough to defaults ─────────────────────────────────────
407
408    #[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    // ── First matching rule wins ────────────────────────────────────
422
423    #[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        // "data/secret" matches the first rule (admin only)
439        assert!(!auth.check_subscribe("data/secret", &anonymous()));
440        assert!(auth.check_subscribe("data/secret", &user_with_role("u", "admin")));
441
442        // "data/other" matches the second rule (public)
443        assert!(auth.check_subscribe("data/other", &anonymous()));
444    }
445
446    // ── Mixed subscribe/publish policies ────────────────────────────
447
448    #[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        // Anyone can subscribe
460        assert!(auth.check_subscribe("broadcast/news", &viewer));
461        assert!(auth.check_subscribe("broadcast/news", &broadcaster));
462
463        // Only broadcasters can publish
464        assert!(!auth.check_publish("broadcast/news", &viewer));
465        assert!(auth.check_publish("broadcast/news", &broadcaster));
466    }
467
468    // ── add_rule / set_defaults ─────────────────────────────────────
469
470    #[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    // ── Policy serialization ────────────────────────────────────────
501
502    #[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    // ── AuthContext constructors ─────────────────────────────────────
531
532    #[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}