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/// Resolves a decision with parent inheritance on the crate-head deny-overrides
44/// model.
45///
46/// A child deny overrides a parent allow, a nearer rule overrides a farther one
47/// within the same precedence band, and a parent grant cascades to the child
48/// only when no nearer rule matches. An unknown child entity
49/// (`default_included == None`) yields [`DenyReason::UnknownEntity`] unless a
50/// rule or a parent's `default_included` grants access.
51#[must_use]
52pub fn resolve(input: ResolveInput<'_>) -> Decision {
53    let ResolveInput {
54        entity,
55        rules,
56        user_id,
57        user_roles,
58        default_included,
59        parents,
60    } = input;
61
62    if let Some(decision) = match_ruleset(entity, rules, user_id, user_roles) {
63        return decision;
64    }
65    for parent in parents {
66        if let Some(decision) = match_ruleset(parent.entity, parent.rules, user_id, user_roles) {
67            return decision;
68        }
69    }
70
71    if default_included == Some(true) {
72        return Decision::Allow {
73            matched_by: MatchedBy::DefaultIncluded,
74        };
75    }
76    if parents
77        .iter()
78        .any(|parent| parent.default_included == Some(true))
79    {
80        return Decision::Allow {
81            matched_by: MatchedBy::DefaultIncluded,
82        };
83    }
84
85    if default_included.is_none() {
86        return Decision::Deny {
87            reason: DenyReason::UnknownEntity {
88                entity: entity.clone(),
89            },
90        };
91    }
92    Decision::Deny {
93        reason: DenyReason::NotAssigned {
94            entity: entity.clone(),
95            user_id: user_id.clone(),
96            roles: user_roles.to_vec(),
97        },
98    }
99}
100
101fn match_ruleset(
102    target: &EntityRef,
103    ruleset: &[AccessRule],
104    user_id: &UserId,
105    user_roles: &[String],
106) -> Option<Decision> {
107    let user_match =
108        |r: &AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id.as_str();
109    let role_match = |r: &AccessRule| {
110        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
111    };
112
113    if let Some(rule) = ruleset
114        .iter()
115        .find(|r| user_match(r) && r.access == Access::Deny)
116    {
117        return Some(Decision::Deny {
118            reason: DenyReason::UserDeny {
119                entity: target.clone(),
120                user_id: user_id.clone(),
121                justification: rule.justification.clone(),
122            },
123        });
124    }
125    if ruleset
126        .iter()
127        .any(|r| user_match(r) && r.access == Access::Allow)
128    {
129        return Some(Decision::Allow {
130            matched_by: MatchedBy::UserAllow,
131        });
132    }
133    if let Some(rule) = ruleset
134        .iter()
135        .find(|r| role_match(r) && r.access == Access::Deny)
136    {
137        return Some(Decision::Deny {
138            reason: DenyReason::RoleDeny {
139                entity: target.clone(),
140                role: rule.rule_value.clone(),
141                justification: rule.justification.clone(),
142            },
143        });
144    }
145    if let Some(rule) = ruleset
146        .iter()
147        .find(|r| role_match(r) && r.access == Access::Allow)
148    {
149        return Some(Decision::Allow {
150            matched_by: MatchedBy::RoleAllow {
151                role: rule.rule_value.clone(),
152            },
153        });
154    }
155    None
156}