1use hackamore_models::action::{Action, Verb};
15use hackamore_models::policy::{Condition, Effect, Match, Policy, Rule};
16use hackamore_models::verdict::{DenyReason, Verdict};
17use serde_json::Value;
18
19pub fn decide(action: &Action, policy: &Policy) -> Verdict {
24 for rule in &policy.rules {
25 if rule_matches(rule, action) {
26 return verdict_for(rule);
27 }
28 }
29 Verdict::deny(DenyReason::NotAllowed)
30}
31
32fn verdict_for(rule: &Rule) -> Verdict {
36 match rule.effect {
37 Effect::Allow => Verdict::allow(vec![]),
38 Effect::Deny => Verdict::deny(DenyReason::ExplicitDeny),
39 }
40}
41
42fn rule_matches(rule: &Rule, action: &Action) -> bool {
45 let m: &Match = &rule.matches;
46 target_matches(&m.targets, &action.target)
47 && verb_matches(&m.verbs, &action.verb)
48 && resource_matches(&m.resources, &action.resource.path)
49 && m.conditions
50 .iter()
51 .all(|c| condition_holds(c, &action.fields))
52}
53
54fn target_matches(targets: &[String], target: &str) -> bool {
55 targets.is_empty() || targets.iter().any(|t| t == target)
56}
57
58fn verb_matches(verbs: &[Verb], verb: &Verb) -> bool {
59 verbs.is_empty() || verbs.contains(verb)
60}
61
62fn resource_matches(patterns: &[String], path: &str) -> bool {
63 patterns.is_empty() || patterns.iter().any(|p| glob_matches(p, path))
64}
65
66fn glob_matches(pattern: &str, path: &str) -> bool {
70 let pat: Vec<&str> = pattern.split('/').collect();
71 let seg: Vec<&str> = path.split('/').collect();
72 segments_match(&pat, &seg)
73}
74
75fn segments_match(pat: &[&str], seg: &[&str]) -> bool {
76 match pat.split_first() {
77 None => seg.is_empty(),
78 Some((&"**", rest)) => {
79 (0..=seg.len()).any(|i| segments_match(rest, &seg[i..]))
81 }
82 Some((&head, rest)) => match seg.split_first() {
83 None => false,
84 Some((&shead, srest)) => (head == "*" || head == shead) && segments_match(rest, srest),
85 },
86 }
87}
88
89fn condition_holds(condition: &Condition, fields: &Value) -> bool {
91 match condition {
92 Condition::Equals(c) => lookup(fields, &c.field) == Some(&c.value),
93 Condition::OneOf(c) => lookup(fields, &c.field).is_some_and(|v| c.values.contains(v)),
94 Condition::Exists(c) => lookup(fields, &c.field).is_some_and(|v| !v.is_null()),
95 }
96}
97
98fn lookup<'a>(fields: &'a Value, path: &str) -> Option<&'a Value> {
101 let mut cur = fields;
102 for seg in path.split('.') {
103 cur = cur.as_object()?.get(seg)?;
104 }
105 Some(cur)
106}
107
108#[cfg(test)]
109#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
110mod tests {
111 use super::*;
112 use hackamore_models::action::{Action, CrudKind, Resource, Verb};
113 use hackamore_models::policy::{
114 Condition, Effect, EqualsCondition, ExistsCondition, Match, OneOfCondition, Policy, Rule,
115 };
116 use hackamore_models::verdict::{DenyReason, Verdict};
117
118 fn empty_match() -> Match {
119 Match {
120 targets: vec![],
121 verbs: vec![],
122 resources: vec![],
123 conditions: vec![],
124 }
125 }
126
127 fn allow(matches: Match) -> Rule {
128 Rule {
129 effect: Effect::Allow,
130 matches,
131 }
132 }
133
134 fn deny(matches: Match) -> Rule {
135 Rule {
136 effect: Effect::Deny,
137 matches,
138 }
139 }
140
141 fn pr_create() -> Action {
142 Action::of(
143 "github",
144 Verb::crud(CrudKind::Create),
145 Resource::of("repos/octocat/hello/pulls", "pull_request"),
146 )
147 }
148
149 #[test]
150 fn empty_policy_denies_default() {
151 let v = decide(&pr_create(), &Policy { rules: vec![] });
152 assert!(matches!(
153 v,
154 Verdict::Deny(d) if d.reason == DenyReason::NotAllowed
155 ));
156 }
157
158 #[test]
159 fn matching_allow_rule_yields_bare_allow() {
160 let policy = Policy {
161 rules: vec![allow(Match {
162 verbs: vec![Verb::crud(CrudKind::Create)],
163 resources: vec!["repos/octocat/*/pulls".into()],
164 ..empty_match()
165 })],
166 };
167 match decide(&pr_create(), &policy) {
168 Verdict::Allow(a) => assert!(a.obligations.is_empty()),
171 Verdict::Deny(_) => panic!("expected allow"),
172 }
173 }
174
175 #[test]
176 fn named_verb_matches_named_rule() {
177 let describe = Action::of(
178 "aws-acct-a",
179 Verb::action("ec2:DescribeInstances"),
180 Resource::of("", "root"),
181 );
182 let policy = Policy {
183 rules: vec![allow(Match {
184 verbs: vec![Verb::action("ec2:DescribeInstances")],
185 ..empty_match()
186 })],
187 };
188 assert!(decide(&describe, &policy).is_allow());
189 let terminate = Action::of(
191 "aws-acct-a",
192 Verb::action("ec2:TerminateInstances"),
193 Resource::of("", "root"),
194 );
195 assert!(!decide(&terminate, &policy).is_allow());
196 }
197
198 #[test]
199 fn first_match_wins_deny_before_allow() {
200 let policy = Policy {
201 rules: vec![
202 deny(Match {
203 verbs: vec![Verb::crud(CrudKind::Create)],
204 ..empty_match()
205 }),
206 allow(empty_match()),
207 ],
208 };
209 let v = decide(&pr_create(), &policy);
210 assert!(matches!(
211 v,
212 Verdict::Deny(d) if d.reason == DenyReason::ExplicitDeny
213 ));
214 }
215
216 #[test]
217 fn read_only_agent_denied_create() {
218 let policy = Policy {
220 rules: vec![allow(Match {
221 verbs: vec![Verb::crud(CrudKind::Read)],
222 ..empty_match()
223 })],
224 };
225 let read = Action::of(
226 "github",
227 Verb::crud(CrudKind::Read),
228 Resource::of("repos/octocat/hello", "repo"),
229 );
230 assert!(decide(&read, &policy).is_allow());
231 assert!(!decide(&pr_create(), &policy).is_allow());
232 }
233
234 #[test]
235 fn condition_gates_on_field_value() {
236 let policy = Policy {
238 rules: vec![allow(Match {
239 verbs: vec![Verb::crud(CrudKind::Create)],
240 resources: vec!["repos/*/*/pulls".into()],
241 conditions: vec![Condition::Equals(EqualsCondition {
242 field: "base".into(),
243 value: serde_json::json!("develop"),
244 })],
245 ..empty_match()
246 })],
247 };
248 let to_develop = pr_create().with_fields(serde_json::json!({ "base": "develop" }));
249 let to_main = pr_create().with_fields(serde_json::json!({ "base": "main" }));
250 assert!(decide(&to_develop, &policy).is_allow());
251 assert!(!decide(&to_main, &policy).is_allow());
252 }
253
254 #[test]
255 fn one_of_and_exists_conditions() {
256 let policy = Policy {
257 rules: vec![allow(Match {
258 conditions: vec![
259 Condition::OneOf(OneOfCondition {
260 field: "base".into(),
261 values: vec![serde_json::json!("develop"), serde_json::json!("staging")],
262 }),
263 Condition::Exists(ExistsCondition {
264 field: "title".into(),
265 }),
266 ],
267 ..empty_match()
268 })],
269 };
270 let ok = pr_create().with_fields(serde_json::json!({ "base": "staging", "title": "x" }));
271 let no_title = pr_create().with_fields(serde_json::json!({ "base": "staging" }));
272 let bad_base = pr_create().with_fields(serde_json::json!({ "base": "main", "title": "x" }));
273 assert!(decide(&ok, &policy).is_allow());
274 assert!(!decide(&no_title, &policy).is_allow());
275 assert!(!decide(&bad_base, &policy).is_allow());
276 }
277
278 #[test]
279 fn glob_double_star_matches_remainder() {
280 assert!(glob_matches(
281 "repos/octocat/**",
282 "repos/octocat/hello/pulls/1"
283 ));
284 assert!(glob_matches("repos/*/*/pulls", "repos/a/b/pulls"));
285 assert!(!glob_matches("repos/*/*/pulls", "repos/a/b/issues"));
286 assert!(!glob_matches("repos/*/pulls", "repos/a/b/pulls"));
287 assert!(glob_matches("repos/octocat/**", "repos/octocat"));
288 }
289
290 #[test]
291 fn dotted_field_lookup() {
292 let fields = serde_json::json!({ "head": { "ref": "feature" } });
293 assert_eq!(
294 lookup(&fields, "head.ref"),
295 Some(&serde_json::json!("feature"))
296 );
297 assert_eq!(lookup(&fields, "head.sha"), None);
298 assert_eq!(lookup(&fields, "missing"), None);
299 }
300}