Skip to main content

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}