mod attempt;
pub use attempt::{ATTEMPT_AUDIT_EVENT_TYPE, AttemptAuditEventError, decode_authorization_attempt};
use gatekeep::{AuditEntry, AuditEntryError, DecisionAuditId, GatekeepError, LegacyAuditEntry};
use serde_json::Error as JsonError;
use thiserror::Error;
use url::Url;
use dovecote::{ContentType, EventData, EventId, EventSource, EventType, NewEvent, StreamName};
#[cfg(feature = "mysql")]
mod mysql;
#[cfg(feature = "postgres")]
mod postgres;
#[cfg(feature = "sqlite")]
mod sqlite;
#[cfg(feature = "mysql")]
pub use self::mysql::{MySqlDovecoteAudit, MySqlDovecoteAuditError};
#[cfg(feature = "postgres")]
pub use self::postgres::{PgDovecoteAudit, PgDovecoteAuditError};
#[cfg(feature = "sqlite")]
pub use self::sqlite::{SqliteDovecoteAudit, SqliteDovecoteAuditError};
pub const DEFAULT_AUDIT_STREAM: &str = "gatekeep-audit";
pub const DECISION_AUDIT_EVENT_TYPE: &str = "gatekeep.decision_audit_recorded";
pub const DECISION_AUDIT_CONTENT_TYPE: &str = "application/json";
const MAX_AUDIT_PAYLOAD_BYTES: usize = 1_048_576;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DecisionAuditConfig {
source: EventSource,
stream: StreamName,
event_type: EventType,
content_type: ContentType,
}
impl DecisionAuditConfig {
pub fn new(source: impl Into<String>) -> Result<Self, DecisionAuditConfigError> {
let source = source.into();
let parsed = Url::parse(&source).map_err(|_| DecisionAuditConfigError::Source {
value: source.clone(),
})?;
if parsed.scheme().is_empty() {
return Err(DecisionAuditConfigError::Source { value: source });
}
Ok(Self {
source: EventSource::new(source)
.map_err(|source| DecisionAuditConfigError::SourceValue { source })?,
stream: StreamName::new(DEFAULT_AUDIT_STREAM)
.map_err(|source| DecisionAuditConfigError::Stream { source })?,
event_type: EventType::new(DECISION_AUDIT_EVENT_TYPE)
.map_err(|source| DecisionAuditConfigError::EventType { source })?,
content_type: ContentType::new(DECISION_AUDIT_CONTENT_TYPE)
.map_err(|source| DecisionAuditConfigError::ContentType { source })?,
})
}
#[must_use]
pub const fn source(&self) -> &EventSource {
&self.source
}
#[must_use]
pub const fn stream(&self) -> &StreamName {
&self.stream
}
#[must_use]
pub const fn event_type(&self) -> &EventType {
&self.event_type
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DecisionAuditConfigError {
#[error("decision audit source must be an absolute URI: {value}")]
Source {
value: String,
},
#[error("invalid decision audit source: {source}")]
SourceValue {
#[source]
source: dovecote::ValidationError,
},
#[error("invalid decision audit stream: {source}")]
Stream {
#[source]
source: dovecote::ValidationError,
},
#[error("invalid decision audit event type: {source}")]
EventType {
#[source]
source: dovecote::ValidationError,
},
#[error("invalid decision audit content type: {source}")]
ContentType {
#[source]
source: dovecote::ValidationError,
},
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DecisionAuditEventError {
#[error("decision audit payload exceeds size limit")]
PayloadTooLarge,
#[error(transparent)]
Entry(#[from] AuditEntryError),
#[error("serialize decision audit entry")]
Json(#[source] JsonError),
#[error("validate decision audit identity")]
Identity(#[source] GatekeepError),
#[error("validate decision audit event")]
Validation(#[source] dovecote::ValidationError),
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DecisionAuditDecodeError {
#[error(transparent)]
Entry(#[from] AuditEntryError),
#[error("unexpected Gatekeep decision audit event {field}")]
UnexpectedShape {
field: &'static str,
},
#[error("decision audit event has no JSON payload")]
MissingPayload,
#[error("decode decision audit payload")]
Json(#[source] JsonError),
#[error("decode decision audit identity")]
Identity(#[source] GatekeepError),
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LegacyDecisionAuditDecodeError {
#[error("unexpected Gatekeep legacy decision audit event {field}")]
UnexpectedShape {
field: &'static str,
},
#[error("legacy decision audit event has no JSON payload")]
MissingPayload,
#[error("decode legacy decision audit payload")]
Json(#[source] JsonError),
#[error("legacy decision audit identity does not match the event")]
Identity,
#[error("legacy decision audit tenant does not match the storage row")]
Tenant,
}
pub fn decode_decision_audit(
config: &DecisionAuditConfig,
paged: &dovecote::PagedEvent,
) -> Result<AuditEntry, DecisionAuditDecodeError> {
let entry = decode_event(config, paged.event())?;
if paged.tenant_id().as_str() != entry.tenant().as_str() {
return Err(DecisionAuditDecodeError::UnexpectedShape { field: "tenant" });
}
entry.validate_current()?;
Ok(entry)
}
pub fn decode_legacy_decision_audit(
config: &DecisionAuditConfig,
paged: &dovecote::PagedEvent,
) -> Result<LegacyAuditEntry, LegacyDecisionAuditDecodeError> {
let event = paged.event();
if event.stream() != config.stream()
|| event.source() != config.source()
|| event.event_type() != config.event_type()
|| event.datacontenttype().map(dovecote::ContentType::as_str)
!= Some(DECISION_AUDIT_CONTENT_TYPE)
{
return Err(LegacyDecisionAuditDecodeError::UnexpectedShape {
field: "attributes",
});
}
let Some(dovecote::EventData::Json(payload)) = event.data() else {
return Err(LegacyDecisionAuditDecodeError::MissingPayload);
};
let entry: LegacyAuditEntry =
serde_json::from_slice(payload.as_bytes()).map_err(LegacyDecisionAuditDecodeError::Json)?;
if event.id().as_str() != format!("gatekeep-audit-{}", entry.decision_audit_id)
|| event.time() != Some(entry.occurred_at)
{
return Err(LegacyDecisionAuditDecodeError::Identity);
}
if paged.tenant_id().as_str() != entry.tenant.as_str() {
return Err(LegacyDecisionAuditDecodeError::Tenant);
}
Ok(entry)
}
fn decode_event(
config: &DecisionAuditConfig,
event: &dovecote::StoredEvent,
) -> Result<AuditEntry, DecisionAuditDecodeError> {
if event.stream() != config.stream()
|| event.source() != config.source()
|| event.event_type() != config.event_type()
|| event.datacontenttype().map(dovecote::ContentType::as_str)
!= Some(DECISION_AUDIT_CONTENT_TYPE)
{
return Err(DecisionAuditDecodeError::UnexpectedShape {
field: "attributes",
});
}
let Some(dovecote::EventData::Json(payload)) = event.data() else {
return Err(DecisionAuditDecodeError::MissingPayload);
};
let entry = decode_payload(payload.as_bytes())?;
let expected_id = format!("gatekeep-audit-{}", entry.decision_audit_id().as_str());
if event.id().as_str() != expected_id || event.time() != Some(entry.occurred_at()) {
return Err(DecisionAuditDecodeError::UnexpectedShape {
field: "identity or occurrence time",
});
}
DecisionAuditId::new(entry.decision_audit_id().as_str().to_owned())
.map_err(DecisionAuditDecodeError::Identity)?;
Ok(entry)
}
fn decode_payload(payload: &[u8]) -> Result<AuditEntry, DecisionAuditDecodeError> {
if payload.len() > MAX_AUDIT_PAYLOAD_BYTES {
return Err(DecisionAuditDecodeError::UnexpectedShape {
field: "payload size",
});
}
serde_json::from_slice(payload).map_err(DecisionAuditDecodeError::Json)
}
fn event_from_entry(
config: &DecisionAuditConfig,
entry: &AuditEntry,
) -> Result<(dovecote::TenantId, NewEvent), DecisionAuditEventError> {
entry.validate_current()?;
DecisionAuditId::new(entry.decision_audit_id().as_str().to_owned())
.map_err(DecisionAuditEventError::Identity)?;
let event_id = EventId::new(format!(
"gatekeep-audit-{}",
entry.decision_audit_id().as_str()
))
.map_err(DecisionAuditEventError::Validation)?;
let payload = serde_json::to_vec(entry).map_err(DecisionAuditEventError::Json)?;
if payload.len() > MAX_AUDIT_PAYLOAD_BYTES {
return Err(DecisionAuditEventError::PayloadTooLarge);
}
let event = NewEvent::builder(
config.stream.clone(),
event_id,
config.source.clone(),
config.event_type.clone(),
)
.time(entry.occurred_at())
.datacontenttype(config.content_type.clone())
.data(EventData::json(payload).map_err(DecisionAuditEventError::Validation)?)
.build()
.map_err(DecisionAuditEventError::Validation)?;
let tenant = dovecote::TenantId::new(entry.tenant().as_str().to_owned())
.map_err(DecisionAuditEventError::Validation)?;
Ok((tenant, event))
}