feat(grant): clean up expired grants

This commit is contained in:
Edouard Vanbelle
2026-07-12 18:14:15 +02:00
parent 9ed360443a
commit ba620166ee
12 changed files with 623 additions and 1 deletions
@@ -0,0 +1,124 @@
//! Background daemon that purges expired `storage.role_grants` rows.
//!
//! The AuthZ engine already filters expired grants out of every
//! permission check at read time (`expires_at IS NULL OR
//! expires_at > NOW()` on every `check` / `list_grants_*` path in
//! `PgAclEngine`), so expired rows never leak permission. They just
//! accumulate. This daemon garbage-collects them once per
//! [`GrantCleanupService::interval_hours`], with a grace window past
//! `expires_at` that preserves the audit / support answer to "what
//! happened to my access?" for a few weeks.
//!
//! Shape mirrors [`TrashCleanupService`] verbatim (fire-and-forget
//! `tokio::spawn`, `tokio::time::interval`, first-tick-immediate). The
//! authoritative pattern for background daemons in this codebase; see
//! the plan doc `docs/plan/` (deferred future work: fold all daemons
//! into a central `JobRegistry` that plugins can also register into).
//!
//! [`TrashCleanupService`]: crate::infrastructure::services::trash_cleanup_service::TrashCleanupService
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::time;
use tracing::{error, info};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
/// Daemon that periodically deletes expired grants.
///
/// Owns an `Arc<PgAclEngine>` (not a `dyn AuthorizationEngine`) to avoid
/// the wrapper allocation on every SQL call — the daemon is the sole
/// caller of `purge_expired_grants` outside of the admin trigger
/// endpoint, both statically dispatched.
pub struct GrantCleanupService {
authz: Arc<PgAclEngine>,
grace_days: u32,
interval_hours: u64,
}
impl GrantCleanupService {
pub fn new(authz: Arc<PgAclEngine>, grace_days: u32, interval_hours: u64) -> Self {
Self {
authz,
grace_days,
// Minimum 1 hour — matches TrashCleanupService's clamp so
// a mis-set `0` doesn't spin a hot loop.
interval_hours: interval_hours.max(1),
}
}
/// Grace period the daemon uses on its scheduled ticks. Exposed
/// for the admin trigger's default-response field.
pub fn grace_days(&self) -> u32 {
self.grace_days
}
/// Fire-and-forget the periodic purge. Never joins; killed
/// implicitly at `tokio::runtime::shutdown`.
pub async fn start_cleanup_job(self: Arc<Self>) {
let interval_hours = self.interval_hours;
let grace_days = self.grace_days;
info!(
"Starting grant-cleanup daemon: every {}h, grace = {}d",
interval_hours, grace_days
);
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(interval_hours * 60 * 60));
// First tick fires immediately — matches TrashCleanupService.
// Any accumulated backlog at boot gets flushed straight away.
loop {
interval.tick().await;
self.run_once().await;
}
});
}
/// One scheduled pass. Also called by the admin trigger endpoint
/// (via a shared `Arc<GrantCleanupService>` on `AppState`).
///
/// `grace_override`:
/// - `None` → use the configured grace (`self.grace_days`).
/// - `Some(n)` → override with `n`. The admin `?force=true` trigger
/// passes `Some(0)` so Hurl regressions can hit expired grants
/// without waiting the configured grace out.
pub async fn purge(&self, grace_override: Option<u32>) -> u64 {
let grace = grace_override.unwrap_or(self.grace_days);
let start = Instant::now();
match self.authz.purge_expired_grants(grace).await {
Ok(count) => {
// Audit-channel logging: bulk deletion of authorization
// rows is security-relevant enough to keep it in the
// audit stream even when the count is zero (proves the
// daemon is reachable).
info!(
target: "audit",
event = "grant_cleanup.purged",
count = count,
grace_days = grace,
elapsed_ms = start.elapsed().as_millis() as u64,
"👮🏻‍♂️ Purged {} expired grant(s) older than {} days",
count,
grace,
);
count
}
Err(e) => {
error!(
target: "audit",
event = "grant_cleanup.failed",
grace_days = grace,
error = %e,
"Grant cleanup failed"
);
0
}
}
}
/// Convenience for the scheduled loop.
async fn run_once(&self) {
let _ = self.purge(None).await;
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod face_indexing_service;
pub mod ffmpeg_video_frame_service;
pub mod file_content_cache;
pub mod file_system_i18n_service;
pub mod grant_cleanup_service;
pub mod image_transcode_service;
pub mod jwt_service;
pub mod local_blob_backend;
@@ -2075,6 +2075,27 @@ impl AuthorizationEngine for PgAclEngine {
Ok(())
}
async fn purge_expired_grants(&self, grace_days: u32) -> Result<u64, DomainError> {
// Uses the partial index `idx_role_grants_expires_at` (migration
// 20260730000000), which covers `WHERE expires_at IS NOT NULL`
// — so this DELETE only touches indexed rows even when the
// `role_grants` table has tens of millions of permanent grants.
//
// Grace days is bound as bigint and multiplied into an
// interval — parameterised, no injection surface. u32 → i64
// is loss-free.
let result = sqlx::query(
"DELETE FROM storage.role_grants \
WHERE expires_at IS NOT NULL \
AND expires_at < NOW() - ($1::bigint * INTERVAL '1 day')",
)
.bind(grace_days as i64)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("purge_expired_grants: {e}")))?;
Ok(result.rows_affected())
}
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
.bind(grant_id)