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