Skip to main content

confium_signatif/
scope.rs

1//! The multi-dimensional authorization scope lattice (SIGNATIF §11).
2//!
3//! A trust authority's scope is a set of independent dimensions —
4//! `domain`, `subdomain`, `class`, `instance`, `identity` — each holding
5//! a [`ScopeValue`] from a three-level lattice:
6//!
7//! ```text
8//! Wildcard ⊇ Set { .. } ⊇ Single _
9//! ```
10//!
11//! Delegation must narrow monotonically: on every dimension the child
12//! value must be a subset of (or equal to) the parent value. Widening
13//! any dimension at any link is a hard verification failure. Unknown
14//! dimensions are carried in `extra` so schemes can extend the model
15//! without breaking verifiers that do not recognize the extension; a
16//! dimension absent from the parent is treated as unconstrained.
17
18use std::collections::BTreeMap;
19use std::collections::BTreeSet;
20
21use serde::{Deserialize, Serialize};
22
23/// One dimension's value in the scope lattice. The default is
24/// [`ScopeValue::Wildcard`]: an unconstrained dimension.
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
27pub enum ScopeValue {
28    /// Unconstrained — the top of the lattice.
29    #[default]
30    Wildcard,
31    /// One of a finite set of values.
32    Set(BTreeSet<String>),
33    /// Exactly one value — the bottom of the lattice.
34    Single(String),
35}
36
37impl ScopeValue {
38    /// Returns true when `self` is a subset of (or equal to) `parent`
39    /// in the lattice: wildcard encompasses anything; a set encompasses
40    /// its subsets and singles; a single encompasses only itself.
41    pub fn narrows_within(&self, parent: &ScopeValue) -> bool {
42        match (self, parent) {
43            (_, ScopeValue::Wildcard) => true,
44            (ScopeValue::Single(a), ScopeValue::Single(b)) => a == b,
45            (ScopeValue::Single(a), ScopeValue::Set(sup)) => sup.contains(a),
46            (ScopeValue::Set(sub), ScopeValue::Set(sup)) => sub.is_subset(sup),
47            (ScopeValue::Set(_), ScopeValue::Single(_)) => false,
48            (ScopeValue::Wildcard, _) => false,
49        }
50    }
51}
52
53/// A multi-dimensional scope: the five named SIGNATIF dimensions plus
54/// an extension map for scheme-registered dimensions.
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ScopeDimensions {
57    /// Top-level business or regulatory domain.
58    pub domain: ScopeValue,
59    /// Subdivision of the domain.
60    pub subdomain: ScopeValue,
61    /// Class of objects the authority may attest.
62    pub class: ScopeValue,
63    /// A specific instance identifier (batch, serial, lot).
64    pub instance: ScopeValue,
65    /// Authorized actor identity.
66    pub identity: ScopeValue,
67    /// Scheme-registered extension dimensions.
68    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
69    pub extra: BTreeMap<String, ScopeValue>,
70    /// Executable scope conditions (JSON Logic subset) evaluated at
71    /// verification time against the artifact and its chain (§11).
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub conditions: Vec<serde_json::Value>,
74}
75
76impl ScopeDimensions {
77    /// Unconstrained scope (all wildcards).
78    pub fn unconstrained() -> Self {
79        Self::default()
80    }
81
82    /// Iterate over `(dimension, value)` pairs, named dimensions first.
83    pub fn dimensions(&self) -> impl Iterator<Item = (&'static str, &ScopeValue)> {
84        [
85            ("domain", &self.domain),
86            ("subdomain", &self.subdomain),
87            ("class", &self.class),
88            ("instance", &self.instance),
89            ("identity", &self.identity),
90        ]
91        .into_iter()
92    }
93
94    /// Look up a dimension by name, including extensions.
95    pub fn get(&self, dimension: &str) -> Option<&ScopeValue> {
96        match dimension {
97            "domain" => Some(&self.domain),
98            "subdomain" => Some(&self.subdomain),
99            "class" => Some(&self.class),
100            "instance" => Some(&self.instance),
101            "identity" => Some(&self.identity),
102            other => self.extra.get(other),
103        }
104    }
105
106    /// Set a dimension value by name (including extensions).
107    pub fn set(&mut self, dimension: &str, value: ScopeValue) {
108        match dimension {
109            "domain" => self.domain = value,
110            "subdomain" => self.subdomain = value,
111            "class" => self.class = value,
112            "instance" => self.instance = value,
113            "identity" => self.identity = value,
114            other => {
115                self.extra.insert(other.to_string(), value);
116            }
117        }
118    }
119
120    /// The monotonic narrowing invariant: `self` (child) narrows within
121    /// `parent` on every dimension. Dimensions absent from the parent
122    /// are unconstrained by the parent and therefore always satisfied.
123    pub fn narrows_within(&self, parent: &ScopeDimensions) -> bool {
124        self.first_widened_dimension(parent).is_none()
125    }
126
127    /// Returns the first dimension on which `self` widens relative to
128    /// `parent`, if any — used to produce precise hard-failure errors.
129    pub fn first_widened_dimension(&self, parent: &ScopeDimensions) -> Option<String> {
130        for (name, child_value) in self.dimensions() {
131            if let Some(parent_value) = parent.get(name) {
132                if !child_value.narrows_within(parent_value) {
133                    return Some(name.to_string());
134                }
135            }
136        }
137        for (name, child_value) in &self.extra {
138            if let Some(parent_value) = parent.get(name) {
139                if !child_value.narrows_within(parent_value) {
140                    return Some(name.clone());
141                }
142            }
143        }
144        None
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn single(s: &str) -> ScopeValue {
153        ScopeValue::Single(s.into())
154    }
155
156    fn set(items: &[&str]) -> ScopeValue {
157        ScopeValue::Set(items.iter().map(|s| s.to_string()).collect())
158    }
159
160    #[test]
161    fn lattice_narrowing() {
162        let wildcard = ScopeValue::Wildcard;
163        let two = set(&["pharma", "food"]);
164        let one = set(&["pharma"]);
165        let exact = single("pharma");
166
167        assert!(exact.narrows_within(&one));
168        assert!(one.narrows_within(&two));
169        assert!(two.narrows_within(&wildcard));
170        assert!(exact.narrows_within(&wildcard));
171
172        assert!(!two.narrows_within(&one));
173        assert!(!one.narrows_within(&exact));
174        assert!(!single("food").narrows_within(&one));
175    }
176
177    #[test]
178    fn monotonic_narrowing_invariant() {
179        let mut parent = ScopeDimensions::unconstrained();
180        parent.set("domain", set(&["pharma", "food"]));
181        parent.set("class", single("certificate"));
182
183        let mut child = parent.clone();
184        child.set("domain", single("pharma"));
185        assert!(child.narrows_within(&parent));
186
187        let mut widening = parent.clone();
188        widening.set("domain", ScopeValue::Wildcard);
189        assert_eq!(
190            widening.first_widened_dimension(&parent),
191            Some("domain".to_string())
192        );
193        assert!(!widening.narrows_within(&parent));
194    }
195
196    #[test]
197    fn extension_dimensions_extend_without_breaking() {
198        let parent = ScopeDimensions::unconstrained();
199        let mut child = ScopeDimensions::unconstrained();
200        child.set("cnml:instrument-class", single("mass"));
201        // Parent lacks the dimension => unconstrained => narrow holds.
202        assert!(child.narrows_within(&parent));
203    }
204}