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//!
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 department: &'a str,
28    pub default_included: Option<bool>,
29}
30
31#[must_use]
32pub fn resolve(input: ResolveInput<'_>) -> Decision {
33    let ResolveInput {
34        entity,
35        rules,
36        user_id,
37        user_roles,
38        department,
39        default_included,
40    } = input;
41    let Some(default_included) = default_included else {
42        return Decision::Deny {
43            reason: DenyReason::UnknownEntity {
44                entity: entity.clone(),
45            },
46        };
47    };
48
49    let user_match =
50        |r: &AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id.as_str();
51    let role_match = |r: &AccessRule| {
52        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
53    };
54    let dept_match = |r: &AccessRule| {
55        r.rule_type == RuleType::Department && r.rule_value == department && !department.is_empty()
56    };
57
58    if let Some(rule) = rules
59        .iter()
60        .find(|r| user_match(r) && r.access == Access::Deny)
61    {
62        return Decision::Deny {
63            reason: DenyReason::UserDeny {
64                entity: entity.clone(),
65                user_id: user_id.clone(),
66                justification: rule.justification.clone(),
67            },
68        };
69    }
70    if rules
71        .iter()
72        .any(|r| user_match(r) && r.access == Access::Allow)
73    {
74        return Decision::Allow {
75            matched_by: MatchedBy::UserAllow,
76        };
77    }
78    if let Some(rule) = rules
79        .iter()
80        .find(|r| role_match(r) && r.access == Access::Deny)
81    {
82        return Decision::Deny {
83            reason: DenyReason::RoleDeny {
84                entity: entity.clone(),
85                role: rule.rule_value.clone(),
86                justification: rule.justification.clone(),
87            },
88        };
89    }
90    if let Some(rule) = rules
91        .iter()
92        .find(|r| role_match(r) && r.access == Access::Allow)
93    {
94        return Decision::Allow {
95            matched_by: MatchedBy::RoleAllow {
96                role: rule.rule_value.clone(),
97            },
98        };
99    }
100    if let Some(rule) = rules
101        .iter()
102        .find(|r| dept_match(r) && r.access == Access::Deny)
103    {
104        return Decision::Deny {
105            reason: DenyReason::DepartmentDeny {
106                entity: entity.clone(),
107                department: rule.rule_value.clone(),
108                justification: rule.justification.clone(),
109            },
110        };
111    }
112    if rules
113        .iter()
114        .any(|r| dept_match(r) && r.access == Access::Allow)
115    {
116        return Decision::Allow {
117            matched_by: MatchedBy::DepartmentAllow {
118                department: department.to_owned(),
119            },
120        };
121    }
122    if default_included {
123        return Decision::Allow {
124            matched_by: MatchedBy::DefaultIncluded,
125        };
126    }
127    Decision::Deny {
128        reason: DenyReason::NotAssigned {
129            entity: entity.clone(),
130            user_id: user_id.clone(),
131            roles: user_roles.to_vec(),
132        },
133    }
134}