Skip to main content

systemprompt_security/authz/
resolver.rs

1//! Pure deny-overrides resolver with `user > role > department` specificity.
2//!
3//! The function is intentionally synchronous and free of I/O so it can be
4//! reused by the in-process default hook, the template's webhook handler,
5//! and unit tests without setup. Callers fetch [`AccessRule`]s plus the
6//! `default_included` sentinel from
7//! [`super::repository::AccessControlRepository`] and pass them in.
8
9use super::types::{Access, AccessRule, Decision, RuleType};
10
11#[must_use]
12pub fn resolve(
13    rules: &[AccessRule],
14    user_id: &str,
15    user_roles: &[String],
16    department: &str,
17    default_included: bool,
18) -> Decision {
19    let user_match = |r: &&AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id;
20    let role_match = |r: &&AccessRule| {
21        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
22    };
23    let dept_match = |r: &&AccessRule| {
24        r.rule_type == RuleType::Department && r.rule_value == department && !department.is_empty()
25    };
26
27    if let Some(rule) = rules
28        .iter()
29        .find(|r| user_match(r) && r.access == Access::Deny)
30    {
31        return Decision::Deny {
32            reason: format!("user-level deny: {user_id}"),
33            justification: rule.justification.clone(),
34        };
35    }
36    if rules
37        .iter()
38        .any(|r| user_match(&r) && r.access == Access::Allow)
39    {
40        return Decision::Allow;
41    }
42    if let Some(rule) = rules
43        .iter()
44        .find(|r| role_match(r) && r.access == Access::Deny)
45    {
46        return Decision::Deny {
47            reason: format!("role deny: {}", rule.rule_value),
48            justification: rule.justification.clone(),
49        };
50    }
51    if rules
52        .iter()
53        .any(|r| role_match(&r) && r.access == Access::Allow)
54    {
55        return Decision::Allow;
56    }
57    if let Some(rule) = rules
58        .iter()
59        .find(|r| dept_match(r) && r.access == Access::Deny)
60    {
61        return Decision::Deny {
62            reason: format!("department deny: {department}"),
63            justification: rule.justification.clone(),
64        };
65    }
66    if rules
67        .iter()
68        .any(|r| dept_match(&r) && r.access == Access::Allow)
69    {
70        return Decision::Allow;
71    }
72    if default_included {
73        return Decision::Allow;
74    }
75    Decision::Deny {
76        reason: "not assigned".into(),
77        justification: None,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn rule(rule_type: RuleType, value: &str, access: Access) -> AccessRule {
86        AccessRule {
87            id: systemprompt_identifiers::RuleId::new(format!("{rule_type}-{value}-{access}")),
88            rule_type,
89            rule_value: value.into(),
90            access,
91            default_included: false,
92            justification: None,
93        }
94    }
95
96    #[test]
97    fn no_rules_no_default_denies() {
98        let d = resolve(&[], "u1", &["eng".into()], "platform", false);
99        assert!(matches!(d, Decision::Deny { .. }));
100    }
101
102    #[test]
103    fn no_rules_default_allows() {
104        let d = resolve(&[], "u1", &["eng".into()], "platform", true);
105        assert_eq!(d, Decision::Allow);
106    }
107
108    #[test]
109    fn user_deny_overrides_role_allow() {
110        let rules = vec![
111            rule(RuleType::User, "u1", Access::Deny),
112            rule(RuleType::Role, "eng", Access::Allow),
113        ];
114        let d = resolve(&rules, "u1", &["eng".into()], "platform", true);
115        assert!(
116            matches!(d, Decision::Deny { ref reason, .. } if reason == "user-level deny: u1"),
117            "got {d:?}",
118        );
119    }
120
121    #[test]
122    fn user_allow_beats_role_deny() {
123        let rules = vec![
124            rule(RuleType::User, "u1", Access::Allow),
125            rule(RuleType::Role, "eng", Access::Deny),
126        ];
127        let d = resolve(&rules, "u1", &["eng".into()], "platform", false);
128        assert_eq!(d, Decision::Allow);
129    }
130
131    #[test]
132    fn role_deny_overrides_role_allow_in_multirole() {
133        let rules = vec![
134            rule(RuleType::Role, "eng", Access::Allow),
135            rule(RuleType::Role, "contractor", Access::Deny),
136        ];
137        let d = resolve(
138            &rules,
139            "u1",
140            &["eng".into(), "contractor".into()],
141            "platform",
142            false,
143        );
144        assert!(
145            matches!(d, Decision::Deny { ref reason, .. } if reason == "role deny: contractor"),
146            "got {d:?}",
147        );
148    }
149
150    #[test]
151    fn role_allow_beats_department_deny() {
152        let rules = vec![
153            rule(RuleType::Role, "eng", Access::Allow),
154            rule(RuleType::Department, "platform", Access::Deny),
155        ];
156        let d = resolve(&rules, "u1", &["eng".into()], "platform", false);
157        assert_eq!(d, Decision::Allow);
158    }
159
160    #[test]
161    fn department_deny_when_no_role_match() {
162        let rules = vec![rule(RuleType::Department, "platform", Access::Deny)];
163        let d = resolve(&rules, "u1", &["eng".into()], "platform", true);
164        assert!(
165            matches!(d, Decision::Deny { ref reason, .. } if reason == "department deny: platform"),
166        );
167    }
168
169    #[test]
170    fn department_allow_when_no_role_match() {
171        let rules = vec![rule(RuleType::Department, "platform", Access::Allow)];
172        let d = resolve(&rules, "u1", &["eng".into()], "platform", false);
173        assert_eq!(d, Decision::Allow);
174    }
175
176    #[test]
177    fn empty_department_does_not_match_dept_rules() {
178        let rules = vec![rule(RuleType::Department, "", Access::Allow)];
179        let d = resolve(&rules, "u1", &["eng".into()], "", false);
180        assert!(matches!(d, Decision::Deny { ref reason, .. } if reason == "not assigned"));
181    }
182
183    #[test]
184    fn no_match_with_default_off_denies_not_assigned() {
185        let rules = vec![rule(RuleType::Role, "ops", Access::Allow)];
186        let d = resolve(&rules, "u1", &["eng".into()], "platform", false);
187        assert!(matches!(d, Decision::Deny { ref reason, .. } if reason == "not assigned"));
188    }
189
190    #[test]
191    fn no_match_with_default_on_allows() {
192        let rules = vec![rule(RuleType::Role, "ops", Access::Allow)];
193        let d = resolve(&rules, "u1", &["eng".into()], "platform", true);
194        assert_eq!(d, Decision::Allow);
195    }
196
197    #[test]
198    fn user_allow_alone_allows() {
199        let rules = vec![rule(RuleType::User, "u1", Access::Allow)];
200        let d = resolve(&rules, "u1", &[], "", false);
201        assert_eq!(d, Decision::Allow);
202    }
203}