Skip to main content

gatekeep_sqlx/audit/
attempt.rs

1use dovecote::{EventData, EventId, EventType, NewEvent, PagedEvent, TenantId};
2use gatekeep::AuthorizationAttempt;
3use thiserror::Error;
4
5use super::{DECISION_AUDIT_CONTENT_TYPE, DecisionAuditConfig};
6
7/// Event type distinguishing failed attempts from completed policy decisions.
8pub const ATTEMPT_AUDIT_EVENT_TYPE: &str = "gatekeep.authorization_attempt_failed";
9
10/// Invalid attempt event or durable payload.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum AttemptAuditEventError {
14    /// Encoded evidence exceeds the supported one MiB payload bound.
15    #[error("authorization attempt payload exceeds size limit")]
16    PayloadTooLarge,
17    /// Invalid Gatekeep attempt record.
18    #[error(transparent)]
19    Attempt(#[from] gatekeep::AttemptValidationError),
20    /// Invalid event attributes.
21    #[error(transparent)]
22    Event(#[from] dovecote::ValidationError),
23    /// Invalid JSON payload.
24    #[error(transparent)]
25    Json(#[from] serde_json::Error),
26    /// Stored attributes or tenant disagree with the record.
27    #[error("authorization attempt event does not match its payload")]
28    Shape,
29}
30
31pub(super) fn event_from_attempt(
32    config: &DecisionAuditConfig,
33    entry: &AuthorizationAttempt,
34) -> Result<(TenantId, NewEvent), AttemptAuditEventError> {
35    entry.validate()?;
36    let payload = serde_json::to_vec(entry)?;
37    if payload.len() > super::MAX_AUDIT_PAYLOAD_BYTES {
38        return Err(AttemptAuditEventError::PayloadTooLarge);
39    }
40
41    let event = NewEvent::builder(
42        config.stream().clone(),
43        EventId::new(format!(
44            "gatekeep-attempt-{}",
45            entry.occurrence().decision_audit_id()
46        ))?,
47        config.source().clone(),
48        EventType::new(ATTEMPT_AUDIT_EVENT_TYPE)?,
49    )
50    .time(entry.occurrence().occurred_at())
51    .datacontenttype(dovecote::ContentType::new(DECISION_AUDIT_CONTENT_TYPE)?)
52    .data(EventData::json(payload)?)
53    .build()?;
54    Ok((TenantId::new(entry.tenant().as_str())?, event))
55}
56
57/// Decodes a bounded attempt event and verifies scope, identity, time and type.
58///
59/// # Errors
60/// Rejects malformed, mismatched or oversized records. Decision events need
61/// [`super::decode_decision_audit`], never this decoder.
62pub fn decode_authorization_attempt(
63    config: &DecisionAuditConfig,
64    paged: &PagedEvent,
65) -> Result<AuthorizationAttempt, AttemptAuditEventError> {
66    let event = paged.event();
67    if event.stream() != config.stream()
68        || event.source() != config.source()
69        || event.event_type().as_str() != ATTEMPT_AUDIT_EVENT_TYPE
70        || event.datacontenttype().map(dovecote::ContentType::as_str)
71            != Some(DECISION_AUDIT_CONTENT_TYPE)
72    {
73        return Err(AttemptAuditEventError::Shape);
74    }
75
76    let Some(EventData::Json(payload)) = event.data() else {
77        return Err(AttemptAuditEventError::Shape);
78    };
79
80    if payload.as_bytes().len() > super::MAX_AUDIT_PAYLOAD_BYTES {
81        return Err(AttemptAuditEventError::Shape);
82    }
83
84    let entry: AuthorizationAttempt = serde_json::from_slice(payload.as_bytes())?;
85    if entry.tenant().as_str() != paged.tenant_id().as_str()
86        || event.time() != Some(entry.occurrence().occurred_at())
87        || event.id().as_str()
88            != format!(
89                "gatekeep-attempt-{}",
90                entry.occurrence().decision_audit_id()
91            )
92    {
93        return Err(AttemptAuditEventError::Shape);
94    }
95
96    entry.validate()?;
97    Ok(entry)
98}