Skip to main content

systemprompt_security/authz/
resolver.rs

1//! Pure deny-overrides resolver with `user > … > role` specificity.
2//!
3//! Core ships two subject dimensions, `user` and `role`. Extensions declare
4//! their own — department, cost centre, clearance — as
5//! [`SubjectDimension`]s and pass them in via [`ResolveInput::dimensions`],
6//! with the user's values for them in [`ResolveInput::attributes`]. The
7//! precedence ladder is built per call from those two fields, so `resolve`
8//! learns no tenant vocabulary and stays pure. With no dimensions passed the
9//! ladder is exactly `user > role`, which is the pre-existing behaviour.
10//!
11//! The function is intentionally synchronous and free of I/O so it can be
12//! reused by the in-process [`super::rule_based::RuleBasedHook`], the
13//! template's webhook handler, and unit tests without setup. Callers fetch
14//! [`AccessRule`]s plus the `default_included` sentinel from
15//! [`super::repository::AccessControlRepository`] and pass them in.
16//!
17//! A declared ruleset is **authoritative and closed**: an entity that names its
18//! own roles is closed to every role it does not name, and only an entity with
19//! no rules of its own defers to its parents. This is what makes a narrow
20//! `roles: [admin]` grant restrictive even when the entity belongs to a group
21//! that is granted to everyone.
22//!
23//! `default_included` is `Option<bool>` — `None` signals the entity is
24//! unknown to access control (no row in `access_control_entities`), which
25//! the resolver turns into [`DenyReason::UnknownEntity`] rather than the
26//! generic `NotAssigned` deny. This distinction matters operationally: an
27//! unknown entity is a publish-pipeline gap, not a missing role grant.
28//!
29//! Copyright (c) systemprompt.io — Business Source License 1.1.
30//! See <https://systemprompt.io> for licensing details.
31
32use systemprompt_identifiers::UserId;
33
34use super::subject::{ROLE_PRECEDENCE, SubjectAttributes, SubjectDimension, USER_PRECEDENCE};
35use super::types::{Access, AccessRule, Decision, DenyReason, EntityRef, MatchedBy, RuleType};
36
37/// A parent entity whose rules cascade onto the child being resolved.
38///
39/// Parents are ordered nearest-first: the entity directly above the child
40/// comes before its grandparent, so a closer grant wins over a more distant
41/// one within the same precedence band.
42#[derive(Debug, Clone, Copy)]
43pub struct ResolveParent<'a> {
44    pub entity: &'a EntityRef,
45    pub rules: &'a [AccessRule],
46    pub default_included: Option<bool>,
47}
48
49/// Inputs to [`resolve`]. Bundled so the function stays under the clippy
50/// argument-count limit and so call sites can read top-to-bottom.
51#[derive(Debug, Clone, Copy)]
52pub struct ResolveInput<'a> {
53    pub entity: &'a EntityRef,
54    pub rules: &'a [AccessRule],
55    pub user_id: &'a UserId,
56    pub user_roles: &'a [String],
57    pub default_included: Option<bool>,
58    pub parents: &'a [ResolveParent<'a>],
59    pub attributes: &'a SubjectAttributes,
60    pub dimensions: &'a [SubjectDimension],
61}
62
63#[must_use]
64pub fn resolve(input: ResolveInput<'_>) -> Decision {
65    let ResolveInput {
66        entity,
67        rules,
68        user_id,
69        user_roles,
70        default_included,
71        parents,
72        attributes,
73        dimensions,
74    } = input;
75
76    let ladder = ladder(dimensions);
77    let subject = Subject {
78        user_id,
79        user_roles,
80        attributes,
81        ladder: &ladder,
82    };
83
84    if let Some(decision) = match_ruleset(entity, rules, &subject) {
85        return decision;
86    }
87    let parents = if rules.is_empty() { parents } else { &[] };
88
89    for parent in parents {
90        if let Some(decision) = match_ruleset(parent.entity, parent.rules, &subject) {
91            return decision;
92        }
93    }
94
95    if default_included == Some(true) {
96        return Decision::Allow {
97            matched_by: MatchedBy::DefaultIncluded,
98        };
99    }
100    if parents
101        .iter()
102        .any(|parent| parent.default_included == Some(true))
103    {
104        return Decision::Allow {
105            matched_by: MatchedBy::DefaultIncluded,
106        };
107    }
108
109    if default_included.is_none() {
110        return Decision::Deny {
111            reason: DenyReason::UnknownEntity {
112                entity: entity.clone(),
113            },
114        };
115    }
116    Decision::Deny {
117        reason: DenyReason::NotAssigned {
118            entity: entity.clone(),
119            user_id: user_id.clone(),
120            roles: user_roles.to_vec(),
121        },
122    }
123}
124
125fn ladder(dimensions: &[SubjectDimension]) -> Vec<(RuleType, u16)> {
126    let mut bands = vec![
127        (RuleType::USER, USER_PRECEDENCE),
128        (RuleType::ROLE, ROLE_PRECEDENCE),
129    ];
130    bands.extend(
131        dimensions
132            .iter()
133            .filter(|d| d.rule_type != RuleType::USER && d.rule_type != RuleType::ROLE)
134            .map(|d| (d.rule_type.clone(), d.precedence)),
135    );
136    bands.sort_by_key(|&(_, precedence)| precedence);
137    bands
138}
139
140struct Subject<'a> {
141    user_id: &'a UserId,
142    user_roles: &'a [String],
143    attributes: &'a SubjectAttributes,
144    ladder: &'a [(RuleType, u16)],
145}
146
147impl Subject<'_> {
148    fn matches(&self, rule: &AccessRule) -> bool {
149        if rule.rule_type == RuleType::USER {
150            return rule.rule_value == self.user_id.as_str();
151        }
152        let held = if rule.rule_type == RuleType::ROLE {
153            self.user_roles
154        } else {
155            self.attributes.values(&rule.rule_type)
156        };
157        held.iter().any(|value| value == &rule.rule_value)
158    }
159}
160
161fn match_ruleset(
162    target: &EntityRef,
163    ruleset: &[AccessRule],
164    subject: &Subject<'_>,
165) -> Option<Decision> {
166    for (rule_type, _) in subject.ladder {
167        let in_band = |r: &&AccessRule| r.rule_type == *rule_type && subject.matches(r);
168
169        if let Some(rule) = ruleset
170            .iter()
171            .find(|r| in_band(r) && r.access == Access::Deny)
172        {
173            return Some(deny_for(target, subject, rule));
174        }
175        if let Some(rule) = ruleset
176            .iter()
177            .find(|r| in_band(r) && r.access == Access::Allow)
178        {
179            return Some(allow_for(rule));
180        }
181    }
182    None
183}
184
185fn deny_for(target: &EntityRef, subject: &Subject<'_>, rule: &AccessRule) -> Decision {
186    let reason = if rule.rule_type == RuleType::USER {
187        DenyReason::UserDeny {
188            entity: target.clone(),
189            user_id: subject.user_id.clone(),
190            justification: rule.justification.clone(),
191        }
192    } else if rule.rule_type == RuleType::ROLE {
193        DenyReason::RoleDeny {
194            entity: target.clone(),
195            role: rule.rule_value.clone(),
196            justification: rule.justification.clone(),
197        }
198    } else {
199        DenyReason::AttributeDeny {
200            entity: target.clone(),
201            rule_type: rule.rule_type.clone(),
202            value: rule.rule_value.clone(),
203            justification: rule.justification.clone(),
204        }
205    };
206    Decision::Deny { reason }
207}
208
209fn allow_for(rule: &AccessRule) -> Decision {
210    let matched_by = if rule.rule_type == RuleType::USER {
211        MatchedBy::UserAllow
212    } else if rule.rule_type == RuleType::ROLE {
213        MatchedBy::RoleAllow {
214            role: rule.rule_value.clone(),
215        }
216    } else {
217        MatchedBy::AttributeAllow {
218            rule_type: rule.rule_type.clone(),
219            value: rule.rule_value.clone(),
220        }
221    };
222    Decision::Allow { matched_by }
223}