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        workspace_id: None,
122        tenant_id: request.tenant_id.clone(),
123        request_id: None,
124        ip_address: None,
125        resource_id: None,
126        action: Some("permission_check".to_string()),
127        prev_hash: None,
128    }
129}
130
131fn audit_outcome_name(outcome: &AuditOutcome) -> &'static str {
132    match outcome {
133        AuditOutcome::Allowed => "allowed",
134        AuditOutcome::Denied => "denied",
135        AuditOutcome::Error => "error",
136        _ => "unknown",
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use std::sync::{Arc, Mutex};
143
144    use adk_auth::AuthError;
145    use async_trait::async_trait;
146    use chrono::{TimeZone, Utc};
147    use serde_json::json;
148
149    use super::*;
150    use crate::domain::{
151        Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
152        ProtocolExtensions, TransactionId,
153    };
154
155    struct RecordingAuditSink {
156        events: Mutex<Vec<AuditEvent>>,
157    }
158
159    impl RecordingAuditSink {
160        fn new() -> Self {
161            Self { events: Mutex::new(Vec::new()) }
162        }
163
164        fn recorded_events(&self) -> Vec<AuditEvent> {
165            self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
166        }
167    }
168
169    #[async_trait]
170    impl AuditSink for RecordingAuditSink {
171        async fn log(&self, event: AuditEvent) -> Result<(), AuthError> {
172            self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(event);
173            Ok(())
174        }
175    }
176
177    fn sample_transaction() -> TransactionRecord {
178        let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 14, 0, 0).unwrap();
179        let mut record = TransactionRecord::new(
180            TransactionId::from("tx-audit"),
181            CommerceActor {
182                actor_id: "merchant-agent".to_string(),
183                role: CommerceActorRole::Merchant,
184                display_name: Some("merchant".to_string()),
185                tenant_id: Some("tenant-1".to_string()),
186                extensions: ProtocolExtensions::default(),
187            },
188            MerchantRef {
189                merchant_id: "merchant-123".to_string(),
190                legal_name: "Merchant Example LLC".to_string(),
191                display_name: Some("Merchant Example".to_string()),
192                statement_descriptor: Some("MERCHANT*EXAMPLE".to_string()),
193                country_code: Some("US".to_string()),
194                website: Some("https://merchant.example".to_string()),
195                extensions: ProtocolExtensions::default(),
196            },
197            CommerceMode::HumanPresent,
198            Cart {
199                cart_id: Some("cart-1".to_string()),
200                lines: vec![CartLine {
201                    line_id: "line-1".to_string(),
202                    merchant_sku: Some("sku-1".to_string()),
203                    title: "Widget".to_string(),
204                    quantity: 1,
205                    unit_price: Money::new("USD", 3_500, 2),
206                    total_price: Money::new("USD", 3_500, 2),
207                    product_class: Some("widgets".to_string()),
208                    extensions: ProtocolExtensions::default(),
209                }],
210                subtotal: Some(Money::new("USD", 3_500, 2)),
211                adjustments: Vec::new(),
212                total: Money::new("USD", 3_500, 2),
213                affiliate_attribution: None,
214                extensions: ProtocolExtensions::default(),
215            },
216            created_at,
217        );
218        record.session_identity = Some(adk_core::AdkIdentity::new(
219            adk_core::AppName::try_from("commerce-app").unwrap(),
220            adk_core::UserId::try_from("alice").unwrap(),
221            adk_core::SessionId::try_from("session-123").unwrap(),
222        ));
223        record
224    }
225
226    #[tokio::test]
227    async fn auditor_emits_structured_payment_metadata() {
228        let sink = Arc::new(RecordingAuditSink::new());
229        let auditor = PaymentAuditor::new(sink.clone());
230        let request = AuthenticatedPaymentRequest::new("alice")
231            .with_session_id("session-123")
232            .with_tenant_id("tenant-1")
233            .with_scopes(["payments:checkout:complete"])
234            .with_metadata("channel", json!("agent"));
235
236        auditor
237            .record_operation(
238                &request,
239                &sample_transaction(),
240                &ProtocolDescriptor::acp("2026-01-30"),
241                PaymentOperation::CompleteCheckout,
242                AuditOutcome::Allowed,
243                true,
244            )
245            .await
246            .unwrap();
247
248        let events = sink.recorded_events();
249        assert_eq!(events.len(), 1);
250        assert_eq!(events[0].resource, "payments.checkout.complete");
251        assert_eq!(events[0].user, "alice");
252        assert_eq!(events[0].session_id, None);
253
254        let metadata = events[0].metadata.as_ref().unwrap();
255        assert_eq!(metadata["transactionId"], "tx-audit");
256        assert_eq!(metadata["protocol"], "acp");
257        assert_eq!(metadata["protocolVersion"], "2026-01-30");
258        assert_eq!(metadata["operation"], "checkout_complete");
259        assert_eq!(metadata["interventionOccurred"], true);
260        assert_eq!(metadata["authenticatedRequest"]["tenantId"], "tenant-1");
261        assert_eq!(metadata["protocolActor"]["actorId"], "merchant-agent");
262        assert_eq!(metadata["sessionIdentity"], true);
263    }
264}