use crate::authn::event::AuthEvent;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ArchiveBatchId(String);
impl ArchiveBatchId {
pub fn from_parts(first: &str, last: &str, count: usize) -> Self {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for part in [
first.as_bytes(),
last.as_bytes(),
count.to_string().as_bytes(),
] {
h.update((part.len() as u64).to_be_bytes());
h.update(part);
}
let mut hex = String::with_capacity(64);
for b in h.finalize() {
use std::fmt::Write as _;
let _ = write!(hex, "{b:02x}");
}
Self(hex)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ArchiveBatchId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub trait AuditArchiver: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
fn archive_batch(
&self,
batch: &ArchiveBatchId,
events: &[AuthEvent],
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn name(&self) -> &'static str;
}
#[derive(Debug, Clone)]
pub struct AuditRetentionPolicy {
pub archive_after: Duration,
pub purge_hot_after_archive: Duration,
}
impl Default for AuditRetentionPolicy {
fn default() -> Self {
Self {
archive_after: Duration::from_secs(90 * 24 * 60 * 60),
purge_hot_after_archive: Duration::from_secs(7 * 24 * 60 * 60),
}
}
}
impl AuditRetentionPolicy {
pub fn with_archive_after(mut self, d: Duration) -> Self {
self.archive_after = d;
self
}
pub fn with_purge_hot_after_archive(mut self, d: Duration) -> Self {
self.purge_hot_after_archive = d;
self
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopAuditArchiver;
impl AuditArchiver for NoopAuditArchiver {
type Error = std::convert::Infallible;
async fn archive_batch(
&self,
_batch: &ArchiveBatchId,
events: &[AuthEvent],
) -> Result<(), Self::Error> {
tracing::trace!(
target: "axess::audit::archive",
count = events.len(),
"NoopAuditArchiver: batch acknowledged, no cold-storage copy made",
);
Ok(())
}
fn name(&self) -> &'static str {
"noop"
}
}
fn cutoff_before(
now: chrono::DateTime<chrono::Utc>,
age: Duration,
) -> chrono::DateTime<chrono::Utc> {
chrono::Duration::from_std(age)
.ok()
.and_then(|d| now.checked_sub_signed(d))
.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
}
pub trait AuditRetentionSource: Send + Sync + 'static {
type EventId: Clone + std::fmt::Display + Send + Sync + 'static;
type Error: std::error::Error + Send + Sync + 'static;
fn select_unarchived_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
limit: usize,
) -> impl std::future::Future<Output = Result<Vec<(Self::EventId, AuthEvent)>, Self::Error>> + Send;
fn mark_archived(
&self,
ids: &[Self::EventId],
at: chrono::DateTime<chrono::Utc>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
fn purge_hot_archived_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> impl std::future::Future<Output = Result<u64, Self::Error>> + Send;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RetentionTickReport {
pub archived: u64,
pub purged: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum RetentionError<S, A>
where
S: std::error::Error + Send + Sync + 'static,
A: std::error::Error + Send + Sync + 'static,
{
#[error("retention source: {0}")]
Source(#[source] S),
#[error("archiver: {0}")]
Archiver(#[source] A),
}
pub struct AuditRetentionLoop<S, A>
where
S: AuditRetentionSource,
A: AuditArchiver,
{
source: std::sync::Arc<S>,
archiver: std::sync::Arc<A>,
policy: AuditRetentionPolicy,
tick_interval: Duration,
batch_size: usize,
clock: std::sync::Arc<dyn axess_clock::Clock>,
}
impl<S, A> AuditRetentionLoop<S, A>
where
S: AuditRetentionSource,
A: AuditArchiver,
{
pub fn new(source: S, archiver: A, policy: AuditRetentionPolicy) -> Self {
Self {
source: std::sync::Arc::new(source),
archiver: std::sync::Arc::new(archiver),
policy,
tick_interval: Duration::from_secs(3600),
batch_size: 10_000,
clock: std::sync::Arc::new(axess_clock::SystemClock),
}
}
pub fn with_tick_interval(mut self, d: Duration) -> Self {
self.tick_interval = d;
self
}
pub fn with_batch_size(mut self, n: usize) -> Self {
self.batch_size = n.max(1);
self
}
pub fn with_clock(mut self, c: std::sync::Arc<dyn axess_clock::Clock>) -> Self {
self.clock = c;
self
}
pub async fn tick(&self) -> Result<RetentionTickReport, RetentionError<S::Error, A::Error>> {
let now = self.clock.now();
let archive_cutoff = cutoff_before(now, self.policy.archive_after);
let purge_cutoff = cutoff_before(now, self.policy.purge_hot_after_archive);
let batch = self
.source
.select_unarchived_before(archive_cutoff, self.batch_size)
.await
.map_err(RetentionError::Source)?;
let mut report = RetentionTickReport::default();
if !batch.is_empty() {
let (ids, events): (Vec<S::EventId>, Vec<AuthEvent>) = batch.into_iter().unzip();
let batch_id = match (ids.first(), ids.last()) {
(Some(first), Some(last)) => {
ArchiveBatchId::from_parts(&first.to_string(), &last.to_string(), ids.len())
}
_ => unreachable!("the batch is not empty"),
};
self.archiver
.archive_batch(&batch_id, &events)
.await
.map_err(RetentionError::Archiver)?;
self.source
.mark_archived(&ids, now)
.await
.map_err(RetentionError::Source)?;
report.archived = events.len() as u64;
}
let purged = self
.source
.purge_hot_archived_before(purge_cutoff)
.await
.map_err(RetentionError::Source)?;
report.purged = purged;
Ok(report)
}
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
let interval_dur = self.tick_interval;
tokio::spawn(async move {
loop {
tokio::time::sleep(interval_dur).await;
match self.tick().await {
Ok(report) if report.archived > 0 || report.purged > 0 => {
tracing::debug!(
archived = report.archived,
purged = report.purged,
"audit retention tick",
);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "audit retention tick failed; loop continuing");
}
}
}
})
}
}
#[cfg(feature = "audit-archive-fs")]
mod filesystem;
#[cfg(feature = "audit-archive-fs")]
pub use filesystem::{FilesystemArchiveError, FilesystemAuditArchiver};
#[cfg(test)]
mod pacing;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn noop_archiver_accepts_any_batch() {
let archiver = NoopAuditArchiver;
let result = archiver
.archive_batch(&ArchiveBatchId::from_parts("0", "0", 0), &[])
.await;
assert!(result.is_ok());
}
#[test]
fn default_policy_archives_at_90_days_and_purges_hot_after_7() {
let p = AuditRetentionPolicy::default();
assert_eq!(p.archive_after, Duration::from_secs(90 * 24 * 60 * 60));
assert_eq!(
p.purge_hot_after_archive,
Duration::from_secs(7 * 24 * 60 * 60)
);
}
#[test]
fn policy_builder_chain_overrides_each_threshold() {
let p = AuditRetentionPolicy::default()
.with_archive_after(Duration::from_secs(60 * 24 * 60 * 60))
.with_purge_hot_after_archive(Duration::from_secs(14 * 24 * 60 * 60));
assert_eq!(p.archive_after.as_secs(), 60 * 24 * 60 * 60);
assert_eq!(p.purge_hot_after_archive.as_secs(), 14 * 24 * 60 * 60);
}
#[test]
fn maximum_age_yields_a_cutoff_that_matches_nothing() {
let now = chrono::Utc::now();
assert_eq!(
cutoff_before(now, Duration::MAX),
chrono::DateTime::<chrono::Utc>::MIN_UTC
);
let day = Duration::from_secs(24 * 60 * 60);
assert_eq!(cutoff_before(now, day), now - chrono::Duration::days(1));
}
#[derive(Default)]
struct CutoffSource(std::sync::Mutex<Vec<chrono::DateTime<chrono::Utc>>>);
impl AuditRetentionSource for CutoffSource {
type EventId = u64;
type Error = std::convert::Infallible;
async fn select_unarchived_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
_: usize,
) -> Result<Vec<(u64, AuthEvent)>, Self::Error> {
self.0.lock().unwrap().push(cutoff);
Ok(vec![])
}
async fn mark_archived(
&self,
_: &[u64],
_: chrono::DateTime<chrono::Utc>,
) -> Result<(), Self::Error> {
Ok(())
}
async fn purge_hot_archived_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<u64, Self::Error> {
self.0.lock().unwrap().push(cutoff);
Ok(0)
}
}
#[tokio::test]
async fn tick_with_maximum_retention_selects_and_purges_nothing() {
let policy = AuditRetentionPolicy::default()
.with_archive_after(Duration::MAX)
.with_purge_hot_after_archive(Duration::MAX);
let lp = AuditRetentionLoop::new(CutoffSource::default(), NoopAuditArchiver, policy);
lp.tick().await.unwrap();
let cutoffs = lp.source.0.lock().unwrap().clone();
assert_eq!(cutoffs.len(), 2);
for c in cutoffs {
assert_eq!(c, chrono::DateTime::<chrono::Utc>::MIN_UTC);
}
}
}