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//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use systemprompt_identifiers::UserId;
19
20use super::types::{Access, AccessRule, Decision, DenyReason, EntityRef, MatchedBy, RuleType};
21
22/// A parent entity whose rules cascade onto the child being resolved.
23///
24/// Parents are ordered nearest-first: the entity directly above the child
25/// comes before its grandparent, so a closer grant wins over a more distant
26/// one within the same precedence band.
27#[derive(Debug, Clone, Copy)]
28pub struct ResolveParent<'a> {
29    pub entity: &'a EntityRef,
30    pub rules: &'a [AccessRule],
31    pub default_included: Option<bool>,
32}
33
34/// Inputs to [`resolve`]. Bundled so the function stays under the clippy
35/// argument-count limit and so call sites can read top-to-bottom.
36#[derive(Debug, Clone, Copy)]
37pub struct ResolveInput<'a> {
38    pub entity: &'a EntityRef,
39    pub rules: &'a [AccessRule],
40    pub user_id: &'a UserId,
41    pub user_roles: &'a [String],
42    pub default_included: Option<bool>,
43    pub parents: &'a [ResolveParent<'a>],
44}
45
46/// Resolves a decision with parent inheritance on the crate-head deny-overrides
47/// model.
48///
49/// A child deny overrides a parent allow, a nearer rule overrides a farther one
50/// within the same precedence band, and a parent grant cascades to the child
51/// only when no nearer rule matches. An unknown child entity
52/// (`default_included == None`) yields [`DenyReason::UnknownEntity`] unless a
53/// rule or a parent's `default_included` grants access.
54#[must_use]
55pub fn resolve(input: ResolveInput<'_>) -> Decision {
56    let ResolveInput {
57        entity,
58        rules,
59        user_id,
60        user_roles,
61        default_included,
62        parents,
63    } = input;
64
65    if let Some(decision) = match_ruleset(entity, rules, user_id, user_roles) {
66        return decision;
67    }
68    for parent in parents {
69        if let Some(decision) = match_ruleset(parent.entity, parent.rules, user_id, user_roles) {
70            return decision;
71        }
72    }
73
74    if default_included == Some(true) {
75        return Decision::Allow {
76            matched_by: MatchedBy::DefaultIncluded,
77        };
78    }
79    if parents
80        .iter()
81        .any(|parent| parent.default_included == Some(true))
82    {
83        return Decision::Allow {
84            matched_by: MatchedBy::DefaultIncluded,
85        };
86    }
87
88    if default_included.is_none() {
89        return Decision::Deny {
90            reason: DenyReason::UnknownEntity {
91                entity: entity.clone(),
92            },
93        };
94    }
95    Decision::Deny {
96        reason: DenyReason::NotAssigned {
97            entity: entity.clone(),
98            user_id: user_id.clone(),
99            roles: user_roles.to_vec(),
100        },
101    }
102}
103
104fn match_ruleset(
105    target: &EntityRef,
106    ruleset: &[AccessRule],
107    user_id: &UserId,
108    user_roles: &[String],
109) -> Option<Decision> {
110    let user_match =
111        |r: &AccessRule| r.rule_type == RuleType::User && r.rule_value == user_id.as_str();
112    let role_match = |r: &AccessRule| {
113        r.rule_type == RuleType::Role && user_roles.iter().any(|role| role == &r.rule_value)
114    };
115
116    if let Some(rule) = ruleset
117        .iter()
118        .find(|r| user_match(r) && r.access == Access::Deny)
119    {
120        return Some(Decision::Deny {
121            reason: DenyReason::UserDeny {
122                entity: target.clone(),
123                user_id: user_id.clone(),
124                justification: rule.justification.clone(),
125            },
126        });
127    }
128    if ruleset
129        .iter()
130        .any(|r| user_match(r) && r.access == Access::Allow)
131    {
132        return Some(Decision::Allow {
133            matched_by: MatchedBy::UserAllow,
134        });
135    }
136    if let Some(rule) = ruleset
137        .iter()
138        .find(|r| role_match(r) && r.access == Access::Deny)
139    {
140        return Some(Decision::Deny {
141            reason: DenyReason::RoleDeny {
142                entity: target.clone(),
143                role: rule.rule_value.clone(),
144                justification: rule.justification.clone(),
145            },
146        });
147    }
148    if let Some(rule) = ruleset
149        .iter()
150        .find(|r| role_match(r) && r.access == Access::Allow)
151    {
152        return Some(Decision::Allow {
153            matched_by: MatchedBy::RoleAllow {
154                role: rule.rule_value.clone(),
155            },
156        });
157    }
158    None
159}