systemprompt_security/authz/
resolver.rs1use 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}