Skip to main content

hackamore_policy/
lib.rs

1//! The hackamore policy engine — the reusable decision core.
2//!
3//! Its entire public surface is one pure function, [`decide`]: given a normalized
4//! [`Action`] and an agent's [`Policy`], it returns a [`Verdict`]. No I/O, no HTTP, no
5//! async, no awareness that a proxy exists. That narrowness is the point: any data
6//! plane (the bundled reverse proxy today, an Envoy `ext_authz` adapter tomorrow) can
7//! reuse it by translating its request into an `Action` and enforcing the `Verdict`.
8//!
9//! Semantics: rules are evaluated top-to-bottom, **first match wins**, and if no rule
10//! matches the action is **denied** (fail closed). An `Allow` is **bare**: the engine
11//! names no credentials — the matched service instance owns its credential, and the data
12//! plane attaches the inject/passthrough obligation.
13
14use 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
19/// Decide whether `action` is permitted under `policy`.
20///
21/// Pure and total: every action yields either `Allow` (with obligations) or `Deny`
22/// (with a reason). The default, when no rule matches, is `Deny(NotAllowed)`.
23pub 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
32/// Build the verdict a matched rule produces. An `Allow` is **bare** — the engine no
33/// longer names credentials (the matched service instance owns them); the data plane
34/// attaches the inject/passthrough obligation from the routed target.
35fn 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
42/// Whether every facet of a rule's `matches` holds for the action. Empty lists mean
43/// "any", so an all-empty `Match` matches every action.
44fn 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
66/// Segment-wise glob over a slash-joined resource path. `*` matches exactly one
67/// segment; `**` matches any number of segments (including zero) and is normally the
68/// trailing segment. Both pattern and path are compared by their `/`-split segments.
69fn 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            // `**` consumes zero-or-more segments: succeed if `rest` matches any suffix.
80            (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
89/// Whether a single field condition holds against the action's `fields` JSON object.
90fn 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
98/// Resolve a dotted path (e.g. `"head.ref"`) into a JSON object. Returns `None` if any
99/// segment is missing or a non-object is traversed.
100fn 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            // The engine no longer names credentials; allow is bare, the data plane
169            // attaches the target's inject/passthrough obligation.
170            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        // A different named action falls through to default-deny.
190        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        // Allow only reads; a create falls through to default-deny.
219        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        // May open PRs, but only against base "develop".
237        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}