corium-authz 0.1.40

Self-hosted relationship-based (ReBAC) authorization over a Corium system database
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! The bounded relationship search that answers one check.
//!
//! `Check(subject, action, object, database, at_authz_t)` reduces to: resolve
//! the action to candidate relations, then ask whether any of the request's
//! subjects reaches the target object through one of them. The search is a
//! breadth-first walk over `(relation, object)` goals with three hard bounds —
//! maximum depth, maximum visited goals, and a visited set that also serves as
//! cycle detection — because policy data is user-authored and a cyclic
//! `parent` chain must cost a bounded amount, not hang a request.

use std::collections::{BTreeSet, VecDeque};
use std::sync::Arc;

use corium_protocol::authz::ViewFilter;

use crate::model::{ObjectRef, SubjectRef};
use crate::policy::Policy;
use crate::view;

/// Bounds on one check's search.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
    /// Maximum number of relation hops from a target object.
    pub max_depth: usize,
    /// Maximum `(relation, object)` goals visited across the whole check.
    pub max_visited: usize,
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            max_depth: 8,
            max_visited: 10_000,
        }
    }
}

/// One edge of a matched relationship path, outermost (the target) first.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathStep {
    /// Relation walked.
    pub relation: String,
    /// Object it was walked on.
    pub object: ObjectRef,
}

impl std::fmt::Display for PathStep {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "{}#{}", self.object, self.relation)
    }
}

/// A successful path: the relation on the target that was satisfied, the
/// subject that satisfied it, and the goals walked to get there.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match {
    /// Relation on the target object that the action required.
    pub relation: String,
    /// The target object.
    pub object: ObjectRef,
    /// Subject that satisfied it.
    pub subject: SubjectRef,
    /// Goals walked from the target to the matching tuple.
    pub path: Vec<PathStep>,
}

impl Match {
    /// Renders the path as `object#relation -> object#relation -> subject`,
    /// the form audit lines carry.
    #[must_use]
    pub fn render_path(&self) -> String {
        let mut parts: Vec<String> = self.path.iter().map(ToString::to_string).collect();
        parts.push(self.subject.to_string());
        parts.join(" -> ")
    }
}

/// Why a check failed.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Denial {
    /// No permission entity maps the action onto any relation.
    NoPermission {
        /// Object type checked.
        object_type: String,
        /// Action name checked.
        action: String,
    },
    /// Relations were known but no path reached the subject.
    NoPath {
        /// Relations that would have satisfied the action.
        relations: Vec<String>,
        /// Objects that were checked.
        objects: Vec<String>,
    },
    /// The search hit its bounds before finding a path. Reported separately
    /// from `NoPath` because it is an operational signal, not a policy answer.
    Exhausted {
        /// Goals visited before giving up.
        visited: usize,
    },
}

impl std::fmt::Display for Denial {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NoPermission {
                object_type,
                action,
            } => write!(
                formatter,
                "no permission maps action {action:?} on object type {object_type:?}"
            ),
            Self::NoPath { relations, objects } => write!(
                formatter,
                "no relationship path grants {relations:?} on {objects:?}"
            ),
            Self::Exhausted { visited } => write!(
                formatter,
                "relationship search exhausted its budget after {visited} goals"
            ),
        }
    }
}

/// The outcome of one bounded check.
#[derive(Clone, Debug)]
pub enum Outcome {
    /// Permitted with full visibility.
    Allow {
        /// Every relation that granted the access.
        matches: Vec<Match>,
    },
    /// Permitted through a view filter.
    AllowFiltered {
        /// Every relation that granted the access.
        matches: Vec<Match>,
        /// The combined filter.
        filter: Arc<dyn ViewFilter>,
        /// Names of the views that were combined.
        views: Vec<String>,
    },
    /// Refused.
    Deny(Denial),
}

impl Outcome {
    /// Whether the access was permitted (filtered or not).
    #[must_use]
    pub fn is_allowed(&self) -> bool {
        !matches!(self, Self::Deny(_))
    }

    /// The matched paths, when the check succeeded.
    #[must_use]
    pub fn matches(&self) -> &[Match] {
        match self {
            Self::Allow { matches } | Self::AllowFiltered { matches, .. } => matches,
            Self::Deny(_) => &[],
        }
    }
}

/// The request side of a check: the subjects a principal expands to, the
/// relations to look for, and the objects to look on.
#[derive(Clone, Debug)]
pub struct Query {
    /// Subjects the principal expands to (`user:alice`, `role:admin`, …).
    pub subjects: BTreeSet<ObjectRef>,
    /// Relations that satisfy the action, from the permission map.
    pub relations: BTreeSet<String>,
    /// Action name, for the denial message.
    pub action: String,
    /// Objects the access targets; any one granting is enough.
    pub objects: Vec<ObjectRef>,
    /// Relations a plain (non-userset) group subject is expanded through.
    pub expand_relations: Vec<String>,
    /// Search bounds.
    pub limits: Limits,
}

/// Runs the bounded search for `query` against `policy`.
#[must_use]
pub fn check(policy: &Policy, query: &Query) -> Outcome {
    if query.relations.is_empty() {
        return Outcome::Deny(Denial::NoPermission {
            object_type: query
                .objects
                .first()
                .map_or_else(|| "*".to_owned(), |object| object.kind.clone()),
            action: query.action.clone(),
        });
    }

    let mut budget = Budget {
        visited: 0,
        max_visited: query.limits.max_visited,
    };
    let mut matches = Vec::new();
    let mut exhausted = false;
    // One root goal per (relation, object) pair: a view binding attaches to
    // the relation that satisfied the action on the target, so successes are
    // collected per root goal rather than per leaf tuple.
    'roots: for object in &query.objects {
        for relation in &query.relations {
            match Walk::new(policy, query).run(relation, object, &mut budget) {
                Search::Found(found) => matches.push(found),
                Search::NotFound => {}
                Search::Exhausted => {
                    exhausted = true;
                    break 'roots;
                }
            }
        }
    }

    if matches.is_empty() {
        return Outcome::Deny(if exhausted {
            Denial::Exhausted {
                visited: budget.visited,
            }
        } else {
            Denial::NoPath {
                relations: query.relations.iter().cloned().collect(),
                objects: query.objects.iter().map(ToString::to_string).collect(),
            }
        });
    }

    combine_views(policy, matches)
}

/// Applies the view bindings of every successful path.
///
/// Conservative by construction: filters intersect, and a path that declares
/// no binding neither widens nor narrows. Only a binding explicitly marked
/// `:authz.binding/unfiltered` grants full visibility, which is the documented
/// escape hatch for relations like `owner` that must see everything.
fn combine_views(policy: &Policy, matches: Vec<Match>) -> Outcome {
    let mut filters = Vec::new();
    let mut names = Vec::new();
    for found in &matches {
        let Some(binding) = policy.binding_for(&found.relation, &found.object) else {
            continue;
        };
        if binding.unfiltered {
            return Outcome::Allow { matches };
        }
        if let Some(name) = &binding.view
            && let Some(filter) = policy.view(name)
        {
            filters.push(Arc::clone(filter));
            names.push(name.clone());
        }
    }
    match view::combine(filters) {
        None => Outcome::Allow { matches },
        Some(filter) => Outcome::AllowFiltered {
            matches,
            filter,
            views: names,
        },
    }
}

/// The shared visit budget for one check, spanning every root goal.
struct Budget {
    visited: usize,
    max_visited: usize,
}

impl Budget {
    fn spend(&mut self) -> bool {
        if self.visited >= self.max_visited {
            return false;
        }
        self.visited += 1;
        true
    }
}

enum Search {
    Found(Match),
    NotFound,
    Exhausted,
}

/// A queued goal, with a parent pointer so the matched path can be rebuilt
/// without cloning a path per queued entry.
struct Node {
    step: PathStep,
    parent: Option<usize>,
    depth: usize,
}

/// Breadth-first walk for one `(relation, object)` root goal.
struct Walk<'a> {
    policy: &'a Policy,
    query: &'a Query,
    nodes: Vec<Node>,
    queue: VecDeque<usize>,
    visited: BTreeSet<(String, ObjectRef)>,
}

impl<'a> Walk<'a> {
    fn new(policy: &'a Policy, query: &'a Query) -> Self {
        Self {
            policy,
            query,
            nodes: Vec::new(),
            queue: VecDeque::new(),
            visited: BTreeSet::new(),
        }
    }

    fn run(mut self, relation: &str, object: &ObjectRef, budget: &mut Budget) -> Search {
        self.push(relation.to_owned(), object.clone(), None, 0);
        while let Some(index) = self.queue.pop_front() {
            if !budget.spend() {
                return Search::Exhausted;
            }
            let goal = PathStep {
                relation: self.nodes[index].step.relation.clone(),
                object: self.nodes[index].step.object.clone(),
            };
            let depth = self.nodes[index].depth;

            for subject in self.policy.subjects_for(&goal.object, &goal.relation) {
                let subject = subject.clone();
                match &subject.relation {
                    // `group:eng#member writer database:music`: the relation
                    // named on the subject side becomes the next goal.
                    Some(userset) => {
                        self.push(
                            userset.clone(),
                            subject.object.clone(),
                            Some(index),
                            depth + 1,
                        );
                    }
                    None if self.holds(&subject) => {
                        return Search::Found(Match {
                            relation: relation.to_owned(),
                            object: object.clone(),
                            subject,
                            path: self.path_of(index),
                        });
                    }
                    // `group:eng writer database:music` written without the
                    // `#member` suffix: expand the named object through the
                    // configured membership relations.
                    None => {
                        for expansion in &self.query.expand_relations {
                            self.push(
                                expansion.clone(),
                                subject.object.clone(),
                                Some(index),
                                depth + 1,
                            );
                        }
                    }
                }
            }

            for rewrite in self.policy.rewrites_for(&goal.relation, &goal.object.kind) {
                let on_relation = rewrite.on_relation.clone();
                let parents: Vec<ObjectRef> = self
                    .policy
                    .subjects_for(&goal.object, &rewrite.via_relation)
                    .into_iter()
                    .map(|parent| parent.object.clone())
                    .collect();
                for parent in parents {
                    self.push(on_relation.clone(), parent, Some(index), depth + 1);
                }
            }
        }
        Search::NotFound
    }

    /// Whether the request's subject set satisfies a tuple's subject. A tuple
    /// written against `type:*` matches any held subject of that type, which is
    /// how "everyone" and "every authenticated caller" are expressed.
    fn holds(&self, subject: &SubjectRef) -> bool {
        if self.query.subjects.contains(&subject.object) {
            return true;
        }
        subject.object.is_wildcard()
            && self
                .query
                .subjects
                .iter()
                .any(|held| held.kind == subject.object.kind)
    }

    fn push(&mut self, relation: String, object: ObjectRef, parent: Option<usize>, depth: usize) {
        if depth > self.query.limits.max_depth {
            return;
        }
        // The visited set is both the cycle breaker and the work deduplicator:
        // a `parent`-loop in policy data revisits nothing.
        if !self.visited.insert((relation.clone(), object.clone())) {
            return;
        }
        self.nodes.push(Node {
            step: PathStep { relation, object },
            parent,
            depth,
        });
        self.queue.push_back(self.nodes.len() - 1);
    }

    fn path_of(&self, mut index: usize) -> Vec<PathStep> {
        let mut path = Vec::new();
        loop {
            path.push(self.nodes[index].step.clone());
            match self.nodes[index].parent {
                Some(parent) => index = parent,
                None => break,
            }
        }
        path.reverse();
        path
    }
}