Skip to main content

meerkat_mobkit/access/
engine.rs

1//! Pure ABAC evaluation. No locks, no IO — config in, decision out.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use super::model::{AccessControlConfig, AccessEffect, AccessRule, action_pattern_matches};
6
7/// The authenticated caller's attributes.
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct AccessPrincipal {
10    /// Authenticated subject (email or token `sub`). `None` means the
11    /// request was admitted without app auth (open console); such callers
12    /// only match rules with no subject/group constraints.
13    pub subject: Option<String>,
14    /// Resolved group memberships for the subject.
15    pub groups: BTreeSet<String>,
16}
17
18impl AccessPrincipal {
19    pub fn anonymous() -> Self {
20        Self::default()
21    }
22}
23
24/// Attributes of the resource a check targets. All fields are optional:
25/// checks for non-agent actions (e.g. `gating.decide`) carry no resource
26/// at all and only match rules without resource selectors.
27#[derive(Debug, Clone, Copy, Default)]
28pub struct AccessResource<'a> {
29    /// Agent identity (preferred key, e.g. `identity:ops-lead`).
30    pub identity: Option<&'a str>,
31    /// Runtime agent/member id, matched against rule `agents` as a fallback.
32    pub agent_id: Option<&'a str>,
33    /// Agent role/profile name.
34    pub role: Option<&'a str>,
35    /// Agent labels. `None` means the attributes are unknown (label
36    /// selectors cannot match); `Some` empty means known-empty.
37    pub labels: Option<&'a BTreeMap<String, String>>,
38}
39
40impl<'a> AccessResource<'a> {
41    pub fn none() -> Self {
42        Self::default()
43    }
44
45    pub fn for_identity(identity: &'a str) -> Self {
46        Self {
47            identity: Some(identity),
48            ..Self::default()
49        }
50    }
51
52    fn is_present(&self) -> bool {
53        self.identity.is_some() || self.agent_id.is_some()
54    }
55}
56
57/// Outcome of one access check.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum AccessDecision {
60    Allow,
61    Deny { reason: String },
62}
63
64impl AccessDecision {
65    pub fn is_allow(&self) -> bool {
66        matches!(self, Self::Allow)
67    }
68
69    pub fn reason(&self) -> Option<&str> {
70        match self {
71            Self::Allow => None,
72            Self::Deny { reason } => Some(reason),
73        }
74    }
75}
76
77fn action_matches(pattern: &str, action: &str) -> bool {
78    action_pattern_matches(pattern, action)
79}
80
81fn subject_matches(rule: &AccessRule, principal: &AccessPrincipal) -> bool {
82    if rule.subjects.is_empty() && rule.groups.is_empty() {
83        return true;
84    }
85    // `subjects: ["*"]` matches any *authenticated* subject. It does NOT
86    // match the anonymous principal: an anonymous caller only matches rules
87    // with no subject/group constraints at all (see `AccessPrincipal.subject`
88    // and the resource selectors, where `"*"` likewise never matches an
89    // absent attribute). This keeps `subjects:["*"]` from silently granting
90    // unauthenticated callers on an open console.
91    if let Some(subject) = principal.subject.as_deref()
92        && rule
93            .subjects
94            .iter()
95            .any(|candidate| candidate == "*" || candidate == subject)
96    {
97        return true;
98    }
99    rule.groups
100        .iter()
101        .any(|group| principal.groups.contains(group))
102}
103
104fn resource_matches(rule: &AccessRule, resource: &AccessResource<'_>) -> bool {
105    if !rule.has_resource_selector() {
106        return true;
107    }
108    // A constrained rule can never match a check that has no resource.
109    if !resource.is_present() {
110        return false;
111    }
112    if !rule.agents.is_empty() {
113        let agent_listed = rule.agents.iter().any(|candidate| {
114            candidate == "*"
115                || resource
116                    .identity
117                    .is_some_and(|identity| identity == candidate)
118                || resource
119                    .agent_id
120                    .is_some_and(|agent_id| agent_id == candidate)
121        });
122        if !agent_listed {
123            return false;
124        }
125    }
126    if !rule.roles.is_empty() {
127        let role_listed = resource.role.is_some_and(|role| {
128            rule.roles
129                .iter()
130                .any(|candidate| candidate == "*" || candidate == role)
131        });
132        if !role_listed {
133            return false;
134        }
135    }
136    if !rule.match_labels.is_empty() {
137        let Some(labels) = resource.labels else {
138            // Unknown attributes fail label selectors closed.
139            return false;
140        };
141        let all_labels_match = rule
142            .match_labels
143            .iter()
144            .all(|(key, value)| labels.get(key) == Some(value));
145        if !all_labels_match {
146            return false;
147        }
148    }
149    true
150}
151
152/// Evaluate one access check against a configuration.
153///
154/// Deny-by-default with deny-overrides: a matching deny rule always wins,
155/// then a matching allow rule allows, otherwise the check denies. Admin
156/// subjects bypass rules entirely; a disabled config allows everything.
157pub fn evaluate_access(
158    config: &AccessControlConfig,
159    principal: &AccessPrincipal,
160    action: &str,
161    resource: &AccessResource<'_>,
162) -> AccessDecision {
163    evaluate_access_lineage(config, principal, action, std::slice::from_ref(resource))
164}
165
166/// Evaluate one access check against an agent and its spawn lineage.
167///
168/// `resources` is the agent followed by its spawn ancestors (parent,
169/// grandparent, ...). A rule applies when it matches *any* resource in the
170/// chain, so permissions granted on a spawning agent extend to the members
171/// it spawned — and a deny anywhere in the lineage denies the descendant
172/// (deny-overrides is preserved across the chain).
173pub(crate) fn evaluate_access_lineage(
174    config: &AccessControlConfig,
175    principal: &AccessPrincipal,
176    action: &str,
177    resources: &[AccessResource<'_>],
178) -> AccessDecision {
179    if !config.enabled {
180        return AccessDecision::Allow;
181    }
182    if let Some(subject) = principal.subject.as_deref()
183        && config.admins.iter().any(|admin| admin == subject)
184    {
185        return AccessDecision::Allow;
186    }
187
188    let mut allowed = false;
189    for rule in &config.rules {
190        let matches = rule
191            .actions
192            .iter()
193            .any(|pattern| action_matches(pattern, action))
194            && subject_matches(rule, principal)
195            && resources
196                .iter()
197                .any(|resource| resource_matches(rule, resource));
198        if !matches {
199            continue;
200        }
201        match rule.effect {
202            AccessEffect::Deny => {
203                return AccessDecision::Deny {
204                    reason: format!("denied by rule {}", rule.id),
205                };
206            }
207            AccessEffect::Allow => allowed = true,
208        }
209    }
210    if allowed {
211        AccessDecision::Allow
212    } else {
213        AccessDecision::Deny {
214            reason: format!("no rule allows {action}"),
215        }
216    }
217}
218
219/// True when the principal could perform `action` against at least one
220/// resource — i.e. some allow rule matches the principal (by subject/group)
221/// and names the action. Ignores resource selectors and deny rules, so it is
222/// a coarse "is this affordance available at all" signal for capability
223/// advertisement; per-resource checks (including deny-overrides) still apply
224/// at call time. Always true when enforcement is disabled.
225pub(crate) fn principal_may_perform(
226    config: &AccessControlConfig,
227    principal: &AccessPrincipal,
228    action: &str,
229) -> bool {
230    if !config.enabled {
231        return true;
232    }
233    config.rules.iter().any(|rule| {
234        matches!(rule.effect, AccessEffect::Allow)
235            && rule
236                .actions
237                .iter()
238                .any(|pattern| action_matches(pattern, action))
239            && subject_matches(rule, principal)
240    })
241}
242
243/// Resolve the configured group memberships for a subject.
244pub(crate) fn groups_for_subject(config: &AccessControlConfig, subject: &str) -> BTreeSet<String> {
245    config
246        .groups
247        .iter()
248        .filter(|(_, group)| group.members.iter().any(|member| member == subject))
249        .map(|(name, _)| name.clone())
250        .collect()
251}
252
253#[cfg(test)]
254#[allow(clippy::expect_used, clippy::unwrap_used)]
255mod tests {
256    use super::*;
257    use crate::access::model::AccessGroup;
258
259    fn config_with_rules(rules: Vec<AccessRule>) -> AccessControlConfig {
260        AccessControlConfig {
261            enabled: true,
262            admins: vec!["root@example.test".to_string()],
263            groups: BTreeMap::from([(
264                "ops".to_string(),
265                AccessGroup {
266                    description: None,
267                    members: vec!["alice@example.test".to_string()],
268                },
269            )]),
270            rules,
271        }
272    }
273
274    fn principal(subject: &str, groups: &[&str]) -> AccessPrincipal {
275        AccessPrincipal {
276            subject: Some(subject.to_string()),
277            groups: groups.iter().map(ToString::to_string).collect(),
278        }
279    }
280
281    fn allow_rule(id: &str) -> AccessRule {
282        AccessRule {
283            id: id.to_string(),
284            ..AccessRule::default()
285        }
286    }
287
288    #[test]
289    fn disabled_config_allows_everything() {
290        let config = AccessControlConfig::default();
291        let decision = evaluate_access(
292            &config,
293            &AccessPrincipal::anonymous(),
294            "access.admin",
295            &AccessResource::none(),
296        );
297        assert!(decision.is_allow());
298    }
299
300    #[test]
301    fn enabled_config_denies_by_default() {
302        let config = config_with_rules(vec![]);
303        let decision = evaluate_access(
304            &config,
305            &principal("bob@example.test", &[]),
306            "agent.view",
307            &AccessResource::for_identity("identity:ops-lead"),
308        );
309        assert!(!decision.is_allow());
310    }
311
312    #[test]
313    fn admins_bypass_rules() {
314        let config = config_with_rules(vec![]);
315        let decision = evaluate_access(
316            &config,
317            &principal("root@example.test", &[]),
318            "access.admin",
319            &AccessResource::none(),
320        );
321        assert!(decision.is_allow());
322    }
323
324    #[test]
325    fn group_can_view_all_but_send_to_one() {
326        let mut view_rule = allow_rule("ops-view-all");
327        view_rule.groups = vec!["ops".to_string()];
328        view_rule.actions = vec!["agent.view".to_string()];
329        let mut send_rule = allow_rule("ops-send-lead");
330        send_rule.groups = vec!["ops".to_string()];
331        send_rule.actions = vec!["agent.send".to_string()];
332        send_rule.agents = vec!["identity:ops-lead".to_string()];
333        let config = config_with_rules(vec![view_rule, send_rule]);
334        let alice = principal("alice@example.test", &["ops"]);
335
336        let can_view_any = evaluate_access(
337            &config,
338            &alice,
339            "agent.view",
340            &AccessResource::for_identity("identity:scout-1"),
341        );
342        assert!(can_view_any.is_allow());
343        let can_send_lead = evaluate_access(
344            &config,
345            &alice,
346            "agent.send",
347            &AccessResource::for_identity("identity:ops-lead"),
348        );
349        assert!(can_send_lead.is_allow());
350        let cannot_send_other = evaluate_access(
351            &config,
352            &alice,
353            "agent.send",
354            &AccessResource::for_identity("identity:scout-1"),
355        );
356        assert!(!cannot_send_other.is_allow());
357    }
358
359    #[test]
360    fn deny_overrides_allow() {
361        let mut allow_all = allow_rule("everyone-views");
362        allow_all.actions = vec!["agent.view".to_string()];
363        let mut deny_secret = allow_rule("hide-secret");
364        deny_secret.effect = AccessEffect::Deny;
365        deny_secret.actions = vec!["agent.*".to_string()];
366        deny_secret.agents = vec!["identity:secret".to_string()];
367        let config = config_with_rules(vec![allow_all, deny_secret]);
368        let bob = principal("bob@example.test", &[]);
369
370        assert!(
371            evaluate_access(
372                &config,
373                &bob,
374                "agent.view",
375                &AccessResource::for_identity("identity:scout-1"),
376            )
377            .is_allow()
378        );
379        assert!(
380            !evaluate_access(
381                &config,
382                &bob,
383                "agent.view",
384                &AccessResource::for_identity("identity:secret"),
385            )
386            .is_allow()
387        );
388    }
389
390    #[test]
391    fn agent_and_role_selectors_support_wildcard() {
392        let mut agents_wildcard = allow_rule("view-any-agent");
393        agents_wildcard.actions = vec!["agent.view".to_string()];
394        agents_wildcard.agents = vec!["*".to_string()];
395        let mut roles_wildcard = allow_rule("send-any-role");
396        roles_wildcard.actions = vec!["agent.send".to_string()];
397        roles_wildcard.roles = vec!["*".to_string()];
398        let config = config_with_rules(vec![agents_wildcard, roles_wildcard]);
399        let bob = principal("bob@example.test", &[]);
400
401        assert!(
402            evaluate_access(
403                &config,
404                &bob,
405                "agent.view",
406                &AccessResource::for_identity("identity:anyone"),
407            )
408            .is_allow()
409        );
410        // roles: ["*"] matches any known role...
411        let with_role = AccessResource {
412            identity: Some("identity:anyone"),
413            role: Some("scout"),
414            ..AccessResource::default()
415        };
416        assert!(evaluate_access(&config, &bob, "agent.send", &with_role).is_allow());
417        // ...but stays closed when the role attribute is unknown.
418        assert!(
419            !evaluate_access(
420                &config,
421                &bob,
422                "agent.send",
423                &AccessResource::for_identity("identity:anyone"),
424            )
425            .is_allow()
426        );
427    }
428
429    #[test]
430    fn label_selectors_require_known_labels() {
431        let mut rule = allow_rule("payments-only");
432        rule.actions = vec!["agent.view".to_string()];
433        rule.match_labels = BTreeMap::from([("org".to_string(), "payments".to_string())]);
434        let config = config_with_rules(vec![rule]);
435        let bob = principal("bob@example.test", &[]);
436
437        let labels = BTreeMap::from([("org".to_string(), "payments".to_string())]);
438        let with_labels = AccessResource {
439            identity: Some("identity:pay-1"),
440            labels: Some(&labels),
441            ..AccessResource::default()
442        };
443        assert!(evaluate_access(&config, &bob, "agent.view", &with_labels).is_allow());
444
445        let unknown_labels = AccessResource::for_identity("identity:pay-1");
446        assert!(!evaluate_access(&config, &bob, "agent.view", &unknown_labels).is_allow());
447    }
448
449    #[test]
450    fn resourceless_checks_only_match_unconstrained_rules() {
451        let mut constrained = allow_rule("constrained-decide");
452        constrained.actions = vec!["gating.decide".to_string()];
453        constrained.agents = vec!["identity:ops-lead".to_string()];
454        let config = config_with_rules(vec![constrained]);
455        let bob = principal("bob@example.test", &[]);
456        assert!(
457            !evaluate_access(&config, &bob, "gating.decide", &AccessResource::none()).is_allow()
458        );
459
460        let mut unconstrained = allow_rule("decide");
461        unconstrained.actions = vec!["gating.decide".to_string()];
462        let config = config_with_rules(vec![unconstrained]);
463        assert!(
464            evaluate_access(&config, &bob, "gating.decide", &AccessResource::none()).is_allow()
465        );
466    }
467
468    #[test]
469    fn anonymous_matches_only_unconstrained_subjects() {
470        let mut open_rule = allow_rule("everyone-views");
471        open_rule.actions = vec!["agent.view".to_string()];
472        let mut named_rule = allow_rule("alice-sends");
473        named_rule.subjects = vec!["alice@example.test".to_string()];
474        named_rule.actions = vec!["agent.send".to_string()];
475        let config = config_with_rules(vec![open_rule, named_rule]);
476        let anonymous = AccessPrincipal::anonymous();
477
478        assert!(
479            evaluate_access(
480                &config,
481                &anonymous,
482                "agent.view",
483                &AccessResource::for_identity("identity:scout-1"),
484            )
485            .is_allow()
486        );
487        assert!(
488            !evaluate_access(
489                &config,
490                &anonymous,
491                "agent.send",
492                &AccessResource::for_identity("identity:scout-1"),
493            )
494            .is_allow()
495        );
496    }
497
498    #[test]
499    fn wildcard_subject_matches_authenticated_but_not_anonymous() {
500        let mut star = allow_rule("any-authenticated");
501        star.subjects = vec!["*".to_string()];
502        star.actions = vec!["agent.view".to_string()];
503        let config = config_with_rules(vec![star]);
504
505        // Any authenticated subject matches subjects:["*"].
506        assert!(
507            evaluate_access(
508                &config,
509                &principal("carol@example.test", &[]),
510                "agent.view",
511                &AccessResource::for_identity("identity:scout-1"),
512            )
513            .is_allow()
514        );
515        // The anonymous principal does NOT — it only matches unconstrained rules.
516        assert!(
517            !evaluate_access(
518                &config,
519                &AccessPrincipal::anonymous(),
520                "agent.view",
521                &AccessResource::for_identity("identity:scout-1"),
522            )
523            .is_allow()
524        );
525    }
526
527    #[test]
528    fn groups_resolve_from_config() {
529        let config = config_with_rules(vec![]);
530        let groups = groups_for_subject(&config, "alice@example.test");
531        assert!(groups.contains("ops"));
532        assert!(groups_for_subject(&config, "bob@example.test").is_empty());
533    }
534}