Skip to main content

systemprompt_security/authz/
resolver.rs

1//! Pure deny-overrides resolver with `user > role` specificity.
2//!
3//! The function is intentionally synchronous and free of I/O so it can be
4//! reused by the in-process [`super::rule_based::RuleBasedHook`], the
5//! template's webhook handler, and unit tests without setup. Callers fetch
6//! [`AccessRule`]s plus the `default_included` sentinel from
7//! [`super::repository::AccessControlRepository`] and pass them in.
8//!
9//! `default_included` is `Option<bool>` — `None` signals the entity is
10//! unknown to access control (no row in `access_control_entities`), which
11//! the resolver turns into [`DenyReason::UnknownEntity`] rather than the
12//! generic `NotAssigned` deny. This distinction matters operationally: an
13//! unknown entity is a publish-pipeline gap, not a missing role grant.
14
15use systemprompt_identifiers::UserId;
16
17use super::types::{Access, AccessRule, Decision, DenyReason, EntityRef, MatchedBy, RuleType};
18
19/// Inputs to [`resolve`]. Bundled so the function stays under the clippy
20/// argument-count limit and so call sites can read top-to-bottom.
21#[derive(Debug, Clone, Copy)]
22pub struct ResolveInput<'a> {
23    pub entity: &'a EntityRef,
24    pub rules: &'a [AccessRule],
25    pub user_id: &'a UserId,
26    pub user_roles: &'a [String],
27    pub default_included: Option<bool>,
28}
29
30#[must_use]
31pub fn resolve(input: ResolveInput<'_>) -> Decision {
32    let ResolveInput {
33        entity,
34        rules,
35        user_id,
36        user_roles,
37        default_included,
38    } = input;
39    let Some(default_included) = default_included else {
40        return Decision::Deny {
41            reason: DenyReason::UnknownEntity {
42                entity: entity.clone(),
43            },
44        };
45    };
46
47    let user_match =
48        |r: &AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id.as_str();
49    let role_match = |r: &AccessRule| {
50        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
51    };
52
53    if let Some(rule) = rules
54        .iter()
55        .find(|r| user_match(r) && r.access == Access::Deny)
56    {
57        return Decision::Deny {
58            reason: DenyReason::UserDeny {
59                entity: entity.clone(),
60                user_id: user_id.clone(),
61                justification: rule.justification.clone(),
62            },
63        };
64    }
65    if rules
66        .iter()
67        .any(|r| user_match(r) && r.access == Access::Allow)
68    {
69        return Decision::Allow {
70            matched_by: MatchedBy::UserAllow,
71        };
72    }
73    if let Some(rule) = rules
74        .iter()
75        .find(|r| role_match(r) && r.access == Access::Deny)
76    {
77        return Decision::Deny {
78            reason: DenyReason::RoleDeny {
79                entity: entity.clone(),
80                role: rule.rule_value.clone(),
81                justification: rule.justification.clone(),
82            },
83        };
84    }
85    if let Some(rule) = rules
86        .iter()
87        .find(|r| role_match(r) && r.access == Access::Allow)
88    {
89        return Decision::Allow {
90            matched_by: MatchedBy::RoleAllow {
91                role: rule.rule_value.clone(),
92            },
93        };
94    }
95    if default_included {
96        return Decision::Allow {
97            matched_by: MatchedBy::DefaultIncluded,
98        };
99    }
100    Decision::Deny {
101        reason: DenyReason::NotAssigned {
102            entity: entity.clone(),
103            user_id: user_id.clone(),
104            roles: user_roles.to_vec(),
105        },
106    }
107}