1mod 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
33pub const DEFAULT_AUDIT_STREAM: &str = "gatekeep-audit";
35pub const DECISION_AUDIT_EVENT_TYPE: &str = "gatekeep.decision_audit_recorded";
37pub const DECISION_AUDIT_CONTENT_TYPE: &str = "application/json";
39const MAX_AUDIT_PAYLOAD_BYTES: usize = 1_048_576;
40
41#[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 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 #[must_use]
84 pub const fn source(&self) -> &EventSource {
85 &self.source
86 }
87
88 #[must_use]
90 pub const fn stream(&self) -> &StreamName {
91 &self.stream
92 }
93
94 #[must_use]
96 pub const fn event_type(&self) -> &EventType {
97 &self.event_type
98 }
99}
100
101#[derive(Debug, Error)]
103#[non_exhaustive]
104pub enum DecisionAuditConfigError {
105 #[error("decision audit source must be an absolute URI: {value}")]
107 Source {
108 value: String,
110 },
111 #[error("invalid decision audit source: {source}")]
113 SourceValue {
114 #[source]
116 source: dovecote::ValidationError,
117 },
118 #[error("invalid decision audit stream: {source}")]
120 Stream {
121 #[source]
123 source: dovecote::ValidationError,
124 },
125 #[error("invalid decision audit event type: {source}")]
127 EventType {
128 #[source]
130 source: dovecote::ValidationError,
131 },
132 #[error("invalid decision audit content type: {source}")]
134 ContentType {
135 #[source]
137 source: dovecote::ValidationError,
138 },
139}
140
141#[derive(Debug, Error)]
144#[non_exhaustive]
145pub enum DecisionAuditEventError {
146 #[error("decision audit payload exceeds size limit")]
148 PayloadTooLarge,
149 #[error(transparent)]
151 Entry(#[from] AuditEntryError),
152 #[error("serialize decision audit entry")]
154 Json(#[source] JsonError),
155 #[error("validate decision audit identity")]
157 Identity(#[source] GatekeepError),
158 #[error("validate decision audit event")]
161 Validation(#[source] dovecote::ValidationError),
162}
163
164#[derive(Debug, Error)]
167#[non_exhaustive]
168pub enum DecisionAuditDecodeError {
169 #[error(transparent)]
172 Entry(#[from] AuditEntryError),
173 #[error("unexpected Gatekeep decision audit event {field}")]
175 UnexpectedShape {
176 field: &'static str,
178 },
179 #[error("decision audit event has no JSON payload")]
181 MissingPayload,
182 #[error("decode decision audit payload")]
184 Json(#[source] JsonError),
185 #[error("decode decision audit identity")]
187 Identity(#[source] GatekeepError),
188}
189
190#[derive(Debug, Error)]
192#[non_exhaustive]
193pub enum LegacyDecisionAuditDecodeError {
194 #[error("unexpected Gatekeep legacy decision audit event {field}")]
196 UnexpectedShape {
197 field: &'static str,
199 },
200 #[error("legacy decision audit event has no JSON payload")]
202 MissingPayload,
203 #[error("decode legacy decision audit payload")]
205 Json(#[source] JsonError),
206 #[error("legacy decision audit identity does not match the event")]
208 Identity,
209 #[error("legacy decision audit tenant does not match the storage row")]
211 Tenant,
212}
213
214pub 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 entry.validate_current()?;
237 Ok(entry)
238}
239
240pub 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 DecisionAuditId::new(entry.decision_audit_id().as_str().to_owned())
315 .map_err(DecisionAuditDecodeError::Identity)?;
316 Ok(entry)
317}
318
319fn 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 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}