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 const DEFAULT_ANALYTICS_QUEUE: usize = 10_000;
enum Message {
Event(Box<AuthEvent>),
Flush(tokio::sync::oneshot::Sender<()>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("the analytics worker is not running")]
pub struct AnalyticsClosed;
pub struct AuditLogWithAnalytics<L, S, E> {
inner: L,
sink: std::sync::Arc<S>,
queue: tokio::sync::mpsc::Sender<Message>,
dropped: std::sync::atomic::AtomicU64,
enricher: std::marker::PhantomData<fn() -> 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, AnalyticsWorker<S, E>) {
Self::with_capacity(inner, sink, enricher, DEFAULT_ANALYTICS_QUEUE)
}
pub fn with_capacity(
inner: L,
sink: S,
enricher: E,
capacity: usize,
) -> (Self, AnalyticsWorker<S, E>) {
let (queue, rx) = tokio::sync::mpsc::channel(capacity.max(1));
let sink = std::sync::Arc::new(sink);
let worker = AnalyticsWorker {
rx,
sink: std::sync::Arc::clone(&sink),
enricher: std::sync::Arc::new(enricher),
};
let log = Self {
inner,
sink,
queue,
dropped: std::sync::atomic::AtomicU64::new(0),
enricher: std::marker::PhantomData,
};
(log, worker)
}
pub fn inner(&self) -> &L {
&self.inner
}
pub fn sink(&self) -> &std::sync::Arc<S> {
&self.sink
}
pub fn dropped_events(&self) -> u64 {
self.dropped.load(std::sync::atomic::Ordering::Relaxed)
}
pub async fn flush(&self) -> Result<(), AnalyticsClosed> {
let (ack, done) = tokio::sync::oneshot::channel();
self.queue
.send(Message::Flush(ack))
.await
.map_err(|_| AnalyticsClosed)?;
done.await.map_err(|_| AnalyticsClosed)
}
pub async fn shutdown(self) {
let _ = self.flush().await;
}
fn enqueue(&self, event: AuthEvent) {
use std::sync::atomic::Ordering;
if self
.queue
.try_send(Message::Event(Box::new(event)))
.is_err()
{
let total = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
if total.is_power_of_two() {
tracing::warn!(
target: "axess::audit::analytics",
sink = %self.sink.name(),
dropped_total = total,
"analytics queue full or worker stopped; dropping analytics event \
(the regulatory record is unaffected)",
);
}
}
}
}
pub struct AnalyticsWorker<S, E> {
rx: tokio::sync::mpsc::Receiver<Message>,
sink: std::sync::Arc<S>,
enricher: std::sync::Arc<E>,
}
impl<S, E, EFut> AnalyticsWorker<S, E>
where
S: AuthnAnalyticsSink,
E: Fn(AuthEvent) -> EFut + Send + Sync + 'static,
EFut: std::future::Future<Output = RichAuthnEvent> + Send + 'static,
{
pub async fn run(mut self) {
while let Some(message) = self.rx.recv().await {
match message {
Message::Flush(ack) => {
let _ = ack.send(());
}
Message::Event(event) => {
let sink = std::sync::Arc::clone(&self.sink);
let enricher = std::sync::Arc::clone(&self.enricher);
let one = tokio::spawn(async move {
let rich = enricher(*event).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",
);
}
});
if let Err(e) = one.await {
tracing::error!(
target: "axess::audit::analytics",
error = %e,
"analytics enricher or sink panicked; event lost, worker continues",
);
}
}
}
}
}
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(self.run())
}
}
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 get_user_tenant(
&self,
user_id: &crate::authn::ids::UserId,
) -> impl std::future::Future<Output = Result<Option<crate::authn::ids::TenantId>, Self::Error>> + Send
{
self.inner.get_user_tenant(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,
{
async fn record_event(
&self,
event: AuthEvent,
) -> Result<crate::authn::store::AuditOutcome, <L as crate::authn::store::IdentityLookup>::Error>
{
let for_analytics = event.clone();
let outcome = self.inner.record_event(event).await?;
if outcome == crate::authn::store::AuditOutcome::Recorded {
self.enqueue(for_analytics);
}
Ok(outcome)
}
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;