Skip to main content

arc_web/helpers/
access_log.rs

1//! Build [`Identity`](arc_core::access_log::Identity) values from Actix
2//! requests and run an `AccessLogger` call with the appropriate failure
3//! policy for the resource's [`Sensitivity`].
4//!
5//! - PHI / PCI reads → [`FailurePolicy::FailHard`] by default. A logger sink
6//!   failure becomes [`RecordReadOutcome::FailHard`] and the controller
7//!   should respond 503; an audit gap on regulated data is unacceptable.
8//! - Everything else → [`FailurePolicy::FailOpenWarn`]. Failure is warned and
9//!   the read proceeds.
10
11use crate::http::errors::AppError;
12use actix_web::{http::StatusCode, HttpRequest, HttpResponse, Responder};
13use arc_core::access_log::{
14    AccessLogger, AccessedResource, FailurePolicy, Identity, PurposeOfUse, Sensitivity,
15};
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18
19/// A wrapper for sensitive data that has not yet been audit-logged.
20///
21/// `Sensitive<T>` does not implement `Serialize` or `Responder`, preventing it
22/// from being accidentally returned by a controller before an [`AccessLogger`]
23/// call. It can only be "cleansed" into an [`AccessLogged<T>`] through the
24/// appropriate audit helper.
25#[derive(Debug)]
26pub struct Sensitive<T> {
27    data: T,
28    sensitivity: Sensitivity,
29}
30
31impl<T> Sensitive<T> {
32    pub fn new(data: T, sensitivity: Sensitivity) -> Self {
33        Self { data, sensitivity }
34    }
35
36    #[allow(dead_code)]
37    pub fn phi(data: T) -> Self {
38        Self::new(data, Sensitivity::Phi)
39    }
40
41    #[allow(dead_code)]
42    pub fn pci(data: T) -> Self {
43        Self::new(data, Sensitivity::Pci)
44    }
45
46    pub fn pii(data: T) -> Self {
47        Self::new(data, Sensitivity::Pii)
48    }
49
50    #[allow(dead_code)]
51    pub fn confidential(data: T) -> Self {
52        Self::new(data, Sensitivity::Confidential)
53    }
54
55    #[allow(dead_code)]
56    pub fn internal(data: T) -> Self {
57        Self::new(data, Sensitivity::Internal)
58    }
59
60    /// Access the sensitivity level without exposing the data.
61    #[allow(dead_code)]
62    pub fn sensitivity(&self) -> Sensitivity {
63        self.sensitivity
64    }
65
66    /// Deconstruct the sensitive wrapper. This is intentionally internal to
67    /// the framework's audit helpers.
68    pub fn into_parts(self) -> (T, Sensitivity) {
69        (self.data, self.sensitivity)
70    }
71}
72
73/// A wrapper for data that has been successfully audit-logged (or at least
74/// passed the failure policy check).
75///
76/// `AccessLogged<T>` implements `Serialize` and `Responder` (by delegating to `T`),
77/// making it the standard return type for audited read controllers.
78#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
79pub struct AccessLogged<T> {
80    data: T,
81}
82
83impl<T> AccessLogged<T> {
84    /// Wrap data that has been audited.
85    pub fn new(data: T) -> Self {
86        Self { data }
87    }
88
89    /// Return the underlying data.
90    pub fn into_inner(self) -> T {
91        self.data
92    }
93}
94
95/// Build an `Identity` from a request's audit-relevant headers.
96pub fn identity_from(req: &HttpRequest, actor_id: impl Into<String>) -> Identity {
97    Identity {
98        actor_id: actor_id.into(),
99        session_id: None,
100        source_ip: req
101            .connection_info()
102            .realip_remote_addr()
103            .map(str::to_string),
104        user_agent: req
105            .headers()
106            .get("User-Agent")
107            .and_then(|v| v.to_str().ok())
108            .map(str::to_string),
109    }
110}
111
112/// Read `X-Correlation-Id` from the incoming request, if present and parseable.
113pub fn correlation_from(req: &HttpRequest) -> Option<Uuid> {
114    req.headers()
115        .get("X-Correlation-Id")
116        .and_then(|v| v.to_str().ok())
117        .and_then(|s| Uuid::parse_str(s).ok())
118}
119
120/// Log a read of [`Sensitive`] data. Picks failure policy from the resource's
121/// sensitivity.
122///
123/// If successful, returns [`AccessLogged<T>`] which implements [`Responder`].
124pub async fn record_read<T>(
125    logger: &dyn AccessLogger,
126    req: &HttpRequest,
127    actor_id: impl Into<String>,
128    mut resource: AccessedResource,
129    purpose: PurposeOfUse,
130    sensitive: Sensitive<T>,
131) -> Result<AccessLogged<T>, AppError> {
132    let (data, sensitivity) = sensitive.into_parts();
133    resource.sensitivity = sensitivity;
134
135    let policy = FailurePolicy::for_sensitivity(sensitivity);
136    let identity = identity_from(req, actor_id);
137    let correlation = correlation_from(req);
138
139    match logger
140        .log_access(identity, resource, purpose, correlation)
141        .await
142    {
143        Ok(()) => Ok(AccessLogged::new(data)),
144        Err(e) => match policy {
145            FailurePolicy::FailHard => {
146                tracing::error!(
147                    error = %e,
148                    "access log sink failed on regulated read — failing closed"
149                );
150                Err(AppError::AuditFailed {
151                    status: StatusCode::SERVICE_UNAVAILABLE,
152                    message: "Audit sink unavailable".into(),
153                })
154            }
155            FailurePolicy::FailOpenWarn => {
156                tracing::warn!(error = %e, "access log sink rejected read");
157                Ok(AccessLogged::new(data))
158            }
159        },
160    }
161}
162
163impl<T: Serialize> Responder for AccessLogged<T> {
164    type Body = actix_web::body::BoxBody;
165
166    fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
167        HttpResponse::Ok().json(self.into_inner())
168    }
169}