Skip to main content

actr_runtime/
acl.rs

1//! ACL (Access Control List) permission checking
2//!
3//! Starting from the caller_id of an inbound message and the target actor_id,
4//! determines whether the call is permitted based on configured ACL rules.
5//! This module is pure functions with no IO dependencies, suitable for
6//! both native and wasm32 targets.
7
8use actr_protocol::{Acl, AclRule, ActrId};
9
10/// Check whether the caller has permission to access the target Actor
11///
12/// # Returns
13/// - `Ok(true)`: allowed
14/// - `Ok(false)`: denied
15/// - `Err(String)`: check error (should be treated as denied)
16///
17/// # Evaluation logic
18/// 1. No caller_id (local call) -- always allow
19/// 2. No ACL configured -- allow by default (backward compatibility)
20/// 3. ACL configured but rules list empty -- deny all (secure default)
21/// 4. Deny-first: any matching DENY rule immediately denies
22/// 5. At least one matching ALLOW rule -- allow
23/// 6. No rule matches -- deny
24pub fn check_acl_permission(
25    caller_id: Option<&ActrId>,
26    target_id: &ActrId,
27    acl: Option<&Acl>,
28) -> Result<bool, String> {
29    // 1. Local calls are always allowed
30    if caller_id.is_none() {
31        tracing::trace!("ACL: local call, allowing");
32        return Ok(true);
33    }
34
35    let caller = caller_id.unwrap();
36
37    // 2. No ACL configured -- allow by default
38    let acl = match acl {
39        Some(a) => a,
40        None => {
41            tracing::trace!(
42                "ACL: no ACL configured, allowing {} -> {}",
43                caller,
44                target_id,
45            );
46            return Ok(true);
47        }
48    };
49
50    // 3. Empty rules list -- deny all
51    if acl.rules.is_empty() {
52        tracing::warn!(
53            "ACL: empty rule set, denying {} -> {} (default deny)",
54            caller,
55            target_id,
56        );
57        return Ok(false);
58    }
59
60    // 4 & 5. Deny-first evaluation
61    let mut any_allow = false;
62    for rule in &acl.rules {
63        if !matches_rule(caller, rule) {
64            continue;
65        }
66        let is_allow = rule.permission == actr_protocol::acl_rule::Permission::Allow as i32;
67        if !is_allow {
68            tracing::debug!("ACL: DENY rule matched for {} -> {}", caller, target_id,);
69            return Ok(false);
70        }
71        any_allow = true;
72    }
73
74    if any_allow {
75        tracing::debug!("ACL: ALLOW rule matched for {} -> {}", caller, target_id,);
76        return Ok(true);
77    }
78
79    // 6. No rule matches -- deny
80    tracing::warn!(
81        "ACL: no matching rule, denying {} -> {} (default deny)",
82        caller,
83        target_id,
84    );
85    Ok(false)
86}
87
88/// Check whether a single ACL rule matches the given caller
89fn matches_rule(caller: &ActrId, rule: &AclRule) -> bool {
90    use actr_protocol::acl_rule::SourceRealm;
91
92    // Exact type match (manufacturer + name + version)
93    if caller.r#type != rule.from_type {
94        return false;
95    }
96
97    // Realm match
98    match &rule.source_realm {
99        None | Some(SourceRealm::AnyRealm(_)) => true,
100        Some(SourceRealm::RealmId(id)) => caller.realm.realm_id == *id,
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use actr_protocol::{ActrType, Realm, acl_rule::Permission, acl_rule::SourceRealm};
108
109    fn make_id(manufacturer: &str, name: &str, version: &str, realm_id: u32) -> ActrId {
110        ActrId {
111            serial_number: 0xaabb,
112            r#type: ActrType {
113                manufacturer: manufacturer.into(),
114                name: name.into(),
115                version: version.into(),
116            },
117            realm: Realm { realm_id },
118        }
119    }
120
121    fn make_rule(manufacturer: &str, name: &str, version: &str, perm: Permission) -> AclRule {
122        AclRule {
123            permission: perm as i32,
124            from_type: ActrType {
125                manufacturer: manufacturer.into(),
126                name: name.into(),
127                version: version.into(),
128            },
129            source_realm: None,
130        }
131    }
132
133    #[test]
134    fn local_call_always_allowed() {
135        let target = make_id("acme", "svc", "0.1.0", 1);
136        assert!(check_acl_permission(None, &target, None).unwrap());
137    }
138
139    #[test]
140    fn no_acl_allows_by_default() {
141        let caller = make_id("acme", "client", "0.1.0", 1);
142        let target = make_id("acme", "svc", "0.1.0", 1);
143        assert!(check_acl_permission(Some(&caller), &target, None).unwrap());
144    }
145
146    #[test]
147    fn empty_rules_denies() {
148        let caller = make_id("acme", "client", "0.1.0", 1);
149        let target = make_id("acme", "svc", "0.1.0", 1);
150        let acl = Acl { rules: vec![] };
151        assert!(!check_acl_permission(Some(&caller), &target, Some(&acl)).unwrap());
152    }
153
154    #[test]
155    fn deny_overrides_allow() {
156        let caller = make_id("acme", "client", "0.1.0", 1);
157        let target = make_id("acme", "svc", "0.1.0", 1);
158        let acl = Acl {
159            rules: vec![
160                make_rule("acme", "client", "0.1.0", Permission::Allow),
161                make_rule("acme", "client", "0.1.0", Permission::Deny),
162            ],
163        };
164        assert!(!check_acl_permission(Some(&caller), &target, Some(&acl)).unwrap());
165    }
166
167    #[test]
168    fn allow_when_matched() {
169        let caller = make_id("acme", "client", "0.1.0", 1);
170        let target = make_id("acme", "svc", "0.1.0", 1);
171        let acl = Acl {
172            rules: vec![make_rule("acme", "client", "0.1.0", Permission::Allow)],
173        };
174        assert!(check_acl_permission(Some(&caller), &target, Some(&acl)).unwrap());
175    }
176
177    #[test]
178    fn no_match_denies() {
179        let caller = make_id("acme", "client", "0.1.0", 1);
180        let target = make_id("acme", "svc", "0.1.0", 1);
181        let acl = Acl {
182            rules: vec![make_rule("other", "other", "0.1.0", Permission::Allow)],
183        };
184        assert!(!check_acl_permission(Some(&caller), &target, Some(&acl)).unwrap());
185    }
186
187    #[test]
188    fn any_realm_rule_matches_foreign_realm() {
189        let caller = make_id("acme", "client", "0.1.0", 2002);
190        let target = make_id("acme", "svc", "0.1.0", 1001);
191        let acl = Acl {
192            rules: vec![AclRule {
193                permission: Permission::Allow as i32,
194                from_type: ActrType {
195                    manufacturer: "acme".into(),
196                    name: "client".into(),
197                    version: "0.1.0".into(),
198                },
199                source_realm: Some(SourceRealm::AnyRealm(true)),
200            }],
201        };
202        assert!(check_acl_permission(Some(&caller), &target, Some(&acl)).unwrap());
203    }
204}