Skip to main content

rskit_authz/engine/
evaluate.rs

1//! Runtime evaluation: deny-override, default-deny RBAC role grants and ABAC policy matching.
2
3use std::collections::{HashMap, HashSet};
4
5use rskit_errors::{AppError, AppResult};
6use serde_json::Value;
7
8use super::model::{
9    AttributeSource, Condition, Decision, Effect, Operator, Permission, Policy, Request, Role,
10};
11use crate::checker::Checker;
12use crate::matcher::match_any;
13
14/// Canonical RBAC + ABAC engine with deny-override and default-deny semantics.
15#[derive(Debug, Clone)]
16pub struct Engine {
17    roles: HashMap<String, Role>,
18    policies: Vec<Policy>,
19}
20
21impl Engine {
22    /// Construct an engine from roles and policies.
23    pub fn new(roles: Vec<Role>, policies: Vec<Policy>) -> AppResult<Self> {
24        let mut indexed_roles = HashMap::with_capacity(roles.len());
25        for role in roles {
26            if role.name.is_empty() {
27                return Err(AppError::invalid_input(
28                    "role.name",
29                    "role name is required",
30                ));
31            }
32            if indexed_roles.insert(role.name.clone(), role).is_some() {
33                return Err(AppError::invalid_input("role.name", "duplicate role"));
34            }
35        }
36        Ok(Self {
37            roles: indexed_roles,
38            policies,
39        })
40    }
41
42    /// Evaluate a request.
43    ///
44    /// Deny policies are evaluated before role grants and allow policies.
45    /// Object-level constraints that should limit a role should be attached to the role [`Permission`];
46    /// constraints that must override any broad grant should be modeled as deny policies.
47    #[must_use]
48    pub fn authorize(&self, request: &Request) -> Decision {
49        // 1. Deny policies always take precedence.
50        for policy in &self.policies {
51            if policy.effect == Effect::Deny && policy.matches(request) {
52                return Decision {
53                    allowed: false,
54                    reason: String::from("denied by policy"),
55                };
56            }
57        }
58
59        // 2. RBAC role grants are checked before ABAC allow policies (cross-kit canonical order).
60        if self.role_allows(request, &request.subject.roles, &mut HashSet::new()) {
61            return Decision {
62                allowed: true,
63                reason: String::from("allowed by role grant"),
64            };
65        }
66
67        // 3. ABAC allow policies.
68        for policy in &self.policies {
69            if policy.effect == Effect::Allow && policy.matches(request) {
70                return Decision {
71                    allowed: true,
72                    reason: String::from("allowed by policy"),
73                };
74            }
75        }
76
77        Decision {
78            allowed: false,
79            reason: String::from("default deny"),
80        }
81    }
82
83    fn role_allows(
84        &self,
85        request: &Request,
86        role_names: &[String],
87        visited: &mut HashSet<String>,
88    ) -> bool {
89        for role_name in role_names {
90            if !visited.insert(role_name.clone()) {
91                continue;
92            }
93            let Some(role) = self.roles.get(role_name) else {
94                continue;
95            };
96            if role
97                .permissions
98                .iter()
99                .any(|permission| permission.matches_request(request))
100            {
101                return true;
102            }
103            if self.role_allows(request, &role.inherits, visited) {
104                return true;
105            }
106        }
107        false
108    }
109}
110
111impl Checker for Engine {
112    fn authorize(&self, request: &Request) -> Decision {
113        Self::authorize(self, request)
114    }
115}
116
117impl Permission {
118    fn matches_request(&self, request: &Request) -> bool {
119        self.matches(&request.resource.resource_type, &request.action)
120            && self
121                .conditions
122                .iter()
123                .all(|condition| condition.matches(request))
124    }
125}
126
127impl Policy {
128    fn matches(&self, request: &Request) -> bool {
129        if !match_dimension(&self.resources, &request.resource.resource_type)
130            || !match_dimension(&self.actions, &request.action)
131        {
132            return false;
133        }
134        self.conditions
135            .iter()
136            .all(|condition| condition.matches(request))
137    }
138}
139
140impl Condition {
141    fn matches(&self, request: &Request) -> bool {
142        let Some(actual) = attribute_value(request, self.source, &self.key) else {
143            return false;
144        };
145
146        if let (Some(compare_source), Some(compare_key)) =
147            (self.compare_source, self.compare_key.as_deref())
148        {
149            let Some(other) = attribute_value(request, compare_source, compare_key) else {
150                return false;
151            };
152            // Cross-field comparison: exactly one dynamic value, no Vec allocation.
153            match self.operator {
154                Operator::Equals | Operator::OneOf => actual == other,
155                Operator::NotEquals => actual != other,
156            }
157        } else {
158            // Literal-value comparison: compare by reference, no clone.
159            match self.operator {
160                Operator::Equals => self.values.len() == 1 && actual == self.values[0],
161                Operator::NotEquals => self.values.len() == 1 && actual != self.values[0],
162                Operator::OneOf => self.values.iter().any(|v| v == &actual),
163            }
164        }
165    }
166}
167
168fn match_dimension(patterns: &[String], value: &str) -> bool {
169    !patterns.is_empty() && match_any(patterns, value)
170}
171
172fn attribute_value(request: &Request, source: AttributeSource, key: &str) -> Option<Value> {
173    match source {
174        AttributeSource::Subject => {
175            if key == "id" {
176                return (!request.subject.id.is_empty())
177                    .then(|| Value::String(request.subject.id.clone()));
178            }
179            request.subject.attributes.get(key).cloned()
180        }
181        AttributeSource::Resource => match key {
182            "id" => (!request.resource.id.is_empty())
183                .then(|| Value::String(request.resource.id.clone())),
184            "type" => (!request.resource.resource_type.is_empty())
185                .then(|| Value::String(request.resource.resource_type.clone())),
186            _ => request.resource.attributes.get(key).cloned(),
187        },
188        AttributeSource::Context => request.context.get(key).cloned(),
189    }
190}