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**, at every level of the
18//! parent chain: the nearest entity that declares any rule — the entity
19//! itself, else its plugin, else its marketplace — decides, and a level with
20//! no rules is transparent and defers upward. A narrow `roles: [admin]` on a
21//! plugin therefore closes every ruleless skill and artifact it ships to
22//! every other role, even when the marketplace above it admits everyone.
23//!
24//! `default_included` is `Option<bool>` — `None` signals the entity is
25//! unknown to access control (no row in `access_control_entities`), which
26//! the resolver turns into [`DenyReason::UnknownEntity`] rather than the
27//! generic `NotAssigned` deny. This distinction matters operationally: an
28//! unknown entity is a publish-pipeline gap, not a missing role grant.
29//!
30//! Copyright (c) systemprompt.io — Business Source License 1.1.
31//! See <https://systemprompt.io> for licensing details.
32
33use systemprompt_identifiers::UserId;
34
35use super::subject::{ROLE_PRECEDENCE, SubjectAttributes, SubjectDimension, USER_PRECEDENCE};
36use super::types::{Access, AccessRule, Decision, DenyReason, EntityRef, MatchedBy, RuleType};
37
38/// A parent entity whose rules cascade onto the child being resolved.
39///
40/// Parents are ordered nearest-first; the first parent with a non-empty
41/// ruleset closes the cascade, so a farther grant can never reach past a
42/// nearer, declared level.
43#[derive(Debug, Clone, Copy)]
44pub struct ResolveParent<'a> {
45    pub entity: &'a EntityRef,
46    pub rules: &'a [AccessRule],
47    pub default_included: Option<bool>,
48}
49
50/// Inputs to [`resolve`]. Bundled so the function stays under the clippy
51/// argument-count limit and so call sites can read top-to-bottom.
52#[derive(Debug, Clone, Copy)]
53pub struct ResolveInput<'a> {
54    pub entity: &'a EntityRef,
55    pub rules: &'a [AccessRule],
56    pub user_id: &'a UserId,
57    pub user_roles: &'a [String],
58    pub default_included: Option<bool>,
59    pub parents: &'a [ResolveParent<'a>],
60    pub attributes: &'a SubjectAttributes,
61    pub dimensions: &'a [SubjectDimension],
62}
63
64#[must_use]
65pub fn resolve(input: ResolveInput<'_>) -> Decision {
66    let ResolveInput {
67        entity,
68        rules,
69        user_id,
70        user_roles,
71        default_included,
72        parents,
73        attributes,
74        dimensions,
75    } = input;
76
77    let ladder = ladder(dimensions);
78    let subject = Subject {
79        user_id,
80        user_roles,
81        attributes,
82        ladder: &ladder,
83    };
84
85    let closed = |considered: &[ResolveParent<'_>]| {
86        closed_decision(entity, user_id, user_roles, default_included, considered)
87    };
88
89    if let Some(decision) = match_ruleset(entity, rules, &subject) {
90        return decision;
91    }
92    if !rules.is_empty() {
93        if default_included.is_none() {
94            return Decision::Deny {
95                reason: DenyReason::UnknownEntity {
96                    entity: entity.clone(),
97                },
98            };
99        }
100        return closed(&[]);
101    }
102
103    for (index, parent) in parents.iter().enumerate() {
104        if let Some(decision) = match_ruleset(parent.entity, parent.rules, &subject) {
105            return decision;
106        }
107        if !parent.rules.is_empty() {
108            return closed(&parents[..=index]);
109        }
110    }
111
112    if default_included == Some(true)
113        || parents
114            .iter()
115            .any(|parent| parent.default_included == Some(true))
116    {
117        return Decision::Allow {
118            matched_by: MatchedBy::DefaultIncluded,
119        };
120    }
121    if default_included.is_none() {
122        return Decision::Deny {
123            reason: DenyReason::UnknownEntity {
124                entity: entity.clone(),
125            },
126        };
127    }
128    not_assigned(entity, user_id, user_roles)
129}
130
131// Why: a declared level that did not match closes the cascade, so only the
132// levels up to and including it may still admit the subject by default. The
133// entity is known through that level, so an absent sentinel row is
134// `NotAssigned`, never `UnknownEntity`.
135fn closed_decision(
136    entity: &EntityRef,
137    user_id: &UserId,
138    user_roles: &[String],
139    default_included: Option<bool>,
140    considered: &[ResolveParent<'_>],
141) -> Decision {
142    if default_included == Some(true)
143        || considered
144            .iter()
145            .any(|parent| parent.default_included == Some(true))
146    {
147        return Decision::Allow {
148            matched_by: MatchedBy::DefaultIncluded,
149        };
150    }
151    not_assigned(entity, user_id, user_roles)
152}
153
154fn not_assigned(entity: &EntityRef, user_id: &UserId, user_roles: &[String]) -> Decision {
155    Decision::Deny {
156        reason: DenyReason::NotAssigned {
157            entity: entity.clone(),
158            user_id: user_id.clone(),
159            roles: user_roles.to_vec(),
160        },
161    }
162}
163
164fn ladder(dimensions: &[SubjectDimension]) -> Vec<(RuleType, u16)> {
165    let mut bands = vec![
166        (RuleType::USER, USER_PRECEDENCE),
167        (RuleType::ROLE, ROLE_PRECEDENCE),
168    ];
169    bands.extend(
170        dimensions
171            .iter()
172            .filter(|d| d.rule_type != RuleType::USER && d.rule_type != RuleType::ROLE)
173            .map(|d| (d.rule_type.clone(), d.precedence)),
174    );
175    bands.sort_by_key(|&(_, precedence)| precedence);
176    bands
177}
178
179struct Subject<'a> {
180    user_id: &'a UserId,
181    user_roles: &'a [String],
182    attributes: &'a SubjectAttributes,
183    ladder: &'a [(RuleType, u16)],
184}
185
186impl Subject<'_> {
187    fn matches(&self, rule: &AccessRule) -> bool {
188        if rule.rule_type == RuleType::USER {
189            return rule.rule_value == self.user_id.as_str();
190        }
191        let held = if rule.rule_type == RuleType::ROLE {
192            self.user_roles
193        } else {
194            self.attributes.values(&rule.rule_type)
195        };
196        held.iter().any(|value| value == &rule.rule_value)
197    }
198}
199
200fn match_ruleset(
201    target: &EntityRef,
202    ruleset: &[AccessRule],
203    subject: &Subject<'_>,
204) -> Option<Decision> {
205    for (rule_type, _) in subject.ladder {
206        let in_band = |r: &&AccessRule| r.rule_type == *rule_type && subject.matches(r);
207
208        if let Some(rule) = ruleset
209            .iter()
210            .find(|r| in_band(r) && r.access == Access::Deny)
211        {
212            return Some(deny_for(target, subject, rule));
213        }
214        if let Some(rule) = ruleset
215            .iter()
216            .find(|r| in_band(r) && r.access == Access::Allow)
217        {
218            return Some(allow_for(rule));
219        }
220    }
221    None
222}
223
224fn deny_for(target: &EntityRef, subject: &Subject<'_>, rule: &AccessRule) -> Decision {
225    let reason = if rule.rule_type == RuleType::USER {
226        DenyReason::UserDeny {
227            entity: target.clone(),
228            user_id: subject.user_id.clone(),
229            justification: rule.justification.clone(),
230        }
231    } else if rule.rule_type == RuleType::ROLE {
232        DenyReason::RoleDeny {
233            entity: target.clone(),
234            role: rule.rule_value.clone(),
235            justification: rule.justification.clone(),
236        }
237    } else {
238        DenyReason::AttributeDeny {
239            entity: target.clone(),
240            rule_type: rule.rule_type.clone(),
241            value: rule.rule_value.clone(),
242            justification: rule.justification.clone(),
243        }
244    };
245    Decision::Deny { reason }
246}
247
248fn allow_for(rule: &AccessRule) -> Decision {
249    let matched_by = if rule.rule_type == RuleType::USER {
250        MatchedBy::UserAllow
251    } else if rule.rule_type == RuleType::ROLE {
252        MatchedBy::RoleAllow {
253            role: rule.rule_value.clone(),
254        }
255    } else {
256        MatchedBy::AttributeAllow {
257            rule_type: rule.rule_type.clone(),
258            value: rule.rule_value.clone(),
259        }
260    };
261    Decision::Allow { matched_by }
262}