Skip to main content

corium_authz/
policy.rs

1//! Compiling a snapshot of the authz database into an immutable policy value.
2//!
3//! Compilation happens off the request path (see [`crate::SystemDbAuthorizer`]);
4//! what a check touches is only the indexes built here, so the hot path is a
5//! bounded in-memory graph walk with no locking beyond reading the current
6//! snapshot pointer.
7//!
8//! The compiled value is keyed by the authz database's basis `t`. That single
9//! number is what makes decisions reproducible (re-read the database `as-of`
10//! that `t` and the same policy comes back) and cache invalidation trivial
11//! (entries keyed by a stale `t` can never be hit again).
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15
16use corium_core::{AttrId, Keyword, Value};
17use corium_db::Db;
18use corium_protocol::authz::{Action, ActionClass, ViewFilter};
19
20use crate::model::{
21    Binding, FilterKind, ObjectDef, ObjectRef, Permission, PrincipalDef, Rewrite, SubjectRef,
22    Tuple, ViewDef, WILDCARD, action_class_name, action_name,
23};
24use crate::schema;
25use crate::view;
26
27/// Failure to compile a policy from a database snapshot.
28#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
29pub enum PolicyError {
30    /// The snapshot does not carry the reserved authz schema.
31    #[error("database is not an authorization database: {0} is not installed")]
32    NotAnAuthzDatabase(String),
33    /// A policy entity is missing a required attribute.
34    #[error("{entity} at entity {eid} is missing {attribute}")]
35    Incomplete {
36        /// Kind of policy entity (`tuple`, `permission`, …).
37        entity: &'static str,
38        /// Entity id in the authz database.
39        eid: String,
40        /// The attribute that is missing.
41        attribute: &'static str,
42    },
43    /// A value could not be read as the schema's type.
44    #[error("{attribute} at entity {eid} is not a {expected}")]
45    BadValue {
46        /// The attribute.
47        attribute: &'static str,
48        /// Entity id in the authz database.
49        eid: String,
50        /// The expected shape.
51        expected: &'static str,
52    },
53    /// A binding names a view the policy does not define. Fails compilation
54    /// rather than silently dropping a restriction.
55    #[error("binding {relation} on {object} names undefined view {view:?}")]
56    UndefinedView {
57        /// Relation the binding attaches to.
58        relation: String,
59        /// Object the binding attaches to.
60        object: String,
61        /// The missing view name.
62        view: String,
63    },
64    /// A view names a filter type this build does not implement.
65    #[error("view {view:?} has unsupported filter type {kind:?}")]
66    UnsupportedFilter {
67        /// The view name.
68        view: String,
69        /// The filter type read from policy data.
70        kind: String,
71    },
72}
73
74/// Counts describing a compiled policy, for logs and `authz status`.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct PolicyStats {
77    /// Registered principals.
78    pub principals: usize,
79    /// Registered objects.
80    pub objects: usize,
81    /// Relationship tuples.
82    pub tuples: usize,
83    /// Permission mappings.
84    pub permissions: usize,
85    /// Rewrite rules.
86    pub rewrites: usize,
87    /// View definitions.
88    pub views: usize,
89    /// View bindings.
90    pub bindings: usize,
91}
92
93/// An immutable, compiled policy snapshot at one authz basis `t`.
94#[derive(Clone)]
95pub struct Policy {
96    basis_t: u64,
97    /// `(object, relation)` → subjects holding it.
98    by_object: BTreeMap<(ObjectRef, String), Vec<SubjectRef>>,
99    /// Relation → rewrite rules deriving it.
100    rewrites: BTreeMap<String, Vec<Rewrite>>,
101    /// `(object type, action-or-class-or-*)` → relations satisfying it.
102    permissions: BTreeMap<(String, String), BTreeSet<String>>,
103    /// View name → compiled filter.
104    views: BTreeMap<String, Arc<dyn ViewFilter>>,
105    /// `(relation, object)` → binding.
106    bindings: BTreeMap<(String, ObjectRef), Binding>,
107    /// Principal id → registration.
108    principals: BTreeMap<String, PrincipalDef>,
109    /// Corium database name → objects registered against it.
110    objects_by_database: BTreeMap<String, Vec<ObjectRef>>,
111    stats: PolicyStats,
112}
113
114impl std::fmt::Debug for Policy {
115    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        formatter
117            .debug_struct("Policy")
118            .field("basis_t", &self.basis_t)
119            .field("stats", &self.stats)
120            .finish_non_exhaustive()
121    }
122}
123
124impl Policy {
125    /// The empty policy at basis 0: no permission maps, so it denies
126    /// everything. This is the fail-closed value, not a default to run on.
127    #[must_use]
128    pub fn empty() -> Self {
129        Self {
130            basis_t: 0,
131            by_object: BTreeMap::new(),
132            rewrites: BTreeMap::new(),
133            permissions: BTreeMap::new(),
134            views: BTreeMap::new(),
135            bindings: BTreeMap::new(),
136            principals: BTreeMap::new(),
137            objects_by_database: BTreeMap::new(),
138            stats: PolicyStats::default(),
139        }
140    }
141
142    /// Compiles the policy recorded in `db`.
143    ///
144    /// # Errors
145    /// Returns [`PolicyError`] when the snapshot is not an authz database or a
146    /// policy entity is malformed. Compilation is all-or-nothing on purpose: a
147    /// half-read policy would silently under- or over-grant.
148    pub fn compile(db: &Db) -> Result<Self, PolicyError> {
149        let reader = Reader::new(db)?;
150        let mut policy = Self::empty();
151        policy.basis_t = db.basis_t();
152        policy.read_principals(&reader)?;
153        policy.read_objects(&reader)?;
154        policy.read_tuples(&reader)?;
155        policy.read_permissions(&reader)?;
156        policy.read_rewrites(&reader)?;
157        // Views before bindings: a binding that names a view the policy does
158        // not define is a compile error, not a silently dropped restriction.
159        policy.read_views(&reader)?;
160        policy.read_bindings(&reader)?;
161        policy.stats.principals = policy.principals.len();
162        Ok(policy)
163    }
164
165    fn read_principals(&mut self, reader: &Reader) -> Result<(), PolicyError> {
166        for (eid, values) in reader.rows(schema::PRINCIPAL_ID) {
167            let id = reader.one_string("principal", eid, schema::PRINCIPAL_ID, values)?;
168            let provider = reader
169                .value(eid, schema::PRINCIPAL_PROVIDER)
170                .map(|values| {
171                    reader.one_string("principal", eid, schema::PRINCIPAL_PROVIDER, values)
172                })
173                .transpose()?
174                .filter(|provider| provider != WILDCARD);
175            let roles = reader.strings(eid, schema::PRINCIPAL_ROLE);
176            self.principals.insert(
177                id.clone(),
178                PrincipalDef {
179                    id,
180                    provider,
181                    roles,
182                },
183            );
184        }
185        Ok(())
186    }
187
188    fn read_objects(&mut self, reader: &Reader) -> Result<(), PolicyError> {
189        for (eid, values) in reader.rows(schema::OBJECT_ID) {
190            let id = reader.one_string("object", eid, schema::OBJECT_ID, values)?;
191            let kind = reader.required_string("object", eid, schema::OBJECT_TYPE)?;
192            let database = reader
193                .value(eid, schema::OBJECT_DATABASE)
194                .map(|values| reader.one_string("object", eid, schema::OBJECT_DATABASE, values))
195                .transpose()?;
196            let definition = ObjectDef {
197                object: ObjectRef::new(kind, id),
198                database,
199            };
200            if let Some(database) = &definition.database {
201                self.objects_by_database
202                    .entry(database.clone())
203                    .or_default()
204                    .push(definition.object.clone());
205            }
206            self.stats.objects += 1;
207        }
208        Ok(())
209    }
210
211    fn read_tuples(&mut self, reader: &Reader) -> Result<(), PolicyError> {
212        for (eid, values) in reader.rows(schema::TUPLE_RELATION) {
213            let relation = reader.one_string("tuple", eid, schema::TUPLE_RELATION, values)?;
214            let subject = reader.required_string("tuple", eid, schema::TUPLE_SUBJECT)?;
215            let object = reader.required_string("tuple", eid, schema::TUPLE_OBJECT)?;
216            let tuple = Tuple {
217                subject: SubjectRef::parse(&subject),
218                relation,
219                object: ObjectRef::parse(&object),
220            };
221            self.by_object
222                .entry((tuple.object.clone(), tuple.relation.clone()))
223                .or_default()
224                .push(tuple.subject);
225            self.stats.tuples += 1;
226        }
227        Ok(())
228    }
229
230    fn read_permissions(&mut self, reader: &Reader) -> Result<(), PolicyError> {
231        for (eid, values) in reader.rows(schema::PERMISSION_ACTION) {
232            let action = reader.one_string("permission", eid, schema::PERMISSION_ACTION, values)?;
233            let object_type = reader
234                .value(eid, schema::PERMISSION_OBJECT_TYPE)
235                .map(|values| {
236                    reader.one_string("permission", eid, schema::PERMISSION_OBJECT_TYPE, values)
237                })
238                .transpose()?
239                .unwrap_or_else(|| WILDCARD.to_owned());
240            let relations = reader.strings(eid, schema::PERMISSION_RELATION);
241            if relations.is_empty() {
242                return Err(PolicyError::Incomplete {
243                    entity: "permission",
244                    eid: format!("{eid:?}"),
245                    attribute: schema::PERMISSION_RELATION,
246                });
247            }
248            let permission = Permission {
249                object_type,
250                action,
251                relations,
252            };
253            self.permissions
254                .entry((permission.object_type.clone(), permission.action.clone()))
255                .or_default()
256                .extend(permission.relations);
257            self.stats.permissions += 1;
258        }
259        Ok(())
260    }
261
262    fn read_rewrites(&mut self, reader: &Reader) -> Result<(), PolicyError> {
263        for (eid, values) in reader.rows(schema::REWRITE_RELATION) {
264            let relation = reader.one_string("rewrite", eid, schema::REWRITE_RELATION, values)?;
265            let via_relation =
266                reader.required_string("rewrite", eid, schema::REWRITE_VIA_RELATION)?;
267            let on_relation =
268                reader.required_string("rewrite", eid, schema::REWRITE_ON_RELATION)?;
269            let object_type = reader
270                .value(eid, schema::REWRITE_OBJECT_TYPE)
271                .map(|values| {
272                    reader.one_string("rewrite", eid, schema::REWRITE_OBJECT_TYPE, values)
273                })
274                .transpose()?
275                .filter(|kind| kind != WILDCARD);
276            self.rewrites
277                .entry(relation.clone())
278                .or_default()
279                .push(Rewrite {
280                    relation,
281                    via_relation,
282                    on_relation,
283                    object_type,
284                });
285            self.stats.rewrites += 1;
286        }
287        Ok(())
288    }
289
290    fn read_views(&mut self, reader: &Reader) -> Result<(), PolicyError> {
291        for (eid, values) in reader.rows(schema::VIEW_NAME) {
292            let name = reader.one_string("view", eid, schema::VIEW_NAME, values)?;
293            let kind_text = reader.required_string("view", eid, schema::VIEW_FILTER_TYPE)?;
294            let kind =
295                FilterKind::parse(&kind_text).ok_or_else(|| PolicyError::UnsupportedFilter {
296                    view: name.clone(),
297                    kind: kind_text,
298                })?;
299            let definition = ViewDef {
300                name: name.clone(),
301                kind,
302                attributes: reader.strings(eid, schema::VIEW_ATTRIBUTE),
303            };
304            self.views.insert(name, view::build(&definition));
305            self.stats.views += 1;
306        }
307        Ok(())
308    }
309
310    fn read_bindings(&mut self, reader: &Reader) -> Result<(), PolicyError> {
311        for (eid, values) in reader.rows(schema::BINDING_RELATION) {
312            let relation = reader.one_string("binding", eid, schema::BINDING_RELATION, values)?;
313            let object = ObjectRef::parse(&reader.required_string(
314                "binding",
315                eid,
316                schema::BINDING_OBJECT,
317            )?);
318            let view_name = reader
319                .value(eid, schema::BINDING_VIEW)
320                .map(|values| reader.one_string("binding", eid, schema::BINDING_VIEW, values))
321                .transpose()?;
322            let unfiltered = reader
323                .value(eid, schema::BINDING_UNFILTERED)
324                .and_then(|values| values.first())
325                .is_some_and(|value| matches!(value, Value::Bool(true)));
326            if let Some(name) = &view_name
327                && !self.views.contains_key(name)
328            {
329                return Err(PolicyError::UndefinedView {
330                    relation,
331                    object: object.to_string(),
332                    view: name.clone(),
333                });
334            }
335            self.bindings.insert(
336                (relation.clone(), object.clone()),
337                Binding {
338                    relation,
339                    object,
340                    view: view_name,
341                    unfiltered,
342                },
343            );
344            self.stats.bindings += 1;
345        }
346        Ok(())
347    }
348
349    /// The authz database basis this policy was compiled from.
350    #[must_use]
351    pub const fn basis_t(&self) -> u64 {
352        self.basis_t
353    }
354
355    /// Counts of the policy entities compiled.
356    #[must_use]
357    pub const fn stats(&self) -> PolicyStats {
358        self.stats
359    }
360
361    /// The registration for `id`, when policy data names it.
362    #[must_use]
363    pub fn principal(&self, id: &str) -> Option<&PrincipalDef> {
364        self.principals.get(id)
365    }
366
367    /// Objects registered as standing for the Corium database `name`.
368    #[must_use]
369    pub fn objects_for_database(&self, name: &str) -> &[ObjectRef] {
370        self.objects_by_database
371            .get(name)
372            .map_or(&[], Vec::as_slice)
373    }
374
375    /// Relations that satisfy `action` on an object of `object_type`.
376    ///
377    /// Matching widens in three steps — the exact action name, then its class
378    /// (`read`/`write`/`admin`), then `*` — each against both the object's own
379    /// type and `*`, so a policy can be as specific or as coarse as it likes.
380    #[must_use]
381    pub fn relations_for(&self, object_type: &str, action: Action) -> BTreeSet<String> {
382        let names = [
383            action_name(action),
384            action_class_name(ActionClass::of(action)),
385            WILDCARD,
386        ];
387        let mut relations = BTreeSet::new();
388        for name in names {
389            for kind in [object_type, WILDCARD] {
390                if let Some(found) = self.permissions.get(&(kind.to_owned(), name.to_owned())) {
391                    relations.extend(found.iter().cloned());
392                }
393            }
394        }
395        relations
396    }
397
398    /// Subjects holding `relation` on `object`, including tuples written
399    /// against the type wildcard `type:*`.
400    pub(crate) fn subjects_for(&self, object: &ObjectRef, relation: &str) -> Vec<&SubjectRef> {
401        let mut subjects = Vec::new();
402        for key in [object.clone(), object.wildcard_of_type()] {
403            if let Some(found) = self.by_object.get(&(key, relation.to_owned())) {
404                subjects.extend(found.iter());
405            }
406            if object.is_wildcard() {
407                break;
408            }
409        }
410        subjects
411    }
412
413    /// Rewrite rules deriving `relation` on an object of `object_type`.
414    pub(crate) fn rewrites_for(&self, relation: &str, object_type: &str) -> Vec<&Rewrite> {
415        self.rewrites.get(relation).map_or_else(Vec::new, |rules| {
416            rules
417                .iter()
418                .filter(|rule| {
419                    rule.object_type
420                        .as_ref()
421                        .is_none_or(|kind| kind == object_type)
422                })
423                .collect()
424        })
425    }
426
427    /// The binding attached to `relation` on `object`, falling back to the
428    /// type wildcard.
429    pub(crate) fn binding_for(&self, relation: &str, object: &ObjectRef) -> Option<&Binding> {
430        self.bindings
431            .get(&(relation.to_owned(), object.clone()))
432            .or_else(|| {
433                self.bindings
434                    .get(&(relation.to_owned(), object.wildcard_of_type()))
435            })
436    }
437
438    /// The compiled filter a binding names.
439    pub(crate) fn view(&self, name: &str) -> Option<&Arc<dyn ViewFilter>> {
440        self.views.get(name)
441    }
442}
443
444/// Reads the reserved attributes out of a snapshot, grouped by entity.
445struct Reader {
446    /// Attribute ident → entity → values, in entity order.
447    rows: BTreeMap<&'static str, BTreeMap<corium_core::EntityId, Vec<Value>>>,
448    interner: corium_core::KeywordInterner,
449}
450
451impl Reader {
452    fn new(db: &Db) -> Result<Self, PolicyError> {
453        let mut rows: BTreeMap<&'static str, BTreeMap<corium_core::EntityId, Vec<Value>>> =
454            BTreeMap::new();
455        let mut installed = 0usize;
456        for attribute in schema::ATTRIBUTES {
457            let Some(id) = attribute_id(db, attribute) else {
458                continue;
459            };
460            installed += 1;
461            let mut by_entity: BTreeMap<corium_core::EntityId, Vec<Value>> = BTreeMap::new();
462            for datom in db.datoms_for_attribute(id) {
463                by_entity.entry(datom.e).or_default().push(datom.v.clone());
464            }
465            rows.insert(attribute, by_entity);
466        }
467        // A misconfigured authz database (pointed at an application database,
468        // say) must fail loudly: an empty policy would deny every request while
469        // looking like a legitimately restrictive one.
470        if installed == 0 {
471            return Err(PolicyError::NotAnAuthzDatabase(
472                schema::TUPLE_SUBJECT.to_owned(),
473            ));
474        }
475        Ok(Self {
476            rows,
477            interner: db.interner().clone(),
478        })
479    }
480
481    /// Entities carrying `attribute`, with their values.
482    fn rows(&self, attribute: &'static str) -> Vec<(corium_core::EntityId, &Vec<Value>)> {
483        self.rows.get(attribute).map_or_else(Vec::new, |by_entity| {
484            by_entity
485                .iter()
486                .map(|(eid, values)| (*eid, values))
487                .collect()
488        })
489    }
490
491    fn value(&self, eid: corium_core::EntityId, attribute: &'static str) -> Option<&Vec<Value>> {
492        self.rows.get(attribute).and_then(|rows| rows.get(&eid))
493    }
494
495    fn one_string(
496        &self,
497        entity: &'static str,
498        eid: corium_core::EntityId,
499        attribute: &'static str,
500        values: &[Value],
501    ) -> Result<String, PolicyError> {
502        let value = values.first().ok_or_else(|| PolicyError::Incomplete {
503            entity,
504            eid: format!("{eid:?}"),
505            attribute,
506        })?;
507        self.text(value).ok_or_else(|| PolicyError::BadValue {
508            attribute,
509            eid: format!("{eid:?}"),
510            expected: "string",
511        })
512    }
513
514    fn required_string(
515        &self,
516        entity: &'static str,
517        eid: corium_core::EntityId,
518        attribute: &'static str,
519    ) -> Result<String, PolicyError> {
520        let values = self.value(eid, attribute).ok_or(PolicyError::Incomplete {
521            entity,
522            eid: format!("{eid:?}"),
523            attribute,
524        })?;
525        self.one_string(entity, eid, attribute, values)
526    }
527
528    fn strings(&self, eid: corium_core::EntityId, attribute: &'static str) -> Vec<String> {
529        self.value(eid, attribute).map_or_else(Vec::new, |values| {
530            values.iter().filter_map(|value| self.text(value)).collect()
531        })
532    }
533
534    /// Reads a value as text. Keywords are accepted and rendered with their
535    /// leading colon, so `:person/name` may be written either way.
536    fn text(&self, value: &Value) -> Option<String> {
537        match value {
538            Value::Str(text) => Some(text.to_string()),
539            Value::Keyword(id) => self
540                .interner
541                .resolve(*id)
542                .map(std::string::ToString::to_string),
543            _ => None,
544        }
545    }
546}
547
548fn attribute_id(db: &Db, attribute: &str) -> Option<AttrId> {
549    db.idents().entid(&Keyword::parse(attribute))
550}