Skip to main content

bob_adapters/
access_control.rs

1//! Static access control adapter based on per-channel allowlists.
2
3use bob_core::{
4    ports::AccessControlPort,
5    types::{AccessDecision, ChannelAccessPolicy},
6};
7
8/// Static [`AccessControlPort`] backed by a list of [`ChannelAccessPolicy`].
9///
10/// Empty `allow_from` on a policy means "allow all" (default open).
11/// Unknown channels fall back to the default policy (allow all).
12#[derive(Debug, Clone, Default)]
13pub struct StaticAccessControl {
14    policies: Vec<ChannelAccessPolicy>,
15}
16
17impl StaticAccessControl {
18    /// Create a new instance from a list of channel policies.
19    #[must_use]
20    pub fn new(policies: Vec<ChannelAccessPolicy>) -> Self {
21        Self { policies }
22    }
23}
24
25impl AccessControlPort for StaticAccessControl {
26    fn check_access(&self, channel: &str, sender_id: &str) -> AccessDecision {
27        match self.policies.iter().find(|p| p.channel == channel) {
28            Some(policy) if !policy.allow_from.is_empty() => {
29                if policy.allow_from.iter().any(|id| id == sender_id) {
30                    AccessDecision::Allow
31                } else {
32                    AccessDecision::Deny
33                }
34            }
35            // Known channel with empty allow_from, or unknown channel: allow all.
36            _ => AccessDecision::Allow,
37        }
38    }
39
40    fn policies(&self) -> &[ChannelAccessPolicy] {
41        &self.policies
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn allowed_sender_passes() {
51        let ac = StaticAccessControl::new(vec![ChannelAccessPolicy {
52            channel: "telegram".into(),
53            allow_from: vec!["alice".into(), "bob".into()],
54        }]);
55        assert_eq!(ac.check_access("telegram", "alice"), AccessDecision::Allow);
56        assert_eq!(ac.check_access("telegram", "bob"), AccessDecision::Allow);
57    }
58
59    #[test]
60    fn denied_sender_blocked() {
61        let ac = StaticAccessControl::new(vec![ChannelAccessPolicy {
62            channel: "telegram".into(),
63            allow_from: vec!["alice".into()],
64        }]);
65        assert_eq!(ac.check_access("telegram", "eve"), AccessDecision::Deny);
66    }
67
68    #[test]
69    fn empty_allow_from_allows_all() {
70        let ac = StaticAccessControl::new(vec![ChannelAccessPolicy {
71            channel: "cli".into(),
72            allow_from: vec![],
73        }]);
74        assert_eq!(ac.check_access("cli", "anyone"), AccessDecision::Allow);
75    }
76
77    #[test]
78    fn unknown_channel_allows_all() {
79        let ac = StaticAccessControl::new(vec![ChannelAccessPolicy {
80            channel: "telegram".into(),
81            allow_from: vec!["alice".into()],
82        }]);
83        assert_eq!(ac.check_access("discord", "random"), AccessDecision::Allow);
84    }
85
86    #[test]
87    fn multiple_channels_different_policies() {
88        let ac = StaticAccessControl::new(vec![
89            ChannelAccessPolicy { channel: "telegram".into(), allow_from: vec!["alice".into()] },
90            ChannelAccessPolicy {
91                channel: "discord".into(),
92                allow_from: vec!["bob".into(), "carol".into()],
93            },
94        ]);
95        assert_eq!(ac.check_access("telegram", "alice"), AccessDecision::Allow);
96        assert_eq!(ac.check_access("telegram", "bob"), AccessDecision::Deny);
97        assert_eq!(ac.check_access("discord", "bob"), AccessDecision::Allow);
98        assert_eq!(ac.check_access("discord", "carol"), AccessDecision::Allow);
99        assert_eq!(ac.check_access("discord", "alice"), AccessDecision::Deny);
100    }
101
102    #[test]
103    fn no_policies_allows_everything() {
104        let ac = StaticAccessControl::default();
105        assert_eq!(ac.check_access("anything", "anyone"), AccessDecision::Allow);
106        assert_eq!(ac.policies().len(), 0);
107    }
108
109    #[test]
110    fn policies_accessor_returns_configured_list() {
111        let policies = vec![ChannelAccessPolicy {
112            channel: "telegram".into(),
113            allow_from: vec!["alice".into()],
114        }];
115        let ac = StaticAccessControl::new(policies.clone());
116        assert_eq!(ac.policies().len(), 1);
117        assert_eq!(ac.policies()[0].channel, "telegram");
118    }
119}