use crate::authn::event::AuthEvent;
#[cfg(feature = "device")]
use crate::device::types::DeviceTrustLevel;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct UserAgentSummary {
pub browser_family: Option<String>,
pub browser_version: Option<String>,
pub os_family: Option<String>,
pub os_version: Option<String>,
pub is_bot: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct RichAuthnEvent {
pub event: AuthEvent,
#[cfg(feature = "device")]
pub device_trust_level: Option<DeviceTrustLevel>,
pub geo_country: Option<String>,
pub geo_asn: Option<u32>,
pub user_agent_summary: Option<UserAgentSummary>,
pub tags: Vec<(String, String)>,
}
impl RichAuthnEvent {
pub fn from_event(event: AuthEvent) -> Self {
Self {
event,
#[cfg(feature = "device")]
device_trust_level: None,
geo_country: None,
geo_asn: None,
user_agent_summary: None,
tags: Vec::new(),
}
}
#[cfg(feature = "device")]
pub fn with_device_trust_level(mut self, level: DeviceTrustLevel) -> Self {
self.device_trust_level = Some(level);
self
}
pub fn with_geo_country(mut self, country: impl Into<String>) -> Self {
self.geo_country = Some(country.into());
self
}
pub fn with_geo_asn(mut self, asn: u32) -> Self {
self.geo_asn = Some(asn);
self
}
pub fn with_user_agent_summary(mut self, ua: UserAgentSummary) -> Self {
self.user_agent_summary = Some(ua);
self
}
pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.tags.push((key.into(), value.into()));
self
}
}
pub trait AuthnAnalyticsSink: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn record_rich(
&self,
event: RichAuthnEvent,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn name(&self) -> &'static str;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopAuthnAnalyticsSink;
impl AuthnAnalyticsSink for NoopAuthnAnalyticsSink {
type Error = std::convert::Infallible;
async fn record_rich(&self, event: RichAuthnEvent) -> Result<(), Self::Error> {
tracing::trace!(
target: "axess::audit::analytics",
event_type = ?event.event.event_type,
event_time = event.event.event_time,
"NoopAuthnAnalyticsSink: rich event discarded",
);
Ok(())
}
fn name(&self) -> &'static str {
"noop"
}
}
pub struct AuditLogWithAnalytics<L, S, E> {
inner: L,
sink: std::sync::Arc<S>,
enricher: std::sync::Arc<E>,
}
impl<L, S, E> AuditLogWithAnalytics<L, S, E>
where
L: crate::authn::store::IdentityAuthnLog,
S: AuthnAnalyticsSink,
E: Send + Sync + 'static,
{
pub fn new(inner: L, sink: S, enricher: E) -> Self {
Self {
inner,
sink: std::sync::Arc::new(sink),
enricher: std::sync::Arc::new(enricher),
}
}
pub fn inner(&self) -> &L {
&self.inner
}
pub fn sink(&self) -> &std::sync::Arc<S> {
&self.sink
}
}
impl<L, S, E, EFut> crate::authn::store::IdentityLookup for AuditLogWithAnalytics<L, S, E>
where
L: crate::authn::store::IdentityAuthnLog,
S: AuthnAnalyticsSink,
E: Fn(AuthEvent) -> EFut + Send + Sync + 'static,
EFut: std::future::Future<Output = RichAuthnEvent> + Send + 'static,
{
type Error = <L as crate::authn::store::IdentityLookup>::Error;
fn find_user(
&self,
identifier: &str,
tenant_id: &crate::authn::ids::TenantId,
) -> impl std::future::Future<Output = Result<Option<crate::authn::types::User>, Self::Error>> + Send
{
self.inner.find_user(identifier, tenant_id)
}
fn get_user(
&self,
user_id: &crate::authn::ids::UserId,
) -> impl std::future::Future<Output = Result<Option<crate::authn::types::User>, Self::Error>> + Send
{
self.inner.get_user(user_id)
}
fn find_tenant(
&self,
identifier: &str,
) -> impl std::future::Future<Output = Result<Option<crate::authn::types::Tenant>, Self::Error>> + Send
{
self.inner.find_tenant(identifier)
}
fn default_tenant(
&self,
) -> impl std::future::Future<Output = Result<crate::authn::types::Tenant, Self::Error>> + Send
{
self.inner.default_tenant()
}
fn account_status(
&self,
user_id: &crate::authn::ids::UserId,
) -> impl std::future::Future<Output = Result<crate::authn::types::EntityState, Self::Error>> + Send
{
self.inner.account_status(user_id)
}
fn lockout_policy_for_tenant(
&self,
tenant_id: &crate::authn::ids::TenantId,
) -> crate::authn::types::LockoutPolicy {
self.inner.lockout_policy_for_tenant(tenant_id)
}
}
impl<L, S, E, EFut> crate::authn::store::IdentityAuthnLog for AuditLogWithAnalytics<L, S, E>
where
L: crate::authn::store::IdentityAuthnLog,
S: AuthnAnalyticsSink,
E: Fn(AuthEvent) -> EFut + Send + Sync + 'static,
EFut: std::future::Future<Output = RichAuthnEvent> + Send + 'static,
{
fn record_event(
&self,
event: AuthEvent,
) -> impl std::future::Future<
Output = Result<(), <L as crate::authn::store::IdentityLookup>::Error>,
> + Send {
let event_for_analytics = event.clone();
let sink = std::sync::Arc::clone(&self.sink);
let enricher = std::sync::Arc::clone(&self.enricher);
tokio::spawn(async move {
let rich = enricher(event_for_analytics).await;
let sink_name = sink.name();
if let Err(e) = sink.record_rich(rich).await {
tracing::warn!(
sink = %sink_name,
error = %e,
"authn analytics sink rejected event; regulatory record is unaffected",
);
}
});
self.inner.record_event(event)
}
fn record_failed_attempt(
&self,
user_id: &crate::authn::ids::UserId,
) -> impl std::future::Future<
Output = Result<u32, <L as crate::authn::store::IdentityLookup>::Error>,
> + Send {
self.inner.record_failed_attempt(user_id)
}
fn reset_failed_attempts(
&self,
user_id: &crate::authn::ids::UserId,
) -> impl std::future::Future<
Output = Result<(), <L as crate::authn::store::IdentityLookup>::Error>,
> + Send {
self.inner.reset_failed_attempts(user_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_sink_accepts_any_event() {
let sink = NoopAuthnAnalyticsSink;
let event = RichAuthnEvent::from_event(make_test_event());
assert!(sink.record_rich(event).await.is_ok());
assert_eq!(sink.name(), "noop");
}
#[test]
fn rich_event_builder_chain_populates_optionals() {
let event = make_test_event();
let rich = RichAuthnEvent::from_event(event)
.with_geo_country("CH")
.with_geo_asn(13335)
.with_tag("channel", "mobile");
assert_eq!(rich.geo_country.as_deref(), Some("CH"));
assert_eq!(rich.geo_asn, Some(13335));
assert_eq!(rich.tags.len(), 1);
assert_eq!(rich.tags[0], ("channel".to_string(), "mobile".to_string()));
}
fn make_test_event() -> AuthEvent {
use crate::authn::event::{AuthEventBuilder, AuthEventStatus, AuthEventType};
AuthEventBuilder::new(
None,
None,
AuthEventType::LoginAttempt,
AuthEventStatus::Failure,
)
.build()
}
}