Skip to main content

corium_authz/
eval.rs

1//! The bounded relationship search that answers one check.
2//!
3//! `Check(subject, action, object, database, at_authz_t)` reduces to: resolve
4//! the action to candidate relations, then ask whether any of the request's
5//! subjects reaches the target object through one of them. The search is a
6//! breadth-first walk over `(relation, object)` goals with three hard bounds —
7//! maximum depth, maximum visited goals, and a visited set that also serves as
8//! cycle detection — because policy data is user-authored and a cyclic
9//! `parent` chain must cost a bounded amount, not hang a request.
10
11use std::collections::{BTreeSet, VecDeque};
12use std::sync::Arc;
13
14use corium_protocol::authz::ViewFilter;
15
16use crate::model::{ObjectRef, SubjectRef};
17use crate::policy::Policy;
18use crate::view;
19
20/// Bounds on one check's search.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct Limits {
23    /// Maximum number of relation hops from a target object.
24    pub max_depth: usize,
25    /// Maximum `(relation, object)` goals visited across the whole check.
26    pub max_visited: usize,
27}
28
29impl Default for Limits {
30    fn default() -> Self {
31        Self {
32            max_depth: 8,
33            max_visited: 10_000,
34        }
35    }
36}
37
38/// One edge of a matched relationship path, outermost (the target) first.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct PathStep {
41    /// Relation walked.
42    pub relation: String,
43    /// Object it was walked on.
44    pub object: ObjectRef,
45}
46
47impl std::fmt::Display for PathStep {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(formatter, "{}#{}", self.object, self.relation)
50    }
51}
52
53/// A successful path: the relation on the target that was satisfied, the
54/// subject that satisfied it, and the goals walked to get there.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Match {
57    /// Relation on the target object that the action required.
58    pub relation: String,
59    /// The target object.
60    pub object: ObjectRef,
61    /// Subject that satisfied it.
62    pub subject: SubjectRef,
63    /// Goals walked from the target to the matching tuple.
64    pub path: Vec<PathStep>,
65}
66
67impl Match {
68    /// Renders the path as `object#relation -> object#relation -> subject`,
69    /// the form audit lines carry.
70    #[must_use]
71    pub fn render_path(&self) -> String {
72        let mut parts: Vec<String> = self.path.iter().map(ToString::to_string).collect();
73        parts.push(self.subject.to_string());
74        parts.join(" -> ")
75    }
76}
77
78/// Why a check failed.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum Denial {
81    /// No permission entity maps the action onto any relation.
82    NoPermission {
83        /// Object type checked.
84        object_type: String,
85        /// Action name checked.
86        action: String,
87    },
88    /// Relations were known but no path reached the subject.
89    NoPath {
90        /// Relations that would have satisfied the action.
91        relations: Vec<String>,
92        /// Objects that were checked.
93        objects: Vec<String>,
94    },
95    /// The search hit its bounds before finding a path. Reported separately
96    /// from `NoPath` because it is an operational signal, not a policy answer.
97    Exhausted {
98        /// Goals visited before giving up.
99        visited: usize,
100    },
101}
102
103impl std::fmt::Display for Denial {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::NoPermission {
107                object_type,
108                action,
109            } => write!(
110                formatter,
111                "no permission maps action {action:?} on object type {object_type:?}"
112            ),
113            Self::NoPath { relations, objects } => write!(
114                formatter,
115                "no relationship path grants {relations:?} on {objects:?}"
116            ),
117            Self::Exhausted { visited } => write!(
118                formatter,
119                "relationship search exhausted its budget after {visited} goals"
120            ),
121        }
122    }
123}
124
125/// The outcome of one bounded check.
126#[derive(Clone, Debug)]
127pub enum Outcome {
128    /// Permitted with full visibility.
129    Allow {
130        /// Every relation that granted the access.
131        matches: Vec<Match>,
132    },
133    /// Permitted through a view filter.
134    AllowFiltered {
135        /// Every relation that granted the access.
136        matches: Vec<Match>,
137        /// The combined filter.
138        filter: Arc<dyn ViewFilter>,
139        /// Names of the views that were combined.
140        views: Vec<String>,
141    },
142    /// Refused.
143    Deny(Denial),
144}
145
146impl Outcome {
147    /// Whether the access was permitted (filtered or not).
148    #[must_use]
149    pub fn is_allowed(&self) -> bool {
150        !matches!(self, Self::Deny(_))
151    }
152
153    /// The matched paths, when the check succeeded.
154    #[must_use]
155    pub fn matches(&self) -> &[Match] {
156        match self {
157            Self::Allow { matches } | Self::AllowFiltered { matches, .. } => matches,
158            Self::Deny(_) => &[],
159        }
160    }
161}
162
163/// The request side of a check: the subjects a principal expands to, the
164/// relations to look for, and the objects to look on.
165#[derive(Clone, Debug)]
166pub struct Query {
167    /// Subjects the principal expands to (`user:alice`, `role:admin`, …).
168    pub subjects: BTreeSet<ObjectRef>,
169    /// Relations that satisfy the action, from the permission map.
170    pub relations: BTreeSet<String>,
171    /// Action name, for the denial message.
172    pub action: String,
173    /// Objects the access targets; any one granting is enough.
174    pub objects: Vec<ObjectRef>,
175    /// Relations a plain (non-userset) group subject is expanded through.
176    pub expand_relations: Vec<String>,
177    /// Search bounds.
178    pub limits: Limits,
179}
180
181/// Runs the bounded search for `query` against `policy`.
182#[must_use]
183pub fn check(policy: &Policy, query: &Query) -> Outcome {
184    if query.relations.is_empty() {
185        return Outcome::Deny(Denial::NoPermission {
186            object_type: query
187                .objects
188                .first()
189                .map_or_else(|| "*".to_owned(), |object| object.kind.clone()),
190            action: query.action.clone(),
191        });
192    }
193
194    let mut budget = Budget {
195        visited: 0,
196        max_visited: query.limits.max_visited,
197    };
198    let mut matches = Vec::new();
199    let mut exhausted = false;
200    // One root goal per (relation, object) pair: a view binding attaches to
201    // the relation that satisfied the action on the target, so successes are
202    // collected per root goal rather than per leaf tuple.
203    'roots: for object in &query.objects {
204        for relation in &query.relations {
205            match Walk::new(policy, query).run(relation, object, &mut budget) {
206                Search::Found(found) => matches.push(found),
207                Search::NotFound => {}
208                Search::Exhausted => {
209                    exhausted = true;
210                    break 'roots;
211                }
212            }
213        }
214    }
215
216    if matches.is_empty() {
217        return Outcome::Deny(if exhausted {
218            Denial::Exhausted {
219                visited: budget.visited,
220            }
221        } else {
222            Denial::NoPath {
223                relations: query.relations.iter().cloned().collect(),
224                objects: query.objects.iter().map(ToString::to_string).collect(),
225            }
226        });
227    }
228
229    combine_views(policy, matches)
230}
231
232/// Applies the view bindings of every successful path.
233///
234/// Conservative by construction: filters intersect, and a path that declares
235/// no binding neither widens nor narrows. Only a binding explicitly marked
236/// `:authz.binding/unfiltered` grants full visibility, which is the documented
237/// escape hatch for relations like `owner` that must see everything.
238fn combine_views(policy: &Policy, matches: Vec<Match>) -> Outcome {
239    let mut filters = Vec::new();
240    let mut names = Vec::new();
241    for found in &matches {
242        let Some(binding) = policy.binding_for(&found.relation, &found.object) else {
243            continue;
244        };
245        if binding.unfiltered {
246            return Outcome::Allow { matches };
247        }
248        if let Some(name) = &binding.view
249            && let Some(filter) = policy.view(name)
250        {
251            filters.push(Arc::clone(filter));
252            names.push(name.clone());
253        }
254    }
255    match view::combine(filters) {
256        None => Outcome::Allow { matches },
257        Some(filter) => Outcome::AllowFiltered {
258            matches,
259            filter,
260            views: names,
261        },
262    }
263}
264
265/// The shared visit budget for one check, spanning every root goal.
266struct Budget {
267    visited: usize,
268    max_visited: usize,
269}
270
271impl Budget {
272    fn spend(&mut self) -> bool {
273        if self.visited >= self.max_visited {
274            return false;
275        }
276        self.visited += 1;
277        true
278    }
279}
280
281enum Search {
282    Found(Match),
283    NotFound,
284    Exhausted,
285}
286
287/// A queued goal, with a parent pointer so the matched path can be rebuilt
288/// without cloning a path per queued entry.
289struct Node {
290    step: PathStep,
291    parent: Option<usize>,
292    depth: usize,
293}
294
295/// Breadth-first walk for one `(relation, object)` root goal.
296struct Walk<'a> {
297    policy: &'a Policy,
298    query: &'a Query,
299    nodes: Vec<Node>,
300    queue: VecDeque<usize>,
301    visited: BTreeSet<(String, ObjectRef)>,
302}
303
304impl<'a> Walk<'a> {
305    fn new(policy: &'a Policy, query: &'a Query) -> Self {
306        Self {
307            policy,
308            query,
309            nodes: Vec::new(),
310            queue: VecDeque::new(),
311            visited: BTreeSet::new(),
312        }
313    }
314
315    fn run(mut self, relation: &str, object: &ObjectRef, budget: &mut Budget) -> Search {
316        self.push(relation.to_owned(), object.clone(), None, 0);
317        while let Some(index) = self.queue.pop_front() {
318            if !budget.spend() {
319                return Search::Exhausted;
320            }
321            let goal = PathStep {
322                relation: self.nodes[index].step.relation.clone(),
323                object: self.nodes[index].step.object.clone(),
324            };
325            let depth = self.nodes[index].depth;
326
327            for subject in self.policy.subjects_for(&goal.object, &goal.relation) {
328                let subject = subject.clone();
329                match &subject.relation {
330                    // `group:eng#member writer database:music`: the relation
331                    // named on the subject side becomes the next goal.
332                    Some(userset) => {
333                        self.push(
334                            userset.clone(),
335                            subject.object.clone(),
336                            Some(index),
337                            depth + 1,
338                        );
339                    }
340                    None if self.holds(&subject) => {
341                        return Search::Found(Match {
342                            relation: relation.to_owned(),
343                            object: object.clone(),
344                            subject,
345                            path: self.path_of(index),
346                        });
347                    }
348                    // `group:eng writer database:music` written without the
349                    // `#member` suffix: expand the named object through the
350                    // configured membership relations.
351                    None => {
352                        for expansion in &self.query.expand_relations {
353                            self.push(
354                                expansion.clone(),
355                                subject.object.clone(),
356                                Some(index),
357                                depth + 1,
358                            );
359                        }
360                    }
361                }
362            }
363
364            for rewrite in self.policy.rewrites_for(&goal.relation, &goal.object.kind) {
365                let on_relation = rewrite.on_relation.clone();
366                let parents: Vec<ObjectRef> = self
367                    .policy
368                    .subjects_for(&goal.object, &rewrite.via_relation)
369                    .into_iter()
370                    .map(|parent| parent.object.clone())
371                    .collect();
372                for parent in parents {
373                    self.push(on_relation.clone(), parent, Some(index), depth + 1);
374                }
375            }
376        }
377        Search::NotFound
378    }
379
380    /// Whether the request's subject set satisfies a tuple's subject. A tuple
381    /// written against `type:*` matches any held subject of that type, which is
382    /// how "everyone" and "every authenticated caller" are expressed.
383    fn holds(&self, subject: &SubjectRef) -> bool {
384        if self.query.subjects.contains(&subject.object) {
385            return true;
386        }
387        subject.object.is_wildcard()
388            && self
389                .query
390                .subjects
391                .iter()
392                .any(|held| held.kind == subject.object.kind)
393    }
394
395    fn push(&mut self, relation: String, object: ObjectRef, parent: Option<usize>, depth: usize) {
396        if depth > self.query.limits.max_depth {
397            return;
398        }
399        // The visited set is both the cycle breaker and the work deduplicator:
400        // a `parent`-loop in policy data revisits nothing.
401        if !self.visited.insert((relation.clone(), object.clone())) {
402            return;
403        }
404        self.nodes.push(Node {
405            step: PathStep { relation, object },
406            parent,
407            depth,
408        });
409        self.queue.push_back(self.nodes.len() - 1);
410    }
411
412    fn path_of(&self, mut index: usize) -> Vec<PathStep> {
413        let mut path = Vec::new();
414        loop {
415            path.push(self.nodes[index].step.clone());
416            match self.nodes[index].parent {
417                Some(parent) => index = parent,
418                None => break,
419            }
420        }
421        path.reverse();
422        path
423    }
424}