kimberlite_abac/attributes.rs
1//! Attribute types for ABAC evaluation.
2//!
3//! Three attribute categories drive access decisions:
4//! - **User attributes**: Role, department, clearance level, device, network
5//! - **Resource attributes**: Data classification, owner tenant, stream name
6//! - **Environment attributes**: Time, business hours, source country
7
8use chrono::{DateTime, Datelike, Timelike, Utc};
9use kimberlite_types::{ClearanceLevel, DataClass};
10use serde::{Deserialize, Serialize};
11
12// ============================================================================
13// Device Type
14// ============================================================================
15
16/// The type of device making the access request.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub enum DeviceType {
19 /// Desktop workstation or laptop.
20 Desktop,
21 /// Mobile phone or tablet.
22 Mobile,
23 /// Server or automated system.
24 Server,
25 /// Unknown or unclassified device.
26 Unknown,
27}
28
29// ============================================================================
30// User Attributes
31// ============================================================================
32
33/// Highest meaningful clearance level, expressed as a u8 for legacy policy
34/// conditions (see `Condition::ClearanceLevelAtLeast(u8)`). Prefer
35/// [`ClearanceLevel::TopSecret`] in new code.
36pub const MAX_CLEARANCE: u8 = ClearanceLevel::TopSecret.as_u8();
37
38/// Attributes describing the user making the access request.
39///
40/// These are typically populated from the authentication/identity provider
41/// at the start of each request.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct UserAttributes {
44 /// The user's role (e.g., "admin", "analyst", "user", "auditor").
45 pub role: String,
46 /// The user's department (e.g., "engineering", "compliance", "finance").
47 pub department: String,
48 /// Security clearance level in the Bell–LaPadula lattice.
49 ///
50 /// Uses the [`ClearanceLevel`] enum rather than a raw `u8` — out-of-range
51 /// values are unrepresentable after construction. Regression:
52 /// `fuzz_abac_evaluator` previously produced 12 crashes by feeding
53 /// arbitrary u8 inputs through the constructor.
54 pub clearance_level: ClearanceLevel,
55 /// IP address of the request origin (String to avoid `IpAddr` serde issues).
56 pub ip_address: Option<String>,
57 /// The type of device making the request.
58 pub device_type: DeviceType,
59 /// Tenant the user belongs to, if any.
60 pub tenant_id: Option<u64>,
61}
62
63impl UserAttributes {
64 /// Creates a new `UserAttributes` with required fields and sensible defaults.
65 ///
66 /// Sets `ip_address` to `None`, `device_type` to `Unknown`, and `tenant_id` to `None`.
67 ///
68 /// # Clearance clamping
69 ///
70 /// Accepts a `u8` for backward compatibility with existing policy
71 /// definitions that use literal integers. Values above
72 /// [`MAX_CLEARANCE`] (3 = top secret) saturate to [`ClearanceLevel::TopSecret`];
73 /// the field itself is typed as [`ClearanceLevel`], so out-of-range
74 /// values cannot leak past this boundary.
75 ///
76 /// For new code, prefer [`UserAttributes::with_clearance`] which takes
77 /// [`ClearanceLevel`] directly.
78 pub fn new(role: &str, department: &str, clearance_level: u8) -> Self {
79 let clearance_level =
80 ClearanceLevel::try_from(clearance_level).unwrap_or(ClearanceLevel::TopSecret);
81 Self::with_clearance(role, department, clearance_level)
82 }
83
84 /// Creates a new `UserAttributes` using the typed [`ClearanceLevel`] enum.
85 ///
86 /// This is the PRESSURECRAFT-preferred constructor — the clearance level
87 /// is unrepresentable out-of-range, so no saturation or validation runs
88 /// at the boundary.
89 pub fn with_clearance(role: &str, department: &str, clearance_level: ClearanceLevel) -> Self {
90 Self {
91 role: role.to_string(),
92 department: department.to_string(),
93 clearance_level,
94 ip_address: None,
95 device_type: DeviceType::Unknown,
96 tenant_id: None,
97 }
98 }
99
100 /// Sets the IP address.
101 pub fn with_ip(mut self, ip: &str) -> Self {
102 self.ip_address = Some(ip.to_string());
103 self
104 }
105
106 /// Sets the device type.
107 pub fn with_device(mut self, device: DeviceType) -> Self {
108 self.device_type = device;
109 self
110 }
111
112 /// Sets the tenant ID.
113 pub fn with_tenant(mut self, tenant_id: u64) -> Self {
114 self.tenant_id = Some(tenant_id);
115 self
116 }
117}
118
119// ============================================================================
120// Resource Attributes
121// ============================================================================
122
123/// Attributes describing the resource being accessed.
124///
125/// Populated from stream metadata and the data catalog at query time.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ResourceAttributes {
128 /// The data classification of the resource.
129 pub data_class: DataClass,
130 /// The tenant that owns this resource.
131 pub owner_tenant: u64,
132 /// The name of the stream being accessed.
133 pub stream_name: String,
134 /// Configured retention period in days (for SOX 7yr, HIPAA 6yr, PCI 1yr checks).
135 pub retention_days: Option<u32>,
136 /// Whether data correction/amendment is enabled for this resource.
137 pub correction_allowed: bool,
138 /// Whether this resource is under a legal hold (prevents deletion).
139 pub legal_hold_active: bool,
140 /// Specific fields being requested (for field-level restriction checks).
141 pub requested_fields: Option<Vec<String>>,
142}
143
144impl ResourceAttributes {
145 /// Creates a new `ResourceAttributes` with sensible defaults for compliance fields.
146 ///
147 /// Sets `retention_days` and `requested_fields` to `None`,
148 /// `correction_allowed` and `legal_hold_active` to `false`.
149 pub fn new(data_class: DataClass, owner_tenant: u64, stream_name: &str) -> Self {
150 Self {
151 data_class,
152 owner_tenant,
153 stream_name: stream_name.to_string(),
154 retention_days: None,
155 correction_allowed: false,
156 legal_hold_active: false,
157 requested_fields: None,
158 }
159 }
160}
161
162// ============================================================================
163// Environment Attributes
164// ============================================================================
165
166/// Attributes describing the environment/context of the access request.
167///
168/// These are computed at request time from system state and are not
169/// user-controlled, making them harder to forge.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct EnvironmentAttributes {
172 /// The timestamp of the access request.
173 pub timestamp: DateTime<Utc>,
174 /// Whether the request falls within business hours (9:00-17:00 UTC, weekdays).
175 pub is_business_hours: bool,
176 /// ISO 3166-1 alpha-2 country code of the request source (e.g., "US", "DE").
177 pub source_country: String,
178}
179
180impl EnvironmentAttributes {
181 /// Creates `EnvironmentAttributes` from a timestamp, auto-computing business hours.
182 ///
183 /// Business hours are defined as 09:00-17:00 UTC on weekdays (Mon-Fri).
184 /// This is a simplification; production systems should use per-tenant timezone config.
185 pub fn from_timestamp(ts: DateTime<Utc>, country: &str) -> Self {
186 let hour = ts.hour();
187 let weekday = ts.weekday();
188 let is_weekday = matches!(
189 weekday,
190 chrono::Weekday::Mon
191 | chrono::Weekday::Tue
192 | chrono::Weekday::Wed
193 | chrono::Weekday::Thu
194 | chrono::Weekday::Fri
195 );
196 let is_business_hours = is_weekday && (9..17).contains(&hour);
197
198 Self {
199 timestamp: ts,
200 is_business_hours,
201 source_country: country.to_string(),
202 }
203 }
204
205 /// Creates `EnvironmentAttributes` with explicit values (no auto-computation).
206 pub fn new(timestamp: DateTime<Utc>, is_business_hours: bool, source_country: &str) -> Self {
207 Self {
208 timestamp,
209 is_business_hours,
210 source_country: source_country.to_string(),
211 }
212 }
213}
214
215// ============================================================================
216// Tests
217// ============================================================================
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use chrono::TimeZone;
223
224 #[test]
225 fn test_business_hours_weekday_morning() {
226 // Wednesday at 10:00 UTC => business hours
227 let ts = Utc.with_ymd_and_hms(2025, 1, 8, 10, 0, 0).unwrap();
228 let env = EnvironmentAttributes::from_timestamp(ts, "US");
229 assert!(
230 env.is_business_hours,
231 "10:00 UTC on Wednesday should be business hours"
232 );
233 }
234
235 #[test]
236 fn test_business_hours_weekday_evening() {
237 // Wednesday at 18:00 UTC => NOT business hours
238 let ts = Utc.with_ymd_and_hms(2025, 1, 8, 18, 0, 0).unwrap();
239 let env = EnvironmentAttributes::from_timestamp(ts, "US");
240 assert!(
241 !env.is_business_hours,
242 "18:00 UTC on Wednesday should not be business hours"
243 );
244 }
245
246 #[test]
247 fn test_business_hours_weekend() {
248 // Saturday at 10:00 UTC => NOT business hours
249 let ts = Utc.with_ymd_and_hms(2025, 1, 11, 10, 0, 0).unwrap();
250 let env = EnvironmentAttributes::from_timestamp(ts, "US");
251 assert!(
252 !env.is_business_hours,
253 "10:00 UTC on Saturday should not be business hours"
254 );
255 }
256
257 #[test]
258 fn test_business_hours_boundary_start() {
259 // Wednesday at 09:00 UTC => business hours (inclusive start)
260 let ts = Utc.with_ymd_and_hms(2025, 1, 8, 9, 0, 0).unwrap();
261 let env = EnvironmentAttributes::from_timestamp(ts, "US");
262 assert!(
263 env.is_business_hours,
264 "09:00 UTC on Wednesday should be business hours"
265 );
266 }
267
268 #[test]
269 fn test_business_hours_boundary_end() {
270 // Wednesday at 17:00 UTC => NOT business hours (exclusive end)
271 let ts = Utc.with_ymd_and_hms(2025, 1, 8, 17, 0, 0).unwrap();
272 let env = EnvironmentAttributes::from_timestamp(ts, "US");
273 assert!(
274 !env.is_business_hours,
275 "17:00 UTC on Wednesday should not be business hours (exclusive end)"
276 );
277 }
278
279 #[test]
280 fn test_user_attributes_builder() {
281 let user = UserAttributes::new("admin", "engineering", 3)
282 .with_ip("192.168.1.1")
283 .with_device(DeviceType::Desktop)
284 .with_tenant(42);
285
286 assert_eq!(user.role, "admin");
287 assert_eq!(user.department, "engineering");
288 assert_eq!(user.clearance_level, ClearanceLevel::TopSecret);
289 assert_eq!(user.ip_address, Some("192.168.1.1".to_string()));
290 assert_eq!(user.device_type, DeviceType::Desktop);
291 assert_eq!(user.tenant_id, Some(42));
292 }
293
294 /// Regression: `fuzz_abac_evaluator` previously produced 12 crashes by
295 /// feeding arbitrary u8 inputs through this public constructor. The
296 /// constructor now saturates to `ClearanceLevel::TopSecret` in every
297 /// build — the `debug_assert!` that previously tripped under
298 /// cargo-fuzz's release+`debug_assertions` build was redundant with
299 /// the `try_from(..).unwrap_or(TopSecret)` fallback.
300 #[test]
301 fn test_user_attributes_clearance_saturates_to_max() {
302 assert_eq!(
303 UserAttributes::new("admin", "eng", 10).clearance_level,
304 ClearanceLevel::TopSecret
305 );
306 for c in [4u8, 10, 42, 172, 255] {
307 assert_eq!(
308 UserAttributes::new("admin", "engineering", c).clearance_level,
309 ClearanceLevel::TopSecret
310 );
311 }
312 for (c, expected) in [
313 (0, ClearanceLevel::Public),
314 (1, ClearanceLevel::Confidential),
315 (2, ClearanceLevel::Secret),
316 (3, ClearanceLevel::TopSecret),
317 ] {
318 assert_eq!(
319 UserAttributes::new("admin", "engineering", c).clearance_level,
320 expected
321 );
322 }
323 }
324
325 /// New PRESSURECRAFT constructor `with_clearance` takes `ClearanceLevel`
326 /// directly — the type makes out-of-range inputs unrepresentable, so the
327 /// saturation logic doesn't need to run.
328 #[test]
329 fn test_user_attributes_with_clearance_typed() {
330 let user = UserAttributes::with_clearance("analyst", "engineering", ClearanceLevel::Secret);
331 assert_eq!(user.clearance_level, ClearanceLevel::Secret);
332 }
333
334 #[test]
335 fn test_resource_attributes() {
336 let resource = ResourceAttributes::new(DataClass::PHI, 1, "patient_records");
337 assert_eq!(resource.data_class, DataClass::PHI);
338 assert_eq!(resource.owner_tenant, 1);
339 assert_eq!(resource.stream_name, "patient_records");
340 }
341}