feat(sessions): clean expired sessions (exp > 3month)
This commit is contained in:
@@ -1271,6 +1271,36 @@ impl AppServiceFactory {
|
|||||||
.await
|
.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<PgPool>,
|
||||||
|
core: &CoreServices,
|
||||||
|
) -> Arc<crate::infrastructure::services::session_cleanup_service::SessionCleanupService> {
|
||||||
|
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).
|
/// Starts the tree-ETag flush job (requires database).
|
||||||
///
|
///
|
||||||
/// The statement triggers on `storage.files`/`storage.folders` only
|
/// The statement triggers on `storage.files`/`storage.folders` only
|
||||||
@@ -1709,6 +1739,13 @@ impl AppServiceFactory {
|
|||||||
|
|
||||||
self.start_tree_etag_flush_job(&maintenance_pool);
|
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_db_pool_monitor(&pool);
|
||||||
|
|
||||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ pub trait SessionRepository: Send + Sync + 'static {
|
|||||||
/// Deletes expired sessions
|
/// Deletes expired sessions
|
||||||
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
|
async fn delete_expired_sessions(&self) -> SessionRepositoryResult<u64>;
|
||||||
|
|
||||||
|
/// 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<chrono::Utc>,
|
||||||
|
) -> SessionRepositoryResult<u64>;
|
||||||
|
|
||||||
/// One-shot bind a DPoP JWK thumbprint (RFC 7638) to a session that
|
/// One-shot bind a DPoP JWK thumbprint (RFC 7638) to a session that
|
||||||
/// was created without one. Used by the post-redirect bind endpoint
|
/// was created without one. Used by the post-redirect bind endpoint
|
||||||
/// (`POST /api/auth/dpop/bind`) for the OIDC and magic-link flows,
|
/// (`POST /api/auth/dpop/bind`) for the OIDC and magic-link flows,
|
||||||
|
|||||||
@@ -534,6 +534,28 @@ impl SessionRepository for SessionPgRepository {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_sessions_expired_before(
|
||||||
|
&self,
|
||||||
|
cutoff: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> SessionRepositoryResult<u64> {
|
||||||
|
// 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<()> {
|
async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> SessionRepositoryResult<()> {
|
||||||
// `WHERE dpop_jkt IS NULL` enforces the immutability invariant
|
// `WHERE dpop_jkt IS NULL` enforces the immutability invariant
|
||||||
// at the SQL level — a bound session's UPDATE affects 0 rows
|
// at the SQL level — a bound session's UPDATE affects 0 rows
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub mod recent_recording_hook;
|
|||||||
pub mod retry_blob_backend;
|
pub mod retry_blob_backend;
|
||||||
pub mod s3_blob_backend;
|
pub mod s3_blob_backend;
|
||||||
pub mod search_index;
|
pub mod search_index;
|
||||||
|
pub mod session_cleanup_service;
|
||||||
pub mod share_unlock_cookie;
|
pub mod share_unlock_cookie;
|
||||||
pub mod smtp_email_sender;
|
pub mod smtp_email_sender;
|
||||||
pub mod swappable_blob_backend;
|
pub mod swappable_blob_backend;
|
||||||
|
|||||||
@@ -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<SessionPgRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCleanupService {
|
||||||
|
pub const JOB_NAME: &'static str = "session_cleanup";
|
||||||
|
|
||||||
|
pub fn new(session_repository: Arc<SessionPgRepository>) -> Self {
|
||||||
|
Self { session_repository }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register with the scheduler and return `Arc<Self>` for the
|
||||||
|
/// chained-constructor DI pattern (mirrors
|
||||||
|
/// `TrashCleanupService::register`).
|
||||||
|
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
|
||||||
|
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<u64, String> {
|
||||||
|
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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user