1use std::collections::BTreeSet;
9use std::sync::Arc;
10
11use corium_protocol::authz::ViewFilter;
12
13use crate::model::{FilterKind, ViewDef};
14
15#[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#[derive(Clone, Debug)]
27pub struct AttributeAllowlist {
28 name: String,
29 allowed: BTreeSet<String>,
30}
31
32impl AttributeAllowlist {
33 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 #[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#[derive(Clone, Debug)]
62pub struct AttributeDenylist {
63 name: String,
64 denied: BTreeSet<String>,
65}
66
67impl AttributeDenylist {
68 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 #[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#[derive(Clone, Debug)]
102pub struct IntersectionView {
103 parts: Vec<Arc<dyn ViewFilter>>,
104}
105
106impl IntersectionView {
107 #[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#[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#[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}