1use crate::audit::now_us;
31use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34use uuid::Uuid;
35
36#[derive(Debug, Error)]
38pub enum AccessLogError {
39 #[error("access log validation failed: {0}")]
40 Validation(String),
41 #[error("access log sink failed: {0}")]
42 Sink(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct Identity {
52 pub actor_id: String,
53 pub session_id: Option<String>,
54 pub source_ip: Option<String>,
55 pub user_agent: Option<String>,
56}
57
58impl Identity {
59 pub fn new(actor_id: impl Into<String>) -> Self {
60 Self {
61 actor_id: actor_id.into(),
62 session_id: None,
63 source_ip: None,
64 user_agent: None,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum FailurePolicy {
85 FailHard,
86 FailOpenWarn,
87}
88
89impl FailurePolicy {
90 pub fn for_sensitivity(s: Sensitivity) -> Self {
92 match s {
93 Sensitivity::Phi | Sensitivity::Pci => FailurePolicy::FailHard,
94 _ => FailurePolicy::FailOpenWarn,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Sensitivity {
106 Phi,
108 Pci,
110 Pii,
112 Confidential,
114 Internal,
116 Public,
118}
119
120impl Sensitivity {
121 pub fn is_regulated(self) -> bool {
124 !matches!(self, Sensitivity::Public)
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum PurposeOfUse {
133 Treatment,
134 Payment,
135 Operations,
136 Emergency,
137 UserInitiated,
138 AuditReview,
139 Other,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct AccessedResource {
149 pub kind: String,
150 pub identifier: String,
151 pub fields: Vec<String>,
152 pub sensitivity: Sensitivity,
153}
154
155impl AccessedResource {
156 pub fn new(
157 kind: impl Into<String>,
158 identifier: impl Into<String>,
159 sensitivity: Sensitivity,
160 ) -> Self {
161 Self {
162 kind: kind.into(),
163 identifier: identifier.into(),
164 fields: Vec::new(),
165 sensitivity,
166 }
167 }
168
169 pub fn with_fields<I, S>(mut self, fields: I) -> Self
170 where
171 I: IntoIterator<Item = S>,
172 S: Into<String>,
173 {
174 self.fields = fields.into_iter().map(Into::into).collect();
175 self
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct AccessLogEntry {
184 pub access_id: Uuid,
185 pub actor: Identity,
186 pub resource: AccessedResource,
187 pub purpose: PurposeOfUse,
188 pub timestamp_utc_us: i64,
189 pub correlation_id: Option<Uuid>,
190}
191
192impl AccessLogEntry {
193 pub fn new(
195 actor: Identity,
196 resource: AccessedResource,
197 purpose: PurposeOfUse,
198 correlation_id: Option<Uuid>,
199 ) -> Result<Self, AccessLogError> {
200 if actor.actor_id.trim().is_empty() {
201 return Err(AccessLogError::Validation(
202 "actor_id must be non-empty (use 'anonymous' or 'system' explicitly)".into(),
203 ));
204 }
205 if resource.kind.trim().is_empty() {
206 return Err(AccessLogError::Validation(
207 "resource.kind must be non-empty".into(),
208 ));
209 }
210 if resource.identifier.trim().is_empty() {
211 return Err(AccessLogError::Validation(
212 "resource.identifier must be non-empty".into(),
213 ));
214 }
215 Ok(Self {
216 access_id: Uuid::new_v4(),
217 actor,
218 resource,
219 purpose,
220 timestamp_utc_us: now_us(),
221 correlation_id,
222 })
223 }
224}
225
226#[async_trait]
234pub trait AccessLogger: Send + Sync {
235 async fn log_access(
243 &self,
244 actor: Identity,
245 resource: AccessedResource,
246 purpose: PurposeOfUse,
247 correlation_id: Option<Uuid>,
248 ) -> Result<(), AccessLogError>;
249}
250
251#[derive(Debug, Default, Clone, Copy)]
254pub struct NoOpAccessLogger;
255
256#[async_trait]
257impl AccessLogger for NoOpAccessLogger {
258 async fn log_access(
259 &self,
260 actor: Identity,
261 resource: AccessedResource,
262 purpose: PurposeOfUse,
263 correlation_id: Option<Uuid>,
264 ) -> Result<(), AccessLogError> {
265 let _ = AccessLogEntry::new(actor, resource, purpose, correlation_id)?;
267 Ok(())
268 }
269}
270
271#[cfg(any(test, feature = "test-utils"))]
277mod recording {
278 use super::*;
279 use std::sync::Arc;
280 use tokio::sync::Mutex;
281
282 #[derive(Clone, Default)]
285 pub struct RecordingAccessLogger {
286 entries: Arc<Mutex<Vec<AccessLogEntry>>>,
287 }
288
289 impl RecordingAccessLogger {
290 pub fn new() -> Self {
291 Self::default()
292 }
293
294 pub async fn entries(&self) -> Vec<AccessLogEntry> {
295 self.entries.lock().await.clone()
296 }
297 }
298
299 #[async_trait]
300 impl AccessLogger for RecordingAccessLogger {
301 async fn log_access(
302 &self,
303 actor: Identity,
304 resource: AccessedResource,
305 purpose: PurposeOfUse,
306 correlation_id: Option<Uuid>,
307 ) -> Result<(), AccessLogError> {
308 let entry = AccessLogEntry::new(actor, resource, purpose, correlation_id)?;
309 self.entries.lock().await.push(entry);
310 Ok(())
311 }
312 }
313}
314
315#[cfg(any(test, feature = "test-utils"))]
316pub use recording::RecordingAccessLogger;
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 fn ok_actor() -> Identity {
323 Identity::new("alice-uuid")
324 }
325
326 fn ok_resource() -> AccessedResource {
327 AccessedResource::new("UserProfile", "alice-uuid", Sensitivity::Pii)
328 .with_fields(["name", "email"])
329 }
330
331 #[tokio::test]
332 async fn test_noop_logger_accepts_valid_input() {
333 let logger = NoOpAccessLogger;
334 logger
335 .log_access(ok_actor(), ok_resource(), PurposeOfUse::UserInitiated, None)
336 .await
337 .expect("noop must accept valid input");
338 }
339
340 #[tokio::test]
341 async fn test_noop_logger_rejects_empty_actor() {
342 let logger = NoOpAccessLogger;
343 let bad_actor = Identity::new("");
344 let err = logger
345 .log_access(bad_actor, ok_resource(), PurposeOfUse::UserInitiated, None)
346 .await
347 .unwrap_err();
348 assert!(matches!(err, AccessLogError::Validation(_)));
349 }
350
351 #[tokio::test]
352 async fn test_noop_logger_rejects_empty_resource_kind() {
353 let bad_resource = AccessedResource::new("", "x", Sensitivity::Pii);
354 let err = NoOpAccessLogger
355 .log_access(ok_actor(), bad_resource, PurposeOfUse::UserInitiated, None)
356 .await
357 .unwrap_err();
358 assert!(matches!(err, AccessLogError::Validation(_)));
359 }
360
361 #[tokio::test]
362 async fn test_noop_logger_rejects_empty_identifier() {
363 let bad_resource = AccessedResource::new("X", " ", Sensitivity::Pii);
364 let err = NoOpAccessLogger
365 .log_access(ok_actor(), bad_resource, PurposeOfUse::UserInitiated, None)
366 .await
367 .unwrap_err();
368 assert!(matches!(err, AccessLogError::Validation(_)));
369 }
370
371 #[tokio::test]
372 async fn test_recording_logger_captures_entries() {
373 let logger = RecordingAccessLogger::new();
374 let corr = Uuid::new_v4();
375
376 logger
377 .log_access(
378 ok_actor(),
379 ok_resource(),
380 PurposeOfUse::Treatment,
381 Some(corr),
382 )
383 .await
384 .unwrap();
385 logger
386 .log_access(
387 Identity::new("bob"),
388 AccessedResource::new("Order", "ord-1", Sensitivity::Pci),
389 PurposeOfUse::Payment,
390 None,
391 )
392 .await
393 .unwrap();
394
395 let entries = logger.entries().await;
396 assert_eq!(entries.len(), 2);
397 assert_eq!(entries[0].actor.actor_id, "alice-uuid");
398 assert_eq!(entries[0].resource.sensitivity, Sensitivity::Pii);
399 assert_eq!(entries[0].purpose, PurposeOfUse::Treatment);
400 assert_eq!(entries[0].correlation_id, Some(corr));
401 assert!(entries[0].timestamp_utc_us > 0);
402 assert_eq!(entries[1].resource.sensitivity, Sensitivity::Pci);
403 assert_eq!(entries[1].correlation_id, None);
404 }
405
406 #[test]
407 fn test_sensitivity_regulated_classification() {
408 assert!(Sensitivity::Phi.is_regulated());
409 assert!(Sensitivity::Pci.is_regulated());
410 assert!(Sensitivity::Pii.is_regulated());
411 assert!(Sensitivity::Confidential.is_regulated());
412 assert!(Sensitivity::Internal.is_regulated());
413 assert!(!Sensitivity::Public.is_regulated());
414 }
415
416 #[test]
417 fn test_serde_roundtrip() {
418 let entry = AccessLogEntry::new(
419 ok_actor(),
420 ok_resource(),
421 PurposeOfUse::Treatment,
422 Some(Uuid::new_v4()),
423 )
424 .unwrap();
425 let s = serde_json::to_string(&entry).unwrap();
426 let back: AccessLogEntry = serde_json::from_str(&s).unwrap();
427 assert_eq!(entry, back);
428 }
429
430 #[test]
431 fn test_resource_with_fields_chains() {
432 let r = AccessedResource::new("Patient", "pat-1", Sensitivity::Phi)
433 .with_fields(["vitals", "notes"]);
434 assert_eq!(r.fields, vec!["vitals", "notes"]);
435 }
436
437 #[test]
438 fn test_failure_policy_phi_pci_fail_hard() {
439 assert_eq!(
440 FailurePolicy::for_sensitivity(Sensitivity::Phi),
441 FailurePolicy::FailHard
442 );
443 assert_eq!(
444 FailurePolicy::for_sensitivity(Sensitivity::Pci),
445 FailurePolicy::FailHard
446 );
447 }
448
449 #[test]
450 fn test_failure_policy_others_fail_open_warn() {
451 for s in [
452 Sensitivity::Pii,
453 Sensitivity::Confidential,
454 Sensitivity::Internal,
455 Sensitivity::Public,
456 ] {
457 assert_eq!(
458 FailurePolicy::for_sensitivity(s),
459 FailurePolicy::FailOpenWarn,
460 "unexpected default policy for {:?}",
461 s
462 );
463 }
464 }
465}