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    /// The user's values for each extension-declared dimension, gathered by
60    /// [`gather_subject_attributes`][super::subject::gather_subject_attributes].
61    pub attributes: &'a SubjectAttributes,
62    /// Extension dimensions to interleave into the precedence ladder. Passed
63    /// in rather than read from the inventory so `resolve` stays pure and
64    /// unit-testable; pass `&[]` for core-only `user > role` behaviour.
65    pub dimensions: &'a [SubjectDimension],
66}
67
68/// Resolves a decision with parent inheritance on the crate-head deny-overrides
69/// model.
70///
71/// A child deny overrides a parent allow, a nearer rule overrides a farther one
72/// within the same precedence band, and a parent grant cascades to the child
73/// only when the child declares no rules at all — a child that declares any
74/// rule owns its decision and is closed to roles it does not name. An unknown
75/// child entity (`default_included == None`) yields
76/// [`DenyReason::UnknownEntity`] unless a rule or a parent's `default_included`
77/// grants access.
78#[must_use]
79pub fn resolve(input: ResolveInput<'_>) -> Decision {
80    let ResolveInput {
81        entity,
82        rules,
83        user_id,
84        user_roles,
85        default_included,
86        parents,
87        attributes,
88        dimensions,
89    } = input;
90
91    let ladder = ladder(dimensions);
92    let subject = Subject {
93        user_id,
94        user_roles,
95        attributes,
96        ladder: &ladder,
97    };
98
99    if let Some(decision) = match_ruleset(entity, rules, &subject) {
100        return decision;
101    }
102    // A declared ruleset is authoritative: an entity that names its own roles is
103    // closed to every role it does not name. `match_ruleset` cannot distinguish
104    // "no rule matches you" from "no rules exist", so only an entity with no rules
105    // of its own defers to its parents.
106    let parents = if rules.is_empty() { parents } else { &[] };
107
108    for parent in parents {
109        if let Some(decision) = match_ruleset(parent.entity, parent.rules, &subject) {
110            return decision;
111        }
112    }
113
114    if default_included == Some(true) {
115        return Decision::Allow {
116            matched_by: MatchedBy::DefaultIncluded,
117        };
118    }
119    if parents
120        .iter()
121        .any(|parent| parent.default_included == Some(true))
122    {
123        return Decision::Allow {
124            matched_by: MatchedBy::DefaultIncluded,
125        };
126    }
127
128    if default_included.is_none() {
129        return Decision::Deny {
130            reason: DenyReason::UnknownEntity {
131                entity: entity.clone(),
132            },
133        };
134    }
135    Decision::Deny {
136        reason: DenyReason::NotAssigned {
137            entity: entity.clone(),
138            user_id: user_id.clone(),
139            roles: user_roles.to_vec(),
140        },
141    }
142}
143
144/// The precedence ladder for one call: core's built-ins unioned with the
145/// caller-supplied dimensions, tightest-binding first.
146///
147/// A dimension that re-declares `user` or `role` is ignored rather than
148/// duplicating a band — core owns those two slugs and their precedence.
149/// The sort is stable, so dimensions sharing a precedence keep registration
150/// order.
151fn ladder(dimensions: &[SubjectDimension]) -> Vec<(RuleType, u16)> {
152    let mut bands = vec![
153        (RuleType::USER, USER_PRECEDENCE),
154        (RuleType::ROLE, ROLE_PRECEDENCE),
155    ];
156    bands.extend(
157        dimensions
158            .iter()
159            .filter(|d| d.rule_type != RuleType::USER && d.rule_type != RuleType::ROLE)
160            .map(|d| (d.rule_type.clone(), d.precedence)),
161    );
162    bands.sort_by_key(|&(_, precedence)| precedence);
163    bands
164}
165
166/// Everything about the requesting subject the band matcher needs, bundled so
167/// the ladder is built once per [`resolve`] rather than once per ruleset.
168struct Subject<'a> {
169    user_id: &'a UserId,
170    user_roles: &'a [String],
171    attributes: &'a SubjectAttributes,
172    ladder: &'a [(RuleType, u16)],
173}
174
175impl Subject<'_> {
176    /// Whether `rule` targets this subject in its own band.
177    fn matches(&self, rule: &AccessRule) -> bool {
178        if rule.rule_type == RuleType::USER {
179            return rule.rule_value == self.user_id.as_str();
180        }
181        let held = if rule.rule_type == RuleType::ROLE {
182            self.user_roles
183        } else {
184            self.attributes.values(&rule.rule_type)
185        };
186        held.iter().any(|value| value == &rule.rule_value)
187    }
188}
189
190/// Walks the precedence ladder tightest band first, denying before allowing
191/// within each band. The first band that matches decides; a band the subject
192/// holds no value for cannot match and falls through.
193fn match_ruleset(
194    target: &EntityRef,
195    ruleset: &[AccessRule],
196    subject: &Subject<'_>,
197) -> Option<Decision> {
198    for (rule_type, _) in subject.ladder {
199        let in_band = |r: &&AccessRule| r.rule_type == *rule_type && subject.matches(r);
200
201        if let Some(rule) = ruleset
202            .iter()
203            .find(|r| in_band(r) && r.access == Access::Deny)
204        {
205            return Some(deny_for(target, subject, rule));
206        }
207        if let Some(rule) = ruleset
208            .iter()
209            .find(|r| in_band(r) && r.access == Access::Allow)
210        {
211            return Some(allow_for(rule));
212        }
213    }
214    None
215}
216
217/// Built-ins keep their dedicated variants so existing `governance_decisions`
218/// audit JSON stays stable; extension dimensions report through the generic
219/// attribute variants.
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}