1ea3826660
The admin panel had no repair toggle wired to anything but a hardcoded
name list naming the two refcount tenants, so `thumb_derived_import` and
`thumb_attached_import` could not be run in repair mode from the UI at
all despite supporting it. And nothing in the job list said what any
given job does or whether clicking Run on production writes anything.
Three defaulted methods on `JobHandler` and `RecoverableJobHandler`:
fn description(&self) -> &'static str
fn mutates(&self) -> Mutates // Never | Always | OnRepairOnly
fn repair_description(&self) -> Option<&'static str>
`RecoverableAdapter` forwards them — the registry only holds
`dyn JobHandler`, so a tenant's metadata is invisible otherwise, and
falling back to the defaults would report every recoverable job as
read-only, including the ones that delete files.
Three values rather than a boolean because a job can be read-only by
default and destructive under `?repair=true`; a boolean answers wrongly
for one of its two modes, and `false` on something that unlinks files is
the dangerous direction to be wrong in. `repair_description` returning
`Option` collapses "does it repair" and "what does repair do" into one
method: presence gates the toggle, content is the confirmation text —
which the frontend cannot invent, since correcting a counter and
deleting sidecars are not the same warning.
`OnRepairOnly` with no `repair_description` is rejected at registration:
it claims to mutate only under a flag it does not support.
All 17 registered jobs declare all three. The panel now renders the
description under each name, badges read-only jobs, confirms before a
plain run of a mutating one, and offers the repair variant off the
backend flag instead of the name list.
Descriptions are English in the trait, next to the behaviour: one in
`locales/*.json` rots invisibly the moment a job changes, and a
translator cannot know what `manifests_consistency` reconciles. i18n can
layer on later keyed by job name with these as the fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
6.2 KiB
Rust
161 lines
6.2 KiB
Rust
//! Service 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 service garbage-collects them, with a grace window
|
|
//! past `expires_at` that preserves the audit / support answer to
|
|
//! "what happened to my access?" for a few weeks.
|
|
//!
|
|
//! **Scheduling.** Registered with the periodic-job scheduler
|
|
//! (`docs/plan/job-registry.md` Part 1). The retired `start_cleanup_job`
|
|
//! used to spawn its own `tokio::interval` loop; the scheduler now
|
|
//! dispatches [`GrantCleanupService::purge`] on the configured cadence
|
|
//! and handles panic containment + exclusivity + admin trigger routing.
|
|
//! Admin trigger with `?force=true` still bypasses the registered
|
|
//! job and calls `purge(Some(0))` directly so the grace override reaches
|
|
//! the underlying SQL.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
use tracing::{error, info};
|
|
|
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
|
use crate::common::errors::DomainError;
|
|
use crate::infrastructure::scheduler::{JobHandler, JobOutcome, JobRegistry, JobRunArgs, Mutates};
|
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
|
use async_trait::async_trait;
|
|
|
|
pub const GRANT_CLEANUP_JOB_NAME: &str = "grant_cleanup";
|
|
|
|
/// Service that deletes expired grants.
|
|
///
|
|
/// Owns an `Arc<PgAclEngine>` (not a `dyn AuthorizationEngine`) to avoid
|
|
/// the wrapper allocation on every SQL call — the caller set is small
|
|
/// (scheduler tick + 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 service uses on its scheduled runs. Exposed
|
|
/// for the admin trigger's default-response field.
|
|
pub fn grace_days(&self) -> u32 {
|
|
self.grace_days
|
|
}
|
|
|
|
/// Cadence exposed as `Duration`. Internal helper used by
|
|
/// [`Self::register`]; kept `pub` for tests.
|
|
pub fn interval(&self) -> Duration {
|
|
Duration::from_secs(self.interval_hours * 3600)
|
|
}
|
|
|
|
/// Register self with the periodic-job scheduler and return the
|
|
/// same `Arc<Self>` for DI-style chaining. Scheduled tenant with
|
|
/// interval = `self.interval()`, no timeout. See
|
|
/// `docs/plan/job-registry.md` Part 1.
|
|
pub async fn register(self: Arc<Self>, registry: &JobRegistry) -> Arc<Self> {
|
|
let interval = self.interval();
|
|
registry.register(self.clone(), Some(interval), None).await;
|
|
self
|
|
}
|
|
|
|
/// Run one purge pass.
|
|
///
|
|
/// `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.
|
|
///
|
|
/// Returns `Ok(count)` on success, `Err(_)` on DB error. Audit-log
|
|
/// lines fire on both paths (success + failure) — bulk deletion of
|
|
/// authorization rows is security-relevant enough to log even a
|
|
/// zero-count run, and failures MUST reach the audit channel.
|
|
pub async fn purge(&self, grace_override: Option<u32>) -> Result<u64, DomainError> {
|
|
let grace = grace_override.unwrap_or(self.grace_days);
|
|
let start = Instant::now();
|
|
match self.authz.purge_expired_grants(grace).await {
|
|
Ok(count) => {
|
|
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,
|
|
);
|
|
Ok(count)
|
|
}
|
|
Err(e) => {
|
|
error!(
|
|
target: "audit",
|
|
event = "grant_cleanup.failed",
|
|
grace_days = grace,
|
|
error = %e,
|
|
"Grant cleanup failed"
|
|
);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl JobHandler for GrantCleanupService {
|
|
fn name(&self) -> &str {
|
|
GRANT_CLEANUP_JOB_NAME
|
|
}
|
|
|
|
fn description(&self) -> &'static str {
|
|
"Deletes expired role grants once they are past the retention \
|
|
window. Expired grants never leak permission — every AuthZ check \
|
|
filters on expires_at — they just accumulate. The window keeps \
|
|
'what happened to my access?' answerable for a few weeks after \
|
|
expiry."
|
|
}
|
|
|
|
fn mutates(&self) -> Mutates {
|
|
Mutates::Always
|
|
}
|
|
|
|
/// Runs one purge. `count` on the returned `JobOutcome::Ok` is
|
|
/// the number of `role_grants` rows physically deleted;
|
|
/// `extra.grace_days` records which grace was applied so admin
|
|
/// listings can see it without a second lookup.
|
|
///
|
|
/// `args.force = true` collapses the grace window to zero for
|
|
/// this run only — same semantic as
|
|
/// `POST /api/admin/jobs/grant_cleanup/trigger?force=true`. The
|
|
/// configured `self.grace_days` is not mutated.
|
|
async fn run(&self, args: &JobRunArgs) -> JobOutcome {
|
|
let grace_override = if args.force { Some(0) } else { None };
|
|
let effective_grace = grace_override.unwrap_or(self.grace_days);
|
|
match self.purge(grace_override).await {
|
|
Ok(count) => JobOutcome::ok_with(
|
|
count,
|
|
serde_json::json!({
|
|
"grace_days": effective_grace,
|
|
"forced": args.force,
|
|
}),
|
|
),
|
|
Err(e) => JobOutcome::err(format!("grant cleanup failed: {e}")),
|
|
}
|
|
}
|
|
}
|