1use serde::{Deserialize, Serialize};
4
5use super::identity::TenantId;
6use super::tenant_isolation::TenantBoundary;
7
8#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11#[repr(u8)]
12pub enum SecurityClassification {
13 #[default]
15 Public = 0,
16 Internal = 1,
18 Confidential = 2,
20 Restricted = 3,
22 TopSecret = 4,
24}
25
26impl SecurityClassification {
27 #[must_use]
29 pub const fn rank(self) -> u8 {
30 self as u8
31 }
32
33 #[must_use]
35 pub const fn may_flow_to(self, destination: Self) -> bool {
36 self.rank() <= destination.rank()
37 }
38
39 #[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#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
55pub struct ResidencyRequirement {
56 #[serde(default)]
59 pub allowed_regions: Vec<String>,
60 #[serde(default)]
62 pub prohibited_regions: Vec<String>,
63 #[serde(default)]
65 pub data_sovereign: bool,
66}
67
68impl ResidencyRequirement {
69 #[must_use]
71 pub fn unrestricted() -> Self {
72 Self::default()
73 }
74
75 #[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 #[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 #[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 #[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 #[must_use]
118 pub const fn with_sovereignty(mut self, sovereign: bool) -> Self {
119 self.data_sovereign = sovereign;
120 self
121 }
122
123 #[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 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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct ClassifiedData<T> {
158 pub data: T,
160 pub classification: SecurityClassification,
162 pub residency: ResidencyRequirement,
164}
165
166impl<T> ClassifiedData<T> {
167 #[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 #[must_use]
183 pub const fn as_ref(&self) -> &T {
184 &self.data
185 }
186
187 #[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 #[must_use]
199 pub fn into_inner(self) -> T {
200 self.data
201 }
202
203 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
218pub struct ClassificationRule {
219 pub content_type: String,
221 pub classification: SecurityClassification,
223}
224
225#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
227pub struct ClassificationPolicy {
228 pub default: SecurityClassification,
230 #[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 #[must_use]
247 pub fn new(default: SecurityClassification) -> Self {
248 Self {
249 default,
250 rules: Vec::new(),
251 }
252 }
253
254 #[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 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 #[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 #[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}