Skip to main content

lean_ctx/core/trust/
classification.rs

1//! Data classification and residency metadata.
2
3use serde::{Deserialize, Serialize};
4
5use super::identity::TenantId;
6use super::tenant_isolation::TenantBoundary;
7
8/// Ordered sensitivity levels. Higher levels must not flow to lower-trust
9/// destinations without an explicit policy decision.
10#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11#[repr(u8)]
12pub enum SecurityClassification {
13    /// Intended for unrestricted disclosure.
14    #[default]
15    Public = 0,
16    /// Non-public operational information.
17    Internal = 1,
18    /// Information requiring an authorized recipient.
19    Confidential = 2,
20    /// Information requiring explicit need-to-know controls.
21    Restricted = 3,
22    /// Highest sensitivity; enterprise controls must explicitly authorize use.
23    TopSecret = 4,
24}
25
26impl SecurityClassification {
27    /// Returns the stable numeric ordering used by policy evaluators.
28    #[must_use]
29    pub const fn rank(self) -> u8 {
30        self as u8
31    }
32
33    /// Returns whether data at this level may flow to `destination`.
34    #[must_use]
35    pub const fn may_flow_to(self, destination: Self) -> bool {
36        self.rank() <= destination.rank()
37    }
38
39    /// Parses the stable case-insensitive name used in policy conditions.
40    #[must_use]
41    pub fn parse(value: &str) -> Option<Self> {
42        match value.trim().to_ascii_lowercase().as_str() {
43            "public" => Some(Self::Public),
44            "internal" => Some(Self::Internal),
45            "confidential" => Some(Self::Confidential),
46            "restricted" => Some(Self::Restricted),
47            "topsecret" | "top_secret" | "top-secret" => Some(Self::TopSecret),
48            _ => None,
49        }
50    }
51}
52
53/// Region constraints attached to classified data.
54#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
55pub struct ResidencyRequirement {
56    /// Regions in which the data may be stored or processed. Empty means
57    /// unrestricted unless `data_sovereign` is true.
58    #[serde(default)]
59    pub allowed_regions: Vec<String>,
60    /// Regions that are forbidden even when they appear in the allow list.
61    #[serde(default)]
62    pub prohibited_regions: Vec<String>,
63    /// Requires a sovereign deployment and therefore a non-empty allow list.
64    #[serde(default)]
65    pub data_sovereign: bool,
66}
67
68impl ResidencyRequirement {
69    /// Creates an unconstrained residency requirement.
70    #[must_use]
71    pub fn unrestricted() -> Self {
72        Self::default()
73    }
74
75    /// Creates a requirement allowing only the supplied regions.
76    #[must_use]
77    pub fn allowed_in<I, S>(regions: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        Self {
83            allowed_regions: regions.into_iter().map(Into::into).collect(),
84            ..Self::default()
85        }
86    }
87
88    /// Creates a sovereign requirement for the supplied regions.
89    #[must_use]
90    pub fn sovereign<I, S>(regions: I) -> Self
91    where
92        I: IntoIterator<Item = S>,
93        S: Into<String>,
94    {
95        Self {
96            allowed_regions: regions.into_iter().map(Into::into).collect(),
97            data_sovereign: true,
98            ..Self::default()
99        }
100    }
101
102    /// Adds one allowed region.
103    #[must_use]
104    pub fn with_allowed_region(mut self, region: impl Into<String>) -> Self {
105        self.allowed_regions.push(region.into());
106        self
107    }
108
109    /// Adds one prohibited region.
110    #[must_use]
111    pub fn with_prohibited_region(mut self, region: impl Into<String>) -> Self {
112        self.prohibited_regions.push(region.into());
113        self
114    }
115
116    /// Marks the requirement as sovereign.
117    #[must_use]
118    pub const fn with_sovereignty(mut self, sovereign: bool) -> Self {
119        self.data_sovereign = sovereign;
120        self
121    }
122
123    /// Checks whether processing in `region` is permitted.
124    #[must_use]
125    pub fn allows_region(&self, region: &str) -> bool {
126        let region = region.trim();
127        if region.is_empty() || (self.data_sovereign && self.allowed_regions.is_empty()) {
128            return false;
129        }
130
131        let prohibited = self
132            .prohibited_regions
133            .iter()
134            .any(|candidate| region_matches(candidate, region));
135        if prohibited {
136            return false;
137        }
138
139        self.allowed_regions.is_empty()
140            || self
141                .allowed_regions
142                .iter()
143                .any(|candidate| region_matches(candidate, region))
144    }
145
146    /// Returns a validation error for an internally contradictory requirement.
147    pub fn validate(&self) -> Result<(), String> {
148        if self.data_sovereign && self.allowed_regions.is_empty() {
149            return Err("sovereign residency requires at least one allowed region".to_owned());
150        }
151        Ok(())
152    }
153}
154
155/// A value carrying its sensitivity and residency requirements together.
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct ClassifiedData<T> {
158    /// The protected value.
159    pub data: T,
160    /// Sensitivity classification for the value.
161    pub classification: SecurityClassification,
162    /// Storage and processing constraints for the value.
163    pub residency: ResidencyRequirement,
164}
165
166impl<T> ClassifiedData<T> {
167    /// Wraps `data` with classification and residency metadata.
168    #[must_use]
169    pub fn new(
170        data: T,
171        classification: SecurityClassification,
172        residency: ResidencyRequirement,
173    ) -> Self {
174        Self {
175            data,
176            classification,
177            residency,
178        }
179    }
180
181    /// Borrows the wrapped value.
182    #[must_use]
183    pub const fn as_ref(&self) -> &T {
184        &self.data
185    }
186
187    /// Maps the wrapped value while preserving the security metadata.
188    #[must_use]
189    pub fn map<U>(self, map: impl FnOnce(T) -> U) -> ClassifiedData<U> {
190        ClassifiedData {
191            data: map(self.data),
192            classification: self.classification,
193            residency: self.residency,
194        }
195    }
196
197    /// Unwraps the protected value.
198    #[must_use]
199    pub fn into_inner(self) -> T {
200        self.data
201    }
202
203    /// Checks whether the value may be processed in `region`.
204    #[must_use]
205    pub fn is_allowed_in(&self, region: &str) -> bool {
206        self.residency.allows_region(region)
207    }
208}
209
210impl<T: TenantBoundary> TenantBoundary for ClassifiedData<T> {
211    fn tenant_id(&self) -> &TenantId {
212        self.data.tenant_id()
213    }
214}
215
216/// One content-type-to-classification rule.
217#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
218pub struct ClassificationRule {
219    /// Exact content type or a prefix pattern ending in `*`.
220    pub content_type: String,
221    /// Classification assigned when the rule matches.
222    pub classification: SecurityClassification,
223}
224
225/// Deterministic reference policy for automatic classification by content type.
226#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
227pub struct ClassificationPolicy {
228    /// Classification used when no rule matches.
229    pub default: SecurityClassification,
230    /// Rules evaluated as a set; the highest matching classification wins.
231    #[serde(default)]
232    pub rules: Vec<ClassificationRule>,
233}
234
235impl Default for ClassificationPolicy {
236    fn default() -> Self {
237        Self {
238            default: SecurityClassification::Public,
239            rules: Vec::new(),
240        }
241    }
242}
243
244impl ClassificationPolicy {
245    /// Creates a policy with the supplied fallback classification.
246    #[must_use]
247    pub fn new(default: SecurityClassification) -> Self {
248        Self {
249            default,
250            rules: Vec::new(),
251        }
252    }
253
254    /// Adds a content-type rule in builder style.
255    #[must_use]
256    pub fn with_rule(
257        mut self,
258        content_type: impl Into<String>,
259        classification: SecurityClassification,
260    ) -> Self {
261        self.add_rule(content_type, classification);
262        self
263    }
264
265    /// Adds a content-type rule.
266    pub fn add_rule(
267        &mut self,
268        content_type: impl Into<String>,
269        classification: SecurityClassification,
270    ) {
271        self.rules.push(ClassificationRule {
272            content_type: content_type.into(),
273            classification,
274        });
275    }
276
277    /// Classifies a content type using the most restrictive matching rule.
278    #[must_use]
279    pub fn classify(&self, content_type: &str) -> SecurityClassification {
280        self.rules
281            .iter()
282            .filter(|rule| content_type_matches(&rule.content_type, content_type))
283            .map(|rule| rule.classification)
284            .max()
285            .unwrap_or(self.default)
286    }
287
288    /// Alias emphasizing that this is an automatic classification decision.
289    #[must_use]
290    pub fn auto_classify(&self, content_type: &str) -> SecurityClassification {
291        self.classify(content_type)
292    }
293}
294
295fn region_matches(pattern: &str, value: &str) -> bool {
296    let pattern = pattern.trim();
297    pattern == "*" || pattern.eq_ignore_ascii_case(value)
298}
299
300fn content_type_matches(pattern: &str, value: &str) -> bool {
301    let pattern = pattern.trim();
302    if pattern == "*" || pattern.eq_ignore_ascii_case(value.trim()) {
303        return true;
304    }
305    pattern
306        .strip_suffix('*')
307        .is_some_and(|prefix| value.trim().starts_with(prefix.trim_end_matches('/')))
308}