Skip to main content

arc_core/
access_log.rs

1//! # Access Logger
2//!
3//! Generic read-side audit logger. Where `EventStore` records *writes* with
4//! [`AuditMetadata`](crate::audit::AuditMetadata), `AccessLogger` records
5//! *reads* of sensitive data — the other half of HIPAA §164.312(b) and the
6//! equivalent obligations under GDPR, PCI-DSS, and SOC 2.
7//!
8//! Reads do not go through the event store. Controllers must explicitly
9//! invoke [`AccessLogger::log_access`] before returning data classified as
10//! anything beyond [`Sensitivity::Public`].
11//!
12//! ## Why generic, not PHI-specific
13//!
14//! The mechanism is the same regardless of regime: log who looked at what,
15//! when, and why. [`Sensitivity`] tags the regime so a downstream sink can
16//! route PHI to a HIPAA-compliant store, PCI to a separate one, drop
17//! [`Sensitivity::Public`] reads, and so on.
18//!
19//! ## Lifecycle
20//!
21//! 1. A read controller resolves the actor (typically the JWT-bound aggregate UUID).
22//! 2. Builds an [`AccessedResource`] describing what's about to be returned.
23//! 3. Calls `logger.log_access(actor, resource, purpose).await`.
24//! 4. Returns the data to the client.
25//!
26//! Default implementations in tests and non-regulated apps use
27//! [`NoOpAccessLogger`] which validates inputs but discards them. Real
28//! deployments wire a JetStream- or DB-backed implementation (Step 3+).
29
30use crate::audit::now_us;
31use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34use uuid::Uuid;
35
36/// Errors emitted by [`AccessLogger`] implementations.
37#[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/// Identity of the entity performing the read.
46///
47/// `aggregate_id` is the framework-level handle (UUID, `"system"`,
48/// `"anonymous"`, or `"legacy-pre-hipaa"` — the same vocabulary as
49/// [`AuditMetadata::actor_id`](crate::audit::AuditMetadata::actor_id)).
50#[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/// What controllers should do when an [`AccessLogger`] sink fails.
70///
71/// HIPAA-2a: read auditing is required by §164.312(b), but a failed audit
72/// cannot reflexively block every read or the system collapses when the sink
73/// blips. Two policies cover the spectrum:
74///
75/// - [`FailurePolicy::FailHard`] — the read is refused (HTTP 503). Required
76///   when `Sensitivity::Phi` or `Sensitivity::Pci` data would be exposed
77///   without an audit record.
78/// - [`FailurePolicy::FailOpenWarn`] — the read is allowed; the failure is
79///   logged via `tracing::warn!`. Acceptable for dev / `Public` /
80///   `Internal` / `Confidential` reads.
81///
82/// [`FailurePolicy::for_sensitivity`] picks the right default per regime.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum FailurePolicy {
85    FailHard,
86    FailOpenWarn,
87}
88
89impl FailurePolicy {
90    /// Default per regime: PHI and PCI fail hard; everything else logs and continues.
91    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/// Sensitivity tag — selects which regulatory regime governs a resource.
100///
101/// Audit sinks use this to decide retention, routing, and whether to record
102/// at all.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Sensitivity {
106    /// Protected Health Information (HIPAA §164).
107    Phi,
108    /// Payment card industry data (PCI-DSS).
109    Pci,
110    /// Personally identifiable information (GDPR/CCPA).
111    Pii,
112    /// Trade secrets / internal-only documents.
113    Confidential,
114    /// Non-public but unregulated business data.
115    Internal,
116    /// Anything intentionally public (logged for completeness; sinks may downsample).
117    Public,
118}
119
120impl Sensitivity {
121    /// True when reads should be auditable in regulated deployments. Sinks
122    /// MAY drop [`Sensitivity::Public`] events to control volume.
123    pub fn is_regulated(self) -> bool {
124        !matches!(self, Sensitivity::Public)
125    }
126}
127
128/// Reason the read happened. Maps to HIPAA "purpose of use" categories but is
129/// applicable to any regime.
130#[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/// Description of the resource being read.
143///
144/// `kind` and `identifier` together name the row(s); `fields` enumerates the
145/// columns the response will expose; `sensitivity` triggers regime-specific
146/// routing in the sink.
147#[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/// Single record produced for every successful `log_access` call.
180///
181/// Sinks serialize this into their target medium; tests inspect it directly.
182#[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    /// Construct an entry from caller inputs. Validates and stamps timestamp.
194    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/// Sink for read-access audit events.
227///
228/// Implementations:
229/// - [`NoOpAccessLogger`] — validates and discards (default in tests, non-PHI apps)
230/// - `RecordingAccessLogger` — keeps entries in memory for assertions (test-utils)
231/// - JetStream-backed (Step 3+)
232/// - DB-backed (out of scope here)
233#[async_trait]
234pub trait AccessLogger: Send + Sync {
235    /// Log a read. Implementations validate the input, record it (or not),
236    /// and return.
237    ///
238    /// Errors are returned but should NOT block the read response in the
239    /// calling controller — callers typically log the error and continue.
240    /// Reads MUST NOT silently fail closed when the audit sink is down,
241    /// because that would mean audit availability bottlenecks every request.
242    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/// Validates inputs and discards the entry. Default for test apps and any
252/// deployment that has not yet wired a real sink.
253#[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        // Build the entry purely for validation side effects.
266        let _ = AccessLogEntry::new(actor, resource, purpose, correlation_id)?;
267        Ok(())
268    }
269}
270
271// ─────────────────────────────────────────────────────────────────────────────
272// Test helpers — captured behind the `test-utils` feature so downstream
273// integration tests can assert against recorded entries.
274// ─────────────────────────────────────────────────────────────────────────────
275
276#[cfg(any(test, feature = "test-utils"))]
277mod recording {
278    use super::*;
279    use std::sync::Arc;
280    use tokio::sync::Mutex;
281
282    /// In-memory `AccessLogger` that records every successful entry.
283    /// Use `entries()` to assert in tests.
284    #[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}