diff --git a/src/common/di.rs b/src/common/di.rs index 9e89ab0a..5a6fb9f8 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1271,6 +1271,36 @@ impl AppServiceFactory { .await } + /// Registers the session-cleanup janitor with the scheduler. + /// + /// Purges rows in `auth.sessions` whose `expires_at` is older than + /// the janitor's retention window (currently 90 days, hardcoded — + /// see `SessionCleanupService::RETENTION_DAYS`). Runs on the + /// maintenance pool: the sweep is one bulk-DELETE per tick, but + /// keeping session-hygiene off the request pool matches every + /// other janitor in this file and prevents starvation surprises. + /// + /// Fills the gap called out in + /// `[[project_session_janitor_missing]]` — `delete_expired_sessions` + /// (and its cutoff-taking sibling) existed on the repo but nothing + /// was scheduling them, so `auth.sessions` bloated forever. + pub async fn create_session_cleanup_service( + &self, + maintenance_pool: &Arc, + core: &CoreServices, + ) -> Arc { + let session_repo = Arc::new( + crate::infrastructure::repositories::SessionPgRepository::new(maintenance_pool.clone()), + ); + Arc::new( + crate::infrastructure::services::session_cleanup_service::SessionCleanupService::new( + session_repo, + ), + ) + .register(&core.job_registry) + .await + } + /// Starts the tree-ETag flush job (requires database). /// /// The statement triggers on `storage.files`/`storage.folders` only @@ -1709,6 +1739,13 @@ impl AppServiceFactory { self.start_tree_etag_flush_job(&maintenance_pool); + // Session janitor — bulk-deletes `auth.sessions` rows past + // the retention window (90 days beyond `expires_at`). Runs + // once every 24h on the maintenance pool. + let _ = self + .create_session_cleanup_service(&maintenance_pool, &core) + .await; + self.start_db_pool_monitor(&pool); self.start_content_index_job(&maintenance_pool, &core, content_index); diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 8a2a2086..27a5bdbd 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -125,6 +125,22 @@ pub trait SessionRepository: Send + Sync + 'static { /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; + /// Purge session rows whose `expires_at` is strictly older than + /// `cutoff` — i.e. long-expired rows the janitor drops after a + /// forensic window past the natural expiry (per + /// [[project_session_janitor_missing]]). Distinct from + /// `delete_expired_sessions` so callers can pick a policy: + /// * `delete_expired_sessions` — everything past `NOW()`, aggressive + /// (currently unused — the janitor prefers the delayed variant so + /// ops have a trail before the row disappears); + /// * `delete_sessions_expired_before(NOW() - 3 months)` — keeps a + /// 3-month audit window, the shape `SessionCleanupService` runs + /// with. Returns the row count for the audit line. + async fn delete_sessions_expired_before( + &self, + cutoff: chrono::DateTime, + ) -> SessionRepositoryResult; + /// One-shot bind a DPoP JWK thumbprint (RFC 7638) to a session that /// was created without one. Used by the post-redirect bind endpoint /// (`POST /api/auth/dpop/bind`) for the OIDC and magic-link flows, diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index a714babe..b4c638be 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -534,6 +534,28 @@ impl SessionRepository for SessionPgRepository { Ok(result.rows_affected()) } + async fn delete_sessions_expired_before( + &self, + cutoff: chrono::DateTime, + ) -> SessionRepositoryResult { + // Same shape as `delete_expired_sessions` but the caller picks the + // cutoff instead of it being pinned to NOW(). Lets the janitor + // keep a forensic window past the natural session expiry — see + // `SessionCleanupService` and [[project_session_janitor_missing]]. + let result = sqlx::query( + r#" + DELETE FROM auth.sessions + WHERE expires_at < $1 + "#, + ) + .bind(cutoff) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + Ok(result.rows_affected()) + } + async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()> { // `WHERE dpop_jkt IS NULL` enforces the immutability invariant // at the SQL level — a bound session's UPDATE affects 0 rows diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 62f2e0af..b86e7560 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -50,6 +50,7 @@ pub mod recent_recording_hook; pub mod retry_blob_backend; pub mod s3_blob_backend; pub mod search_index; +pub mod session_cleanup_service; pub mod share_unlock_cookie; pub mod smtp_email_sender; pub mod swappable_blob_backend; diff --git a/src/infrastructure/services/session_cleanup_service.rs b/src/infrastructure/services/session_cleanup_service.rs new file mode 100644 index 00000000..c909540a --- /dev/null +++ b/src/infrastructure/services/session_cleanup_service.rs @@ -0,0 +1,126 @@ +//! Periodic janitor that purges long-expired session rows from +//! `auth.sessions`. Naturally-expired sessions accumulate forever +//! otherwise — `SessionRepository::delete_expired_sessions()` and its +//! delayed sibling exist, but nothing was scheduling them (see +//! [[project_session_janitor_missing]] for the historical gap). +//! +//! Retention window: **3 months past `expires_at`**. Rows past the +//! natural refresh-token expiry (default 30 days) already can't +//! authenticate — expiry is checked independently at every auth path +//! (`session.is_expired()` in the refresh handler, JWT `exp` at the +//! middleware). The 3-month cushion buys ops a forensic window before +//! the row disappears entirely — a security-review after-the-fact can +//! still see "this session belonged to user X, from IP Y, minted via +//! origin Z". After that, the row is dead weight. +//! +//! Interval: 24 hours, same cadence as +//! [`super::trash_cleanup_service::TrashCleanupService`]. Session +//! cleanup is even cheaper (one SQL DELETE, no dedup GC pass), so this +//! could run more often — daily is chosen for consistency with the +//! other janitors and to keep operator noise predictable. +//! +//! Not gated behind a feature flag: expired sessions are always safe +//! to drop, and hoarding them creates a slow leak that shows up as +//! `auth.sessions` bloat months into a deployment. The one operator +//! surface is the retention window itself — hardcoded to 90 days for +//! now; if a tenant needs a different value, promote to +//! `OXICLOUD_SESSION_RETENTION_DAYS` and thread through here. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use tracing::{error, info}; + +use crate::domain::repositories::session_repository::SessionRepository; +use crate::infrastructure::repositories::SessionPgRepository; +use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs}; + +/// How long a session row survives past its `expires_at` before this +/// janitor deletes it. Enough time for a security review of a +/// suspicious session to still see the row; not so long that +/// `auth.sessions` bloats indefinitely. +const RETENTION_DAYS: i64 = 90; + +/// How often the sweep runs. Hours, matches the trash-cleanup cadence. +const SWEEP_INTERVAL_HOURS: u64 = 24; + +pub struct SessionCleanupService { + session_repository: Arc, +} + +impl SessionCleanupService { + pub const JOB_NAME: &'static str = "session_cleanup"; + + pub fn new(session_repository: Arc) -> Self { + Self { session_repository } + } + + /// Register with the scheduler and return `Arc` for the + /// chained-constructor DI pattern (mirrors + /// `TrashCleanupService::register`). + pub async fn register(self: Arc, registry: &JobRegistry) -> Arc { + let interval = Duration::from_secs(SWEEP_INTERVAL_HOURS * 3600); + registry.register(self.clone(), Some(interval), None).await; + self + } + + /// One-shot execution — deletes rows where + /// `expires_at < NOW() - RETENTION_DAYS`. Returns the row count so + /// `JobHandler::run` can shape a `JobOutcome::Ok`. + async fn run_once(&self) -> Result { + let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS); + self.session_repository + .delete_sessions_expired_before(cutoff) + .await + .map_err(|e| format!("delete_sessions_expired_before: {e}")) + } +} + +#[async_trait] +impl JobHandler for SessionCleanupService { + fn name(&self) -> &str { + Self::JOB_NAME + } + + /// Runs one bulk-delete of long-expired session rows. `count` on + /// the returned `JobOutcome::Ok` is the number of rows dropped + /// this tick; `extra` records the retention window operators can + /// spot-check against `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` / + /// refresh TTL if they suspect the window is too tight. + /// + /// `args.force` is ignored — there's no acceleration knob (the + /// retention window is a constant, not a runtime tunable). + async fn run(&self, _args: &JobRunArgs) -> JobOutcome { + match self.run_once().await { + Ok(0) => JobOutcome::ok_with( + 0, + serde_json::json!({ + "retention_days": RETENTION_DAYS, + "note": "no rows past retention window", + }), + ), + Ok(deleted) => { + info!( + target: "audit", + event = "session_cleanup.purged", + rows_deleted = deleted, + retention_days = RETENTION_DAYS, + "🧹 Session janitor purged {deleted} rows past {RETENTION_DAYS}-day retention" + ); + JobOutcome::ok_with( + deleted, + serde_json::json!({ + "retention_days": RETENTION_DAYS, + "rows_deleted": deleted, + }), + ) + } + Err(e) => { + error!("Session cleanup failed: {e}"); + JobOutcome::err(format!("session cleanup failed: {e}")) + } + } + } +}