Skip to main content

corium_authz/
view.rs

1//! [`ViewFilter`] implementations built from policy data, and the conservative
2//! rule for combining the filters of several successful authorization paths.
3//!
4//! Policy data names attributes as idents (`:person/email`); callers ask about
5//! whatever spelling their surface uses, so every comparison here normalizes to
6//! the leading-colon form.
7
8use std::collections::BTreeSet;
9use std::sync::Arc;
10
11use corium_protocol::authz::ViewFilter;
12
13use crate::model::{FilterKind, ViewDef};
14
15/// Normalizes an attribute ident to its leading-colon spelling.
16#[must_use]
17pub fn normalize_attribute(attribute: &str) -> String {
18    if attribute.starts_with(':') {
19        attribute.to_owned()
20    } else {
21        format!(":{attribute}")
22    }
23}
24
25/// Hides every attribute outside the allowlist.
26#[derive(Clone, Debug)]
27pub struct AttributeAllowlist {
28    name: String,
29    allowed: BTreeSet<String>,
30}
31
32impl AttributeAllowlist {
33    /// Builds a named allowlist over attribute idents.
34    pub fn new(
35        name: impl Into<String>,
36        attributes: impl IntoIterator<Item = impl AsRef<str>>,
37    ) -> Self {
38        Self {
39            name: name.into(),
40            allowed: attributes
41                .into_iter()
42                .map(|attribute| normalize_attribute(attribute.as_ref()))
43                .collect(),
44        }
45    }
46
47    /// The policy name this filter was compiled from.
48    #[must_use]
49    pub fn name(&self) -> &str {
50        &self.name
51    }
52}
53
54impl ViewFilter for AttributeAllowlist {
55    fn attribute_visible(&self, attribute: &str) -> bool {
56        self.allowed.contains(&normalize_attribute(attribute))
57    }
58}
59
60/// Hides the named attributes and shows everything else.
61#[derive(Clone, Debug)]
62pub struct AttributeDenylist {
63    name: String,
64    denied: BTreeSet<String>,
65}
66
67impl AttributeDenylist {
68    /// Builds a named denylist over attribute idents.
69    pub fn new(
70        name: impl Into<String>,
71        attributes: impl IntoIterator<Item = impl AsRef<str>>,
72    ) -> Self {
73        Self {
74            name: name.into(),
75            denied: attributes
76                .into_iter()
77                .map(|attribute| normalize_attribute(attribute.as_ref()))
78                .collect(),
79        }
80    }
81
82    /// The policy name this filter was compiled from.
83    #[must_use]
84    pub fn name(&self) -> &str {
85        &self.name
86    }
87}
88
89impl ViewFilter for AttributeDenylist {
90    fn attribute_visible(&self, attribute: &str) -> bool {
91        !self.denied.contains(&normalize_attribute(attribute))
92    }
93}
94
95/// The conjunction of several filters: an attribute is visible only when every
96/// part admits it.
97///
98/// This is how the evaluator combines the views of several successful paths —
99/// intersecting visibility constraints rather than taking the widest, so
100/// holding one more relation can never *reveal* more than holding it alone.
101#[derive(Clone, Debug)]
102pub struct IntersectionView {
103    parts: Vec<Arc<dyn ViewFilter>>,
104}
105
106impl IntersectionView {
107    /// Intersects `parts`.
108    #[must_use]
109    pub fn new(parts: Vec<Arc<dyn ViewFilter>>) -> Self {
110        Self { parts }
111    }
112}
113
114impl ViewFilter for IntersectionView {
115    fn attribute_visible(&self, attribute: &str) -> bool {
116        self.parts
117            .iter()
118            .all(|part| part.attribute_visible(attribute))
119    }
120}
121
122/// Builds the filter a [`ViewDef`] describes.
123#[must_use]
124pub fn build(definition: &ViewDef) -> Arc<dyn ViewFilter> {
125    match definition.kind {
126        FilterKind::AttributeAllowlist => Arc::new(AttributeAllowlist::new(
127            definition.name.clone(),
128            &definition.attributes,
129        )),
130        FilterKind::AttributeDenylist => Arc::new(AttributeDenylist::new(
131            definition.name.clone(),
132            &definition.attributes,
133        )),
134    }
135}
136
137/// Combines the views of every successful path.
138///
139/// * no path declared a view — full visibility;
140/// * one path — that view;
141/// * several — their intersection.
142///
143/// A path whose relation is explicitly marked unfiltered is handled by the
144/// caller, which drops every filter when it sees one.
145#[must_use]
146pub fn combine(views: Vec<Arc<dyn ViewFilter>>) -> Option<Arc<dyn ViewFilter>> {
147    match views.len() {
148        0 => None,
149        1 => views.into_iter().next(),
150        _ => Some(Arc::new(IntersectionView::new(views))),
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn allowlist(attributes: &[&str]) -> Arc<dyn ViewFilter> {
159        Arc::new(AttributeAllowlist::new("test", attributes))
160    }
161
162    #[test]
163    fn allowlist_normalizes_idents() {
164        let filter = AttributeAllowlist::new("v", ["person/name", ":person/email"]);
165        assert!(filter.attribute_visible(":person/name"));
166        assert!(filter.attribute_visible("person/email"));
167        assert!(!filter.attribute_visible(":person/ssn"));
168    }
169
170    #[test]
171    fn denylist_hides_only_named_attributes() {
172        let filter = AttributeDenylist::new("v", [":person/ssn"]);
173        assert!(filter.attribute_visible(":person/name"));
174        assert!(!filter.attribute_visible(":person/ssn"));
175    }
176
177    #[test]
178    fn intersection_is_conservative() {
179        let combined = combine(vec![
180            allowlist(&[":person/name", ":person/email"]),
181            allowlist(&[":person/name", ":person/ssn"]),
182        ])
183        .expect("two views intersect");
184        assert!(combined.attribute_visible(":person/name"));
185        assert!(!combined.attribute_visible(":person/email"));
186        assert!(!combined.attribute_visible(":person/ssn"));
187        assert!(combine(Vec::new()).is_none());
188    }
189}