Skip to main content

gatekeep_sqlx/audit/
mod.rs

1//! Dovecote-backed decision audit event construction.
2//!
3//! Gatekeep owns the typed decision value; Dovecote owns durable SQL event and
4//! delivery state. This module only validates the application source and
5//! serializes one complete [`gatekeep::AuditEntry`] into Dovecote's event
6//! shape. It deliberately contains no Gatekeep-owned audit tables or paging
7//! model.
8
9mod attempt;
10pub use attempt::{ATTEMPT_AUDIT_EVENT_TYPE, AttemptAuditEventError, decode_authorization_attempt};
11
12use gatekeep::{AuditEntry, AuditEntryError, DecisionAuditId, GatekeepError, LegacyAuditEntry};
13use serde_json::Error as JsonError;
14use thiserror::Error;
15use url::Url;
16
17use dovecote::{ContentType, EventData, EventId, EventSource, EventType, NewEvent, StreamName};
18
19#[cfg(feature = "mysql")]
20mod mysql;
21#[cfg(feature = "postgres")]
22mod postgres;
23#[cfg(feature = "sqlite")]
24mod sqlite;
25
26#[cfg(feature = "mysql")]
27pub use self::mysql::{MySqlDovecoteAudit, MySqlDovecoteAuditError};
28#[cfg(feature = "postgres")]
29pub use self::postgres::{PgDovecoteAudit, PgDovecoteAuditError};
30#[cfg(feature = "sqlite")]
31pub use self::sqlite::{SqliteDovecoteAudit, SqliteDovecoteAuditError};
32
33/// The default Dovecote stream for Gatekeep decision audit events.
34pub const DEFAULT_AUDIT_STREAM: &str = "gatekeep-audit";
35/// The durable event type for Gatekeep decision audit events.
36pub const DECISION_AUDIT_EVENT_TYPE: &str = "gatekeep.decision_audit_recorded";
37/// The explicit JSON content type used for every decision audit event.
38pub const DECISION_AUDIT_CONTENT_TYPE: &str = "application/json";
39const MAX_AUDIT_PAYLOAD_BYTES: usize = 1_048_576;
40
41/// Configuration required to write Gatekeep audit events.
42///
43/// The source is application-owned and must be an absolute URI. Gatekeep does
44/// not invent an identity for the application or use a process-local default.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct DecisionAuditConfig {
47    source: EventSource,
48    stream: StreamName,
49    event_type: EventType,
50    content_type: ContentType,
51}
52
53impl DecisionAuditConfig {
54    /// Creates configuration using Gatekeep's stable stream, event type, and
55    /// JSON content type defaults.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`DecisionAuditConfigError::Source`] when `source` is not an
60    /// absolute URI accepted by `CloudEvents`.
61    pub fn new(source: impl Into<String>) -> Result<Self, DecisionAuditConfigError> {
62        let source = source.into();
63        let parsed = Url::parse(&source).map_err(|_| DecisionAuditConfigError::Source {
64            value: source.clone(),
65        })?;
66        if parsed.scheme().is_empty() {
67            return Err(DecisionAuditConfigError::Source { value: source });
68        }
69
70        Ok(Self {
71            source: EventSource::new(source)
72                .map_err(|source| DecisionAuditConfigError::SourceValue { source })?,
73            stream: StreamName::new(DEFAULT_AUDIT_STREAM)
74                .map_err(|source| DecisionAuditConfigError::Stream { source })?,
75            event_type: EventType::new(DECISION_AUDIT_EVENT_TYPE)
76                .map_err(|source| DecisionAuditConfigError::EventType { source })?,
77            content_type: ContentType::new(DECISION_AUDIT_CONTENT_TYPE)
78                .map_err(|source| DecisionAuditConfigError::ContentType { source })?,
79        })
80    }
81
82    /// Returns the configured absolute event source.
83    #[must_use]
84    pub const fn source(&self) -> &EventSource {
85        &self.source
86    }
87
88    /// Returns the fixed Gatekeep audit stream.
89    #[must_use]
90    pub const fn stream(&self) -> &StreamName {
91        &self.stream
92    }
93
94    /// Returns the fixed Gatekeep audit event type.
95    #[must_use]
96    pub const fn event_type(&self) -> &EventType {
97        &self.event_type
98    }
99}
100
101/// Errors while validating the application-owned audit source.
102#[derive(Debug, Error)]
103#[non_exhaustive]
104pub enum DecisionAuditConfigError {
105    /// The source was not an absolute URI.
106    #[error("decision audit source must be an absolute URI: {value}")]
107    Source {
108        /// Rejected source value.
109        value: String,
110    },
111    /// Dovecote rejected the source URI.
112    #[error("invalid decision audit source: {source}")]
113    SourceValue {
114        /// Validation failure from Dovecote.
115        #[source]
116        source: dovecote::ValidationError,
117    },
118    /// The built-in stream default was rejected by Dovecote.
119    #[error("invalid decision audit stream: {source}")]
120    Stream {
121        /// Validation failure from Dovecote.
122        #[source]
123        source: dovecote::ValidationError,
124    },
125    /// The built-in event type was rejected by Dovecote.
126    #[error("invalid decision audit event type: {source}")]
127    EventType {
128        /// Validation failure from Dovecote.
129        #[source]
130        source: dovecote::ValidationError,
131    },
132    /// The built-in content type was rejected by Dovecote.
133    #[error("invalid decision audit content type: {source}")]
134    ContentType {
135        /// Validation failure from Dovecote.
136        #[source]
137        source: dovecote::ValidationError,
138    },
139}
140
141/// Errors while converting a typed Gatekeep entry into a validated Dovecote
142/// event.
143#[derive(Debug, Error)]
144#[non_exhaustive]
145pub enum DecisionAuditEventError {
146    /// Encoded evidence exceeds the supported one MiB payload bound.
147    #[error("decision audit payload exceeds size limit")]
148    PayloadTooLarge,
149    /// The entry is not a valid current decision record.
150    #[error(transparent)]
151    Entry(#[from] AuditEntryError),
152    /// The typed entry could not be serialized as JSON.
153    #[error("serialize decision audit entry")]
154    Json(#[source] JsonError),
155    /// The entry uses the migration-only legacy identity namespace.
156    #[error("validate decision audit identity")]
157    Identity(#[source] GatekeepError),
158    /// The generated event identity or event content failed Dovecote
159    /// validation.
160    #[error("validate decision audit event")]
161    Validation(#[source] dovecote::ValidationError),
162}
163
164/// Errors returned while decoding a Dovecote event as a Gatekeep decision
165/// audit entry.
166#[derive(Debug, Error)]
167#[non_exhaustive]
168pub enum DecisionAuditDecodeError {
169    /// The payload represented a current entry but failed its tenant-binding
170    /// invariants.
171    #[error(transparent)]
172    Entry(#[from] AuditEntryError),
173    /// A required Dovecote event attribute did not match Gatekeep's contract.
174    #[error("unexpected Gatekeep decision audit event {field}")]
175    UnexpectedShape {
176        /// Attribute that did not match.
177        field: &'static str,
178    },
179    /// The event did not carry JSON data.
180    #[error("decision audit event has no JSON payload")]
181    MissingPayload,
182    /// The event payload was not a valid typed entry.
183    #[error("decode decision audit payload")]
184    Json(#[source] JsonError),
185    /// The event identity suffix was not a valid typed decision identity.
186    #[error("decode decision audit identity")]
187    Identity(#[source] GatekeepError),
188}
189
190/// Errors returned while decoding a historical audit event explicitly.
191#[derive(Debug, Error)]
192#[non_exhaustive]
193pub enum LegacyDecisionAuditDecodeError {
194    /// A required Dovecote event attribute did not match Gatekeep's contract.
195    #[error("unexpected Gatekeep legacy decision audit event {field}")]
196    UnexpectedShape {
197        /// Attribute that did not match.
198        field: &'static str,
199    },
200    /// The event did not carry JSON data.
201    #[error("legacy decision audit event has no JSON payload")]
202    MissingPayload,
203    /// The event payload was not a valid historical typed entry.
204    #[error("decode legacy decision audit payload")]
205    Json(#[source] JsonError),
206    /// The event identity did not match the historical payload identity.
207    #[error("legacy decision audit identity does not match the event")]
208    Identity,
209    /// The storage tenant differs from the historical payload tenant.
210    #[error("legacy decision audit tenant does not match the storage row")]
211    Tenant,
212}
213
214/// Decodes a tenant-scoped Dovecote page item into a typed Gatekeep audit
215/// entry.
216///
217/// The storage tenant is checked against the payload tenant before the entry
218/// is returned. This check is essential for exports that combine pages from
219/// more than one tenant.
220///
221/// # Errors
222///
223/// Returns [`DecisionAuditDecodeError`] when the event shape, payload, or
224/// storage tenant does not match Gatekeep's audit contract.
225pub fn decode_decision_audit(
226    config: &DecisionAuditConfig,
227    paged: &dovecote::PagedEvent,
228) -> Result<AuditEntry, DecisionAuditDecodeError> {
229    let entry = decode_event(config, paged.event())?;
230    if paged.tenant_id().as_str() != entry.tenant().as_str() {
231        return Err(DecisionAuditDecodeError::UnexpectedShape { field: "tenant" });
232    }
233    // The current decoder never returns a pre-4.0 representation. Historical
234    // records must be migrated through an explicit, separately versioned
235    // importer that can prove their shape and provenance.
236    entry.validate_current()?;
237    Ok(entry)
238}
239
240/// Decodes a historical Gatekeep audit event without treating it as current.
241///
242/// The returned [`LegacyAuditEntry`] retains the pre-4.0 optional fields and
243/// must be explicitly mapped by migration code before it can reach an
244/// [`gatekeep::AuditSink`].
245///
246/// # Errors
247///
248/// Returns [`LegacyDecisionAuditDecodeError`] when event attributes, payload
249/// identity, or tenant routing do not match the historical contract.
250pub fn decode_legacy_decision_audit(
251    config: &DecisionAuditConfig,
252    paged: &dovecote::PagedEvent,
253) -> Result<LegacyAuditEntry, LegacyDecisionAuditDecodeError> {
254    let event = paged.event();
255    if event.stream() != config.stream()
256        || event.source() != config.source()
257        || event.event_type() != config.event_type()
258        || event.datacontenttype().map(dovecote::ContentType::as_str)
259            != Some(DECISION_AUDIT_CONTENT_TYPE)
260    {
261        return Err(LegacyDecisionAuditDecodeError::UnexpectedShape {
262            field: "attributes",
263        });
264    }
265
266    let Some(dovecote::EventData::Json(payload)) = event.data() else {
267        return Err(LegacyDecisionAuditDecodeError::MissingPayload);
268    };
269
270    let entry: LegacyAuditEntry =
271        serde_json::from_slice(payload.as_bytes()).map_err(LegacyDecisionAuditDecodeError::Json)?;
272    if event.id().as_str() != format!("gatekeep-audit-{}", entry.decision_audit_id)
273        || event.time() != Some(entry.occurred_at)
274    {
275        return Err(LegacyDecisionAuditDecodeError::Identity);
276    }
277
278    if paged.tenant_id().as_str() != entry.tenant.as_str() {
279        return Err(LegacyDecisionAuditDecodeError::Tenant);
280    }
281
282    Ok(entry)
283}
284
285fn decode_event(
286    config: &DecisionAuditConfig,
287    event: &dovecote::StoredEvent,
288) -> Result<AuditEntry, DecisionAuditDecodeError> {
289    if event.stream() != config.stream()
290        || event.source() != config.source()
291        || event.event_type() != config.event_type()
292        || event.datacontenttype().map(dovecote::ContentType::as_str)
293            != Some(DECISION_AUDIT_CONTENT_TYPE)
294    {
295        return Err(DecisionAuditDecodeError::UnexpectedShape {
296            field: "attributes",
297        });
298    }
299
300    let Some(dovecote::EventData::Json(payload)) = event.data() else {
301        return Err(DecisionAuditDecodeError::MissingPayload);
302    };
303
304    let entry = decode_payload(payload.as_bytes())?;
305    let expected_id = format!("gatekeep-audit-{}", entry.decision_audit_id().as_str());
306    if event.id().as_str() != expected_id || event.time() != Some(entry.occurred_at()) {
307        return Err(DecisionAuditDecodeError::UnexpectedShape {
308            field: "identity or occurrence time",
309        });
310    }
311    // Deserialization already validates this field. Reconstructing it here
312    // makes the identity boundary explicit and keeps this check future-proof
313    // if AuditEntry's representation ever becomes more permissive.
314    DecisionAuditId::new(entry.decision_audit_id().as_str().to_owned())
315        .map_err(DecisionAuditDecodeError::Identity)?;
316    Ok(entry)
317}
318
319/// Decodes payloads through the ordinary current-entry serde contract. Missing
320/// binding/evidence is rejected by [`decode_decision_audit`] rather than being
321/// silently treated as legacy history.
322fn decode_payload(payload: &[u8]) -> Result<AuditEntry, DecisionAuditDecodeError> {
323    if payload.len() > MAX_AUDIT_PAYLOAD_BYTES {
324        return Err(DecisionAuditDecodeError::UnexpectedShape {
325            field: "payload size",
326        });
327    }
328    serde_json::from_slice(payload).map_err(DecisionAuditDecodeError::Json)
329}
330
331fn event_from_entry(
332    config: &DecisionAuditConfig,
333    entry: &AuditEntry,
334) -> Result<(dovecote::TenantId, NewEvent), DecisionAuditEventError> {
335    entry.validate_current()?;
336    // Ordinary enqueue must never create an event in the migration namespace;
337    // historical records require an explicit versioned importer.
338    DecisionAuditId::new(entry.decision_audit_id().as_str().to_owned())
339        .map_err(DecisionAuditEventError::Identity)?;
340    let event_id = EventId::new(format!(
341        "gatekeep-audit-{}",
342        entry.decision_audit_id().as_str()
343    ))
344    .map_err(DecisionAuditEventError::Validation)?;
345    let payload = serde_json::to_vec(entry).map_err(DecisionAuditEventError::Json)?;
346    if payload.len() > MAX_AUDIT_PAYLOAD_BYTES {
347        return Err(DecisionAuditEventError::PayloadTooLarge);
348    }
349
350    let event = NewEvent::builder(
351        config.stream.clone(),
352        event_id,
353        config.source.clone(),
354        config.event_type.clone(),
355    )
356    .time(entry.occurred_at())
357    .datacontenttype(config.content_type.clone())
358    .data(EventData::json(payload).map_err(DecisionAuditEventError::Validation)?)
359    .build()
360    .map_err(DecisionAuditEventError::Validation)?;
361    let tenant = dovecote::TenantId::new(entry.tenant().as_str().to_owned())
362        .map_err(DecisionAuditEventError::Validation)?;
363    Ok((tenant, event))
364}