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
131fn closed_decision(
132    entity: &EntityRef,
133    user_id: &UserId,
134    user_roles: &[String],
135    default_included: Option<bool>,
136    considered: &[ResolveParent<'_>],
137) -> Decision {
138    if default_included == Some(true)
139        || considered
140            .iter()
141            .any(|parent| parent.default_included == Some(true))
142    {
143        return Decision::Allow {
144            matched_by: MatchedBy::DefaultIncluded,
145        };
146    }
147    not_assigned(entity, user_id, user_roles)
148}
149
150fn not_assigned(entity: &EntityRef, user_id: &UserId, user_roles: &[String]) -> Decision {
151    Decision::Deny {
152        reason: DenyReason::NotAssigned {
153            entity: entity.clone(),
154            user_id: user_id.clone(),
155            roles: user_roles.to_vec(),
156        },
157    }
158}
159
160fn ladder(dimensions: &[SubjectDimension]) -> Vec<(RuleType, u16)> {
161    let mut bands = vec![
162        (RuleType::USER, USER_PRECEDENCE),
163        (RuleType::ROLE, ROLE_PRECEDENCE),
164    ];
165    bands.extend(
166        dimensions
167            .iter()
168            .filter(|d| d.rule_type != RuleType::USER && d.rule_type != RuleType::ROLE)
169            .map(|d| (d.rule_type.clone(), d.precedence)),
170    );
171    bands.sort_by_key(|&(_, precedence)| precedence);
172    bands
173}
174
175struct Subject<'a> {
176    user_id: &'a UserId,
177    user_roles: &'a [String],
178    attributes: &'a SubjectAttributes,
179    ladder: &'a [(RuleType, u16)],
180}
181
182impl Subject<'_> {
183    fn matches(&self, rule: &AccessRule) -> bool {
184        if rule.rule_type == RuleType::USER {
185            return rule.rule_value == self.user_id.as_str();
186        }
187        let held = if rule.rule_type == RuleType::ROLE {
188            self.user_roles
189        } else {
190            self.attributes.values(&rule.rule_type)
191        };
192        held.iter().any(|value| value == &rule.rule_value)
193    }
194}
195
196fn match_ruleset(
197    target: &EntityRef,
198    ruleset: &[AccessRule],
199    subject: &Subject<'_>,
200) -> Option<Decision> {
201    for (rule_type, _) in subject.ladder {
202        let in_band = |r: &&AccessRule| r.rule_type == *rule_type && subject.matches(r);
203
204        if let Some(rule) = ruleset
205            .iter()
206            .find(|r| in_band(r) && r.access == Access::Deny)
207        {
208            return Some(deny_for(target, subject, rule));
209        }
210        if let Some(rule) = ruleset
211            .iter()
212            .find(|r| in_band(r) && r.access == Access::Allow)
213        {
214            return Some(allow_for(rule));
215        }
216    }
217    None
218}
219
220fn deny_for(target: &EntityRef, subject: &Subject<'_>, rule: &AccessRule) -> Decision {
221    let reason = if rule.rule_type == RuleType::USER {
222        DenyReason::UserDeny {
223            entity: target.clone(),
224            user_id: subject.user_id.clone(),
225            justification: rule.justification.clone(),
226        }
227    } else if rule.rule_type == RuleType::ROLE {
228        DenyReason::RoleDeny {
229            entity: target.clone(),
230            role: rule.rule_value.clone(),
231            justification: rule.justification.clone(),
232        }
233    } else {
234        DenyReason::AttributeDeny {
235            entity: target.clone(),
236            rule_type: rule.rule_type.clone(),
237            value: rule.rule_value.clone(),
238            justification: rule.justification.clone(),
239        }
240    };
241    Decision::Deny { reason }
242}
243
244fn allow_for(rule: &AccessRule) -> Decision {
245    let matched_by = if rule.rule_type == RuleType::USER {
246        MatchedBy::UserAllow
247    } else if rule.rule_type == RuleType::ROLE {
248        MatchedBy::RoleAllow {
249            role: rule.rule_value.clone(),
250        }
251    } else {
252        MatchedBy::AttributeAllow {
253            rule_type: rule.rule_type.clone(),
254            value: rule.rule_value.clone(),
255        }
256    };
257    Decision::Allow { matched_by }
258}