diff --git a/docs/config/env.md b/docs/config/env.md index cabc50eb..e6e3414c 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -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//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` 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 diff --git a/example.env b/example.env index b814ae7d..5676c70f 100644 --- a/example.env +++ b/example.env @@ -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 diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 99144483..6a02f696 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -150,6 +150,26 @@ pub trait AuthorizationEngine: Send + Sync + 'static { expires_at: Option>, ) -> 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; + /// 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. diff --git a/src/common/config.rs b/src/common/config.rs index a5fc1f79..01162899 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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::()) + && 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::()) + && 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::()) + && 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 diff --git a/src/common/di.rs b/src/common/di.rs index b6dfa469..5b78fbc5 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1290,6 +1290,9 @@ impl AppServiceFactory { let places_service: Option>; let people_service: Option>; let storage_usage_service: Option>; + let grant_cleanup_service: Option< + Arc, + >; let mut auth_services: Option = None; let mut nextcloud_services: Option = 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>, pub people_service: Option>, pub storage_usage_service: Option>, + /// 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, + >, pub calendar_service: Option>, pub calendar_use_case: Option>, pub addressbook_use_case: Option>, diff --git a/src/infrastructure/services/grant_cleanup_service.rs b/src/infrastructure/services/grant_cleanup_service.rs new file mode 100644 index 00000000..54435606 --- /dev/null +++ b/src/infrastructure/services/grant_cleanup_service.rs @@ -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` (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, + grace_days: u32, + interval_hours: u64, +} + +impl GrantCleanupService { + pub fn new(authz: Arc, 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) { + 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` 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) -> 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; + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 9e65f351..0f85ea61 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 4d69e7fe..ec60aba3 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -2075,6 +2075,27 @@ impl AuthorizationEngine for PgAclEngine { Ok(()) } + async fn purge_expired_grants(&self, grace_days: u32) -> Result { + // 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) diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index cbcd2b2a..5f8913cd 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -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; } diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index b73e69ff..d285b5e2 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -103,6 +103,10 @@ pub fn admin_routes() -> Router> { // 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, 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>, + headers: HeaderMap, + Query(query): Query, +) -> 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() +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 06d87a8c..f9277935 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -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, diff --git a/tests/api/grant_cleanup.hurl b/tests/api/grant_cleanup.hurl new file mode 100644 index 00000000..2ab47ba8 --- /dev/null +++ b/tests/api/grant_cleanup.hurl @@ -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 diff --git a/tests/api/run.sh b/tests/api/run.sh index 5f734bb1..a62f0406 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -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" \