use std::collections::BTreeSet;
use std::sync::Arc;
use corium_protocol::authz::ViewFilter;
use crate::model::{FilterKind, ViewDef};
#[must_use]
pub fn normalize_attribute(attribute: &str) -> String {
if attribute.starts_with(':') {
attribute.to_owned()
} else {
format!(":{attribute}")
}
}
#[derive(Clone, Debug)]
pub struct AttributeAllowlist {
name: String,
allowed: BTreeSet<String>,
}
impl AttributeAllowlist {
pub fn new(
name: impl Into<String>,
attributes: impl IntoIterator<Item = impl AsRef<str>>,
) -> Self {
Self {
name: name.into(),
allowed: attributes
.into_iter()
.map(|attribute| normalize_attribute(attribute.as_ref()))
.collect(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
impl ViewFilter for AttributeAllowlist {
fn attribute_visible(&self, attribute: &str) -> bool {
self.allowed.contains(&normalize_attribute(attribute))
}
}
#[derive(Clone, Debug)]
pub struct AttributeDenylist {
name: String,
denied: BTreeSet<String>,
}
impl AttributeDenylist {
pub fn new(
name: impl Into<String>,
attributes: impl IntoIterator<Item = impl AsRef<str>>,
) -> Self {
Self {
name: name.into(),
denied: attributes
.into_iter()
.map(|attribute| normalize_attribute(attribute.as_ref()))
.collect(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
impl ViewFilter for AttributeDenylist {
fn attribute_visible(&self, attribute: &str) -> bool {
!self.denied.contains(&normalize_attribute(attribute))
}
}
#[derive(Clone, Debug)]
pub struct IntersectionView {
parts: Vec<Arc<dyn ViewFilter>>,
}
impl IntersectionView {
#[must_use]
pub fn new(parts: Vec<Arc<dyn ViewFilter>>) -> Self {
Self { parts }
}
}
impl ViewFilter for IntersectionView {
fn attribute_visible(&self, attribute: &str) -> bool {
self.parts
.iter()
.all(|part| part.attribute_visible(attribute))
}
}
#[must_use]
pub fn build(definition: &ViewDef) -> Arc<dyn ViewFilter> {
match definition.kind {
FilterKind::AttributeAllowlist => Arc::new(AttributeAllowlist::new(
definition.name.clone(),
&definition.attributes,
)),
FilterKind::AttributeDenylist => Arc::new(AttributeDenylist::new(
definition.name.clone(),
&definition.attributes,
)),
}
}
#[must_use]
pub fn combine(views: Vec<Arc<dyn ViewFilter>>) -> Option<Arc<dyn ViewFilter>> {
match views.len() {
0 => None,
1 => views.into_iter().next(),
_ => Some(Arc::new(IntersectionView::new(views))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn allowlist(attributes: &[&str]) -> Arc<dyn ViewFilter> {
Arc::new(AttributeAllowlist::new("test", attributes))
}
#[test]
fn allowlist_normalizes_idents() {
let filter = AttributeAllowlist::new("v", ["person/name", ":person/email"]);
assert!(filter.attribute_visible(":person/name"));
assert!(filter.attribute_visible("person/email"));
assert!(!filter.attribute_visible(":person/ssn"));
}
#[test]
fn denylist_hides_only_named_attributes() {
let filter = AttributeDenylist::new("v", [":person/ssn"]);
assert!(filter.attribute_visible(":person/name"));
assert!(!filter.attribute_visible(":person/ssn"));
}
#[test]
fn intersection_is_conservative() {
let combined = combine(vec![
allowlist(&[":person/name", ":person/email"]),
allowlist(&[":person/name", ":person/ssn"]),
])
.expect("two views intersect");
assert!(combined.attribute_visible(":person/name"));
assert!(!combined.attribute_visible(":person/email"));
assert!(!combined.attribute_visible(":person/ssn"));
assert!(combine(Vec::new()).is_none());
}
}