Merge pull request #569 from EdouardVanbelle/feat/grants-cleanup
feat(grant): clean up expired grants
This commit is contained in:
+4
-1
@@ -69,7 +69,10 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
||||
| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search |
|
||||
| `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata |
|
||||
| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` |
|
||||
| `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. |
|
||||
| `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep`, `POST /api/admin/internal/trigger-gc`, and `POST /api/admin/internal/trigger-grant-cleanup` — test-only synchronous triggers for the storage-usage reconciliation sweep, blob garbage collector, and expired-grant purge respectively. Used by the API test suite to assert convergence deterministically without waiting out the periodic tickers. Leave **off** in production: the routes return 404 even to an admin token when disabled. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
|
||||
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. |
|
||||
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
||||
|
||||
## Storage Backend
|
||||
|
||||
+13
@@ -230,6 +230,19 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
||||
# Enable trash/recycle bin functionality (default: true)
|
||||
#OXICLOUD_ENABLE_TRASH=true
|
||||
|
||||
# Background daemon that deletes expired `storage.role_grants` rows.
|
||||
# The AuthZ engine already filters expired grants out of every
|
||||
# permission check at read time, so leaving expired rows in place is
|
||||
# a hygiene issue — not a security one. This purge deletes rows
|
||||
# whose `expires_at` is more than GRACE_DAYS in the past, preserving
|
||||
# the audit / support answer to "what happened to my access?" for
|
||||
# the grace window.
|
||||
#
|
||||
# Default: enabled. Recommended grace: >= 15 days.
|
||||
#OXICLOUD_GRANT_CLEANUP_ENABLED=true
|
||||
#OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15
|
||||
#OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24
|
||||
|
||||
# Enable search functionality (default: true)
|
||||
#OXICLOUD_ENABLE_SEARCH=true
|
||||
|
||||
|
||||
@@ -150,6 +150,26 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete every row from `storage.role_grants` whose `expires_at` is
|
||||
/// more than `grace_days` in the past. Returns the count of rows
|
||||
/// removed.
|
||||
///
|
||||
/// The engine's `check` / `list_grants_*` paths already ignore
|
||||
/// expired rows (they filter on `expires_at > NOW()` in-query), so
|
||||
/// this is pure garbage collection — no live authorization decision
|
||||
/// changes. The grace window preserves the audit / support answer
|
||||
/// to "what happened to my access?" for a couple of weeks past
|
||||
/// expiration.
|
||||
///
|
||||
/// Grace of `0` means "delete every row whose `expires_at` is in
|
||||
/// the past, right now" — used by the admin `?force=true` trigger
|
||||
/// endpoint to enable Hurl regression testing without waiting the
|
||||
/// configured grace out.
|
||||
///
|
||||
/// Rows with `expires_at IS NULL` (permanent grants) are never
|
||||
/// touched.
|
||||
async fn purge_expired_grants(&self, grace_days: u32) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
|
||||
/// whether or not the row existed. The id comes from a prior listing
|
||||
/// or `find_grant_full_by_id` lookup.
|
||||
|
||||
@@ -931,6 +931,50 @@ pub struct FeaturesConfig {
|
||||
///
|
||||
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
|
||||
pub webdav_drive_listing_prefix: String,
|
||||
|
||||
/// Background purge of 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()`), so leaving the rows in place is a
|
||||
/// hygiene issue — not a security one. This purge deletes rows
|
||||
/// whose `expires_at` is more than [`GrantCleanupConfig::grace_days`]
|
||||
/// in the past, preserving the audit / support answer to
|
||||
/// "what happened to my access?" for the grace window.
|
||||
///
|
||||
/// Enabled by default: expired-auth-row cleanup is a
|
||||
/// security-hygiene default, not opt-in.
|
||||
pub grant_cleanup: GrantCleanupConfig,
|
||||
}
|
||||
|
||||
/// Config for the daily expired-grant purge (see
|
||||
/// [`FeaturesConfig::grant_cleanup`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrantCleanupConfig {
|
||||
/// Master switch. Env: `OXICLOUD_GRANT_CLEANUP_ENABLED`
|
||||
/// (default `true`).
|
||||
pub enabled: bool,
|
||||
/// Days past a grant's `expires_at` before the row is eligible
|
||||
/// for deletion. Env: `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS`
|
||||
/// (default `15`).
|
||||
///
|
||||
/// The recommendation is `> 15` — enough to answer
|
||||
/// support/audit questions about recently-lapsed grants without
|
||||
/// keeping dead rows forever.
|
||||
pub grace_days: u32,
|
||||
/// How often the daemon fires, in hours. Env:
|
||||
/// `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` (default `24`).
|
||||
pub interval_hours: u64,
|
||||
}
|
||||
|
||||
impl Default for GrantCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
grace_days: 15,
|
||||
interval_hours: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FeaturesConfig {
|
||||
@@ -954,6 +998,7 @@ impl Default for FeaturesConfig {
|
||||
// maps to the caller's default drive; drive listing is
|
||||
// reachable at `/webdav/@drive/`.
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
grant_cleanup: GrantCleanupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1525,6 +1570,25 @@ impl AppConfig {
|
||||
config.features.enable_admin_internal_endpoints = val;
|
||||
}
|
||||
|
||||
// Grant-cleanup daemon. Purges rows from `storage.role_grants`
|
||||
// whose `expires_at` is more than `grace_days` in the past.
|
||||
// See `GrantCleanupConfig` for defaults + rationale.
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_ENABLED").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.enabled = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_GRACE_DAYS").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.grace_days = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.interval_hours = val.max(1);
|
||||
}
|
||||
|
||||
// Native WebDAV drive-picker path segment. Sanitised by
|
||||
// stripping leading/trailing slashes so operators can pass
|
||||
// `/drives/` or `drives` interchangeably; empty string means
|
||||
|
||||
@@ -1290,6 +1290,9 @@ impl AppServiceFactory {
|
||||
let places_service: Option<Arc<PlacesService>>;
|
||||
let people_service: Option<Arc<PeopleService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
// Lifted out of the database-services block so PR 9's invite
|
||||
@@ -1333,6 +1336,25 @@ impl AppServiceFactory {
|
||||
|
||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||
|
||||
grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
|
||||
let svc = Arc::new(
|
||||
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
|
||||
authorization.clone(),
|
||||
core.config.features.grant_cleanup.grace_days,
|
||||
core.config.features.grant_cleanup.interval_hours,
|
||||
),
|
||||
);
|
||||
// First tick fires immediately inside start_cleanup_job —
|
||||
// matches the trash/storage-usage daemon shape.
|
||||
svc.clone().start_cleanup_job().await;
|
||||
Some(svc)
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Grant-cleanup daemon disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// User-lifecycle dispatcher. Hook order is registration order;
|
||||
// document dependencies inline if/when any arise. Today:
|
||||
// 1. AuditLifecycleHook — fires first so the
|
||||
@@ -1557,6 +1579,7 @@ impl AppServiceFactory {
|
||||
places_service,
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
grant_cleanup_service,
|
||||
calendar_service: None,
|
||||
calendar_use_case: None,
|
||||
addressbook_use_case: None,
|
||||
@@ -2029,6 +2052,14 @@ pub struct AppState {
|
||||
pub places_service: Option<Arc<PlacesService>>,
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
/// Handle to the background daemon that purges expired
|
||||
/// `storage.role_grants` rows. `None` when the daemon is disabled
|
||||
/// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin
|
||||
/// `POST /api/admin/internal/trigger-grant-cleanup` handler uses
|
||||
/// this to invoke the purge on demand (test-only).
|
||||
pub grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS
|
||||
for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] {
|
||||
let path =
|
||||
root.join(size.dir_name())
|
||||
.join(format!("{}.{}", &blob_hash, format.ext()));
|
||||
.join(format!("{}.{}", blob_hash, format.ext()));
|
||||
if tokio::fs::metadata(&path).await.is_ok() {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// deployments don't need a different route table.
|
||||
.route("/internal/trigger-sweep", post(internal_trigger_sweep))
|
||||
.route("/internal/trigger-gc", post(internal_trigger_gc))
|
||||
.route(
|
||||
"/internal/trigger-grant-cleanup",
|
||||
post(internal_trigger_grant_cleanup),
|
||||
)
|
||||
// Drives — admin-wide view (distinct from `/api/drives` which
|
||||
// is filtered to the caller's role grants).
|
||||
.route("/drives", get(list_all_drives))
|
||||
@@ -2160,3 +2164,93 @@ pub async fn internal_trigger_gc(
|
||||
Err(e) => AppError::internal_error(format!("gc failed: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameters for `POST /api/admin/internal/trigger-grant-cleanup`.
|
||||
///
|
||||
/// `force=true` sets the grace window to `0` for this call — deletes
|
||||
/// every row whose `expires_at` is in the past, right now. Enables
|
||||
/// Hurl regressions to plant a past-dated grant and immediately
|
||||
/// observe it purged, without waiting the configured
|
||||
/// `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` out.
|
||||
///
|
||||
/// Without `force`, the daemon's configured grace applies — the same
|
||||
/// SQL the daily loop runs.
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct InternalTriggerGrantCleanupQuery {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/admin/internal/trigger-grant-cleanup` — run the expired-
|
||||
/// grant purge synchronously.
|
||||
///
|
||||
/// Test-only. Deletes rows from `storage.role_grants` whose
|
||||
/// `expires_at` is more than `grace_days` in the past (or immediately,
|
||||
/// with `?force=true`). Same SQL as the periodic `GrantCleanupService`
|
||||
/// daemon — exposed under an admin route so Hurl can wait for it
|
||||
/// deterministically.
|
||||
///
|
||||
/// Response fields:
|
||||
/// `grants_deleted` — count of rows removed by this invocation
|
||||
/// `grace_days` — the grace window that was applied (0 when
|
||||
/// `?force=true`, otherwise the config value)
|
||||
/// `forced` — echoes the query param
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/internal/trigger-grant-cleanup",
|
||||
params(("force" = Option<bool>, Query, description = "Force grace = 0 for this run (test-only)")),
|
||||
responses(
|
||||
(status = 200, description = "Purge ran"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
|
||||
(status = 503, description = "Grant-cleanup daemon disabled (OXICLOUD_GRANT_CLEANUP_ENABLED=false)"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn internal_trigger_grant_cleanup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<InternalTriggerGrantCleanupQuery>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if !state.core.config.features.enable_admin_internal_endpoints {
|
||||
return internal_endpoints_disabled();
|
||||
}
|
||||
if let Err(e) = admin_guard(&state, &headers).await {
|
||||
return e.into_response();
|
||||
}
|
||||
// Daemon may be disabled by config even when the internal-endpoint
|
||||
// gate is on. Return 503 (rather than 404 or 500) so integration
|
||||
// tests can distinguish "surface not exposed" from "surface
|
||||
// exposed but backing service off".
|
||||
let svc = match state.grant_cleanup_service.as_ref() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": "grant_cleanup_service not available (disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false)",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
// `force=true` collapses the grace window to zero for this run
|
||||
// only — the daemon's configured grace is untouched. Mirrors the
|
||||
// `trigger-gc?force=true` shape.
|
||||
let grace_override = if query.force { Some(0) } else { None };
|
||||
let grants_deleted = svc.purge(grace_override).await;
|
||||
let grace_days = grace_override.unwrap_or_else(|| svc.grace_days());
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"grants_deleted": grants_deleted,
|
||||
"grace_days": grace_days,
|
||||
"forced": query.force,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -225,6 +225,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::admin_handler::complete_migration,
|
||||
handlers::admin_handler::verify_migration,
|
||||
handlers::admin_handler::generate_encryption_key,
|
||||
// Admin internal-trigger handlers — gated by
|
||||
// OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS (Off by default in
|
||||
// prod; on for the Hurl suite). Documented in OpenAPI so
|
||||
// integrators writing test harnesses can discover the surface.
|
||||
handlers::admin_handler::internal_trigger_sweep,
|
||||
handlers::admin_handler::internal_trigger_gc,
|
||||
handlers::admin_handler::internal_trigger_grant_cleanup,
|
||||
// Grant / ReBAC handlers (free functions)
|
||||
handlers::grant_handler::create_grant,
|
||||
handlers::grant_handler::revoke_grant,
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# =============================================================
|
||||
# OxiCloud — Expired-grant purge (GrantCleanupService)
|
||||
# =============================================================
|
||||
# Regression coverage for the daily purge that deletes rows from
|
||||
# `storage.role_grants` whose `expires_at` is more than
|
||||
# `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` in the past.
|
||||
#
|
||||
# The engine's `check` / `list_grants_*` paths already filter
|
||||
# expired grants out at read time — this purge is pure garbage
|
||||
# collection. If the SQL were wrong (e.g. missing
|
||||
# `expires_at IS NOT NULL`, wrong sign on the interval), the
|
||||
# assertions here catch it before the daemon runs against real
|
||||
# data.
|
||||
#
|
||||
# Uses the `POST /api/admin/internal/trigger-grant-cleanup`
|
||||
# admin endpoint (gated by
|
||||
# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`, on for the
|
||||
# api-test suite). `?force=true` collapses the grace window to
|
||||
# zero for the call so we can plant a past-dated grant and
|
||||
# immediately observe it purged, without waiting 15+ days.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login admin (Alice), capture home folder id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
alice_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create a grantee user (mallory) — someone we can
|
||||
# grant Alice's resources to without polluting shared
|
||||
# state used by other test files.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "gc-mallory",
|
||||
"password": "GcMalloryPassword1!",
|
||||
"email": "gc-mallory@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
mallory_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice creates two folders: one to hold an expired
|
||||
# grant, one to hold a permanent (no-expiry) grant we
|
||||
# expect the purge to leave alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-expired", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-permanent", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Plant an expired grant. Set `expires_at` in 2020 so
|
||||
# any grace window less than several years still
|
||||
# catches it. The grant handler silently accepts past-
|
||||
# dated `expires_at` — a separate PR would reject them
|
||||
# on the create path, but here we exploit the
|
||||
# permissive behaviour as a test fixture.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{expired_folder_id}}" },
|
||||
"role": "viewer",
|
||||
"expires_at": "2020-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# Confirm the grant IS present in the listing — the engine's
|
||||
# filter is `expires_at > NOW()`, so the past-dated row is
|
||||
# already invisible to `check()` but still exists physically
|
||||
# (and thus in the list endpoint too — verified below).
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Bare array, filter selector — see memory note on Hurl JSONPath
|
||||
# quirks: use `$[?(...)]` (single-match returns scalar; no `nth`).
|
||||
jsonpath "$[?(@.id=='{{expired_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Plant a permanent grant on the other folder (no
|
||||
# `expires_at`). The purge MUST leave it alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{permanent_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Trigger the purge with `force=true`. The endpoint
|
||||
# collapses the grace window to 0 for this call only
|
||||
# — the daemon's configured grace is untouched.
|
||||
#
|
||||
# Expect `grants_deleted >= 1` (the past-dated row),
|
||||
# `grace_days == 0`, `forced == true`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == true
|
||||
jsonpath "$.grace_days" == 0
|
||||
# At least the expired-fixture row we just planted.
|
||||
jsonpath "$.grants_deleted" >= 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — The expired grant is gone. The permanent grant
|
||||
# survives.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# The list is either empty or contains no row with the expired
|
||||
# grant's id — the filter must not select anything.
|
||||
jsonpath "$[*].id" not contains "{{expired_grant_id}}"
|
||||
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Permanent grant untouched.
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Second trigger with `force=true` on a table that no
|
||||
# longer has any past-dated grants. Expect
|
||||
# `grants_deleted == 0`. This is the regression guard
|
||||
# on the WHERE clause — if `expires_at IS NOT NULL`
|
||||
# were missing, this would nuke the permanent grant
|
||||
# from Step 5 (any row with `NULL < NOW() - 0 days` is
|
||||
# false in SQL, so it's already correct; but a
|
||||
# mistyped predicate could regress).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Unforced trigger. Grace = configured value (15).
|
||||
# No new expired grants planted, so purge is a no-op.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == false
|
||||
# Response echoes the configured grace (15 days by default).
|
||||
jsonpath "$.grace_days" == 15
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# Permanent grant still there after the unforced call.
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — drop both folders. Cascade removes the remaining
|
||||
# grant + any children.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -166,6 +166,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/public_shares.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/grant_cleanup.hurl" \
|
||||
"$API_DIR/role_grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/groups_effective_members.hurl" \
|
||||
|
||||
Reference in New Issue
Block a user