Skip to main content

systemprompt_security/authz/
resolver.rs

1//! Pure deny-overrides resolver with `user > role` specificity.
2//!
3//! The function is intentionally synchronous and free of I/O so it can be
4//! reused by the in-process [`super::rule_based::RuleBasedHook`], the
5//! template's webhook handler, and unit tests without setup. Callers fetch
6//! [`AccessRule`]s plus the `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/// A parent entity whose rules cascade onto the child being resolved.
20///
21/// Parents are ordered nearest-first: the entity directly above the child
22/// comes before its grandparent, so a closer grant wins over a more distant
23/// one within the same precedence band.
24#[derive(Debug, Clone, Copy)]
25pub struct ResolveParent<'a> {
26    pub entity: &'a EntityRef,
27    pub rules: &'a [AccessRule],
28    pub default_included: Option<bool>,
29}
30
31/// Inputs to [`resolve`]. Bundled so the function stays under the clippy
32/// argument-count limit and so call sites can read top-to-bottom.
33#[derive(Debug, Clone, Copy)]
34pub struct ResolveInput<'a> {
35    pub entity: &'a EntityRef,
36    pub rules: &'a [AccessRule],
37    pub user_id: &'a UserId,
38    pub user_roles: &'a [String],
39    pub default_included: Option<bool>,
40    pub parents: &'a [ResolveParent<'a>],
41}
42
43/// Resolve an access decision with deny-overrides precedence and
44/// parent-entity inheritance.
45///
46/// The precedence ladder, evaluated strictly top to bottom and short-circuiting
47/// on the first match, is:
48///
49/// 1. own user-deny → own user-allow → own role-deny → own role-allow
50/// 2. for each parent (nearest first): parent user-deny → parent user-allow →
51///    parent role-deny → parent role-allow
52/// 3. own `default_included` (`true` → Allow)
53/// 4. each parent's `default_included` (nearest `true` → Allow)
54/// 5. otherwise Deny
55///
56/// A grant on a parent therefore cascades to the child only when neither the
57/// child nor a nearer parent has a more specific matching rule. A child deny
58/// always overrides a parent allow, and a nearer rule always overrides a
59/// farther one within the same band.
60///
61/// If the child entity's own `default_included` is `None`, the entity is
62/// unknown to access control. In that case the result is
63/// [`DenyReason::UnknownEntity`] only when no rule (own or parent) matches and
64/// no parent grants access via its own `default_included`; an explicit or
65/// inherited grant still resolves to [`Decision::Allow`].
66#[must_use]
67pub fn resolve(input: ResolveInput<'_>) -> Decision {
68    let ResolveInput {
69        entity,
70        rules,
71        user_id,
72        user_roles,
73        default_included,
74        parents,
75    } = input;
76
77    if let Some(decision) = match_ruleset(entity, rules, user_id, user_roles) {
78        return decision;
79    }
80    for parent in parents {
81        if let Some(decision) = match_ruleset(parent.entity, parent.rules, user_id, user_roles) {
82            return decision;
83        }
84    }
85
86    if default_included == Some(true) {
87        return Decision::Allow {
88            matched_by: MatchedBy::DefaultIncluded,
89        };
90    }
91    if parents
92        .iter()
93        .any(|parent| parent.default_included == Some(true))
94    {
95        return Decision::Allow {
96            matched_by: MatchedBy::DefaultIncluded,
97        };
98    }
99
100    if default_included.is_none() {
101        return Decision::Deny {
102            reason: DenyReason::UnknownEntity {
103                entity: entity.clone(),
104            },
105        };
106    }
107    Decision::Deny {
108        reason: DenyReason::NotAssigned {
109            entity: entity.clone(),
110            user_id: user_id.clone(),
111            roles: user_roles.to_vec(),
112        },
113    }
114}
115
116fn match_ruleset(
117    target: &EntityRef,
118    ruleset: &[AccessRule],
119    user_id: &UserId,
120    user_roles: &[String],
121) -> Option<Decision> {
122    let user_match =
123        |r: &AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id.as_str();
124    let role_match = |r: &AccessRule| {
125        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
126    };
127
128    if let Some(rule) = ruleset
129        .iter()
130        .find(|r| user_match(r) && r.access == Access::Deny)
131    {
132        return Some(Decision::Deny {
133            reason: DenyReason::UserDeny {
134                entity: target.clone(),
135                user_id: user_id.clone(),
136                justification: rule.justification.clone(),
137            },
138        });
139    }
140    if ruleset
141        .iter()
142        .any(|r| user_match(r) && r.access == Access::Allow)
143    {
144        return Some(Decision::Allow {
145            matched_by: MatchedBy::UserAllow,
146        });
147    }
148    if let Some(rule) = ruleset
149        .iter()
150        .find(|r| role_match(r) && r.access == Access::Deny)
151    {
152        return Some(Decision::Deny {
153            reason: DenyReason::RoleDeny {
154                entity: target.clone(),
155                role: rule.rule_value.clone(),
156                justification: rule.justification.clone(),
157            },
158        });
159    }
160    if let Some(rule) = ruleset
161        .iter()
162        .find(|r| role_match(r) && r.access == Access::Allow)
163    {
164        return Some(Decision::Allow {
165            matched_by: MatchedBy::RoleAllow {
166                role: rule.rule_value.clone(),
167            },
168        });
169    }
170    None
171}