Skip to main content

adk_payments/auth/
audit.rs

1use std::sync::Arc;
2
3use adk_auth::{AuditEvent, AuditEventType, AuditOutcome, AuditSink};
4use chrono::Utc;
5use serde_json::json;
6
7use crate::domain::{ProtocolDescriptor, TransactionRecord};
8
9use super::{AuthenticatedPaymentRequest, PaymentOperation, PaymentsAuthError};
10
11/// Emits structured payment audit events through an `adk-auth` audit sink.
12///
13/// # Example
14///
15/// ```rust,ignore
16/// use std::sync::Arc;
17///
18/// use adk_auth::{AuditOutcome, FileAuditSink};
19/// use adk_payments::auth::{AuthenticatedPaymentRequest, PaymentAuditor, PaymentOperation};
20/// use adk_payments::domain::ProtocolDescriptor;
21///
22/// # let sink = Arc::new(FileAuditSink::new("/tmp/payments-audit.jsonl").unwrap());
23/// let auditor = PaymentAuditor::new(sink);
24/// let request = AuthenticatedPaymentRequest::new("alice");
25///
26/// # let transaction = todo!("load transaction");
27/// auditor
28///     .record_operation(
29///         &request,
30///         &transaction,
31///         &ProtocolDescriptor::acp("2026-01-30"),
32///         PaymentOperation::CompleteCheckout,
33///         AuditOutcome::Allowed,
34///         false,
35///     )
36///     .await
37///     .unwrap();
38/// ```
39#[derive(Clone)]
40pub struct PaymentAuditor {
41    sink: Arc<dyn AuditSink>,
42}
43
44impl PaymentAuditor {
45    /// Creates a payment auditor backed by the provided sink.
46    #[must_use]
47    pub fn new(sink: Arc<dyn AuditSink>) -> Self {
48        Self { sink }
49    }
50
51    /// Records one sensitive payment operation with structured metadata.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`PaymentsAuthError::AuditSink`] when the underlying sink fails.
56    pub async fn record_operation(
57        &self,
58        request: &AuthenticatedPaymentRequest,
59        record: &TransactionRecord,
60        protocol: &ProtocolDescriptor,
61        operation: PaymentOperation,
62        outcome: AuditOutcome,
63        intervention_occurred: bool,
64    ) -> Result<(), PaymentsAuthError> {
65        let event = payment_audit_event(
66            request,
67            record,
68            protocol,
69            operation,
70            &outcome,
71            intervention_occurred,
72        );
73        self.sink.log(event).await.map_err(PaymentsAuthError::from)
74    }
75}
76
77fn payment_audit_event(
78    request: &AuthenticatedPaymentRequest,
79    record: &TransactionRecord,
80    protocol: &ProtocolDescriptor,
81    operation: PaymentOperation,
82    outcome: &AuditOutcome,
83    intervention_occurred: bool,
84) -> AuditEvent {
85    let metadata = json!({
86        "operation": operation.as_str(),
87        "transactionId": record.transaction_id.as_str(),
88        "protocol": protocol.name,
89        "protocolVersion": protocol.version,
90        "merchantOfRecord": {
91            "merchantId": record.merchant_of_record.merchant_id,
92            "legalName": record.merchant_of_record.legal_name,
93            "displayName": record.merchant_of_record.display_name,
94        },
95        "outcome": audit_outcome_name(outcome),
96        "interventionOccurred": intervention_occurred,
97        "authenticatedRequest": {
98            "userId": request.user_id,
99            "sessionId": request.session_id,
100            "tenantId": request.tenant_id,
101            "scopes": request.scopes,
102            "metadata": request.metadata,
103        },
104        "sessionIdentity": record.session_identity.is_some(),
105        "protocolActor": {
106            "actorId": record.initiated_by.actor_id,
107            "role": record.initiated_by.role,
108            "displayName": record.initiated_by.display_name,
109            "tenantId": record.initiated_by.tenant_id,
110        },
111    });
112
113    AuditEvent {
114        timestamp: Utc::now(),
115        user: request.user_id.clone(),
116        session_id: None, // redacted — sensitive identifier
117        event_type: AuditEventType::PermissionCheck,
118        resource: operation.audit_resource().to_string(),
119        outcome: outcome.clone(),
120        metadata: Some(metadata),
121    }
122}
123
124fn audit_outcome_name(outcome: &AuditOutcome) -> &'static str {
125    match outcome {
126        AuditOutcome::Allowed => "allowed",
127        AuditOutcome::Denied => "denied",
128        AuditOutcome::Error => "error",
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::sync::{Arc, Mutex};
135
136    use adk_auth::AuthError;
137    use async_trait::async_trait;
138    use chrono::{TimeZone, Utc};
139    use serde_json::json;
140
141    use super::*;
142    use crate::domain::{
143        Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
144        ProtocolExtensions, TransactionId,
145    };
146
147    struct RecordingAuditSink {
148        events: Mutex<Vec<AuditEvent>>,
149    }
150
151    impl RecordingAuditSink {
152        fn new() -> Self {
153            Self { events: Mutex::new(Vec::new()) }
154        }
155
156        fn recorded_events(&self) -> Vec<AuditEvent> {
157            self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
158        }
159    }
160
161    #[async_trait]
162    impl AuditSink for RecordingAuditSink {
163        async fn log(&self, event: AuditEvent) -> Result<(), AuthError> {
164            self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(event);
165            Ok(())
166        }
167    }
168
169    fn sample_transaction() -> TransactionRecord {
170        let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 14, 0, 0).unwrap();
171        let mut record = TransactionRecord::new(
172            TransactionId::from("tx-audit"),
173            CommerceActor {
174                actor_id: "merchant-agent".to_string(),
175                role: CommerceActorRole::Merchant,
176                display_name: Some("merchant".to_string()),
177                tenant_id: Some("tenant-1".to_string()),
178                extensions: ProtocolExtensions::default(),
179            },
180            MerchantRef {
181                merchant_id: "merchant-123".to_string(),
182                legal_name: "Merchant Example LLC".to_string(),
183                display_name: Some("Merchant Example".to_string()),
184                statement_descriptor: Some("MERCHANT*EXAMPLE".to_string()),
185                country_code: Some("US".to_string()),
186                website: Some("https://merchant.example".to_string()),
187                extensions: ProtocolExtensions::default(),
188            },
189            CommerceMode::HumanPresent,
190            Cart {
191                cart_id: Some("cart-1".to_string()),
192                lines: vec![CartLine {
193                    line_id: "line-1".to_string(),
194                    merchant_sku: Some("sku-1".to_string()),
195                    title: "Widget".to_string(),
196                    quantity: 1,
197                    unit_price: Money::new("USD", 3_500, 2),
198                    total_price: Money::new("USD", 3_500, 2),
199                    product_class: Some("widgets".to_string()),
200                    extensions: ProtocolExtensions::default(),
201                }],
202                subtotal: Some(Money::new("USD", 3_500, 2)),
203                adjustments: Vec::new(),
204                total: Money::new("USD", 3_500, 2),
205                affiliate_attribution: None,
206                extensions: ProtocolExtensions::default(),
207            },
208            created_at,
209        );
210        record.session_identity = Some(adk_core::AdkIdentity::new(
211            adk_core::AppName::try_from("commerce-app").unwrap(),
212            adk_core::UserId::try_from("alice").unwrap(),
213            adk_core::SessionId::try_from("session-123").unwrap(),
214        ));
215        record
216    }
217
218    #[tokio::test]
219    async fn auditor_emits_structured_payment_metadata() {
220        let sink = Arc::new(RecordingAuditSink::new());
221        let auditor = PaymentAuditor::new(sink.clone());
222        let request = AuthenticatedPaymentRequest::new("alice")
223            .with_session_id("session-123")
224            .with_tenant_id("tenant-1")
225            .with_scopes(["payments:checkout:complete"])
226            .with_metadata("channel", json!("agent"));
227
228        auditor
229            .record_operation(
230                &request,
231                &sample_transaction(),
232                &ProtocolDescriptor::acp("2026-01-30"),
233                PaymentOperation::CompleteCheckout,
234                AuditOutcome::Allowed,
235                true,
236            )
237            .await
238            .unwrap();
239
240        let events = sink.recorded_events();
241        assert_eq!(events.len(), 1);
242        assert_eq!(events[0].resource, "payments.checkout.complete");
243        assert_eq!(events[0].user, "alice");
244        assert_eq!(events[0].session_id, None);
245
246        let metadata = events[0].metadata.as_ref().unwrap();
247        assert_eq!(metadata["transactionId"], "tx-audit");
248        assert_eq!(metadata["protocol"], "acp");
249        assert_eq!(metadata["protocolVersion"], "2026-01-30");
250        assert_eq!(metadata["operation"], "checkout_complete");
251        assert_eq!(metadata["interventionOccurred"], true);
252        assert_eq!(metadata["authenticatedRequest"]["tenantId"], "tenant-1");
253        assert_eq!(metadata["protocolActor"]["actorId"], "merchant-agent");
254        assert_eq!(metadata["sessionIdentity"], true);
255    }
256}