Merge upstream/main into feat/external-file-mounts
Resolve conflicts between the external-file-mounts feature and upstream's D5/D7 refactor (per-file provenance, keyset pagination, cross-drive move gates, resource-access hook, folder-cascade lifecycle hook). Key resolutions: - FolderService::new now takes (repo, authz, file_lifecycle, mount_router); all callers + DI updated. - FileRetrievalService / FileManagementService keep both the mount_router and the new resource_access_hook / drive_repo / storage_usage wiring. - list_files_batch_with_perms: adapt the mount branch from offset- to keyset (after_name) pagination, mirroring paginate_mount_entries. - download_file_impl: keep upstream's &HeaderMap + `impl IntoResponse + use<>` signature, retain the mount-download branch. - Mount DTOs: the retired `owner_id` field maps onto created_by/updated_by (the mount owner) — the fields the frontend now uses for owner display. - admin/+page.svelte: keep upstream's user-delete modal + the 'mounts' tab. - Bump memmap2 0.9.10 -> 0.9.11 (RUSTSEC critical advisory fix) and regenerate Cargo.lock against the merged Cargo.toml.
This commit is contained in:
@@ -15,6 +15,7 @@ use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
|
||||
use chrono::{Duration, Utc};
|
||||
use moka::future::Cache;
|
||||
use rand_core::RngCore;
|
||||
use smol_str::SmolStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
use uuid::Uuid;
|
||||
@@ -56,12 +57,17 @@ const BASIC_AUTH_CACHE_TTL_SECS: u64 = 300;
|
||||
const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
/// Cached identity returned after a successful Basic Auth verification.
|
||||
///
|
||||
/// `Arc<str>` / inline `SmolStr` fields: moka's `get` clones the value, so
|
||||
/// with owned `String`s every warm Basic-auth request (all DAV traffic)
|
||||
/// paid 3 string copies just to read the cached identity. Now a hit is
|
||||
/// refcount bumps + a 24-byte memcpy.
|
||||
#[derive(Clone)]
|
||||
struct CachedBasicAuthResult {
|
||||
user_id: Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
role: String,
|
||||
username: Arc<str>,
|
||||
email: Arc<str>,
|
||||
role: SmolStr,
|
||||
}
|
||||
|
||||
pub struct AppPasswordService {
|
||||
@@ -299,17 +305,62 @@ impl AppPasswordService {
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Uuid, String, String, String), DomainError> {
|
||||
) -> Result<(Uuid, Arc<str>, Arc<str>, SmolStr), DomainError> {
|
||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||
let cache_key: [u8; 32] =
|
||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||
// Stream the parts into an incremental hasher instead of
|
||||
// `blake3::hash(format!("{username}:{password}").as_bytes())` — the
|
||||
// `format!` heap-allocated one throw-away `String` per request (this
|
||||
// runs before the cache lookup, so even cache hits paid it), and DAV
|
||||
// sync clients hammer Basic auth on every request. Byte-identical key:
|
||||
// blake3 is a stream hash, so `hash(a || ":" || b)` == feeding the same
|
||||
// bytes in order (benches/ROUND19.md §M1).
|
||||
let cache_key: [u8; 32] = {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(username.as_bytes());
|
||||
h.update(b":");
|
||||
h.update(password.as_bytes());
|
||||
h.finalize().into()
|
||||
};
|
||||
|
||||
// ── 2. Cache hit → return immediately ────────────────────────
|
||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||
return Ok((cached.user_id, cached.username, cached.email, cached.role));
|
||||
}
|
||||
// ── 2. Single-flight cache lookup ─────────────────────────────
|
||||
// Concurrent misses on the same credential coalesce into ONE
|
||||
// full verification: DAV sync clients hold 4-8 parallel
|
||||
// connections, so an expiring cache entry used to fan out into
|
||||
// K simultaneous Argon2id runs (~100-300 ms CPU + 64 MiB RAM
|
||||
// apiece) every TTL — a recurring p99 spike on every DAV
|
||||
// surface (8 -> 1 verifications, benches/AUTH-HERD.md).
|
||||
// `try_get_with` caches only `Ok` results, so failed
|
||||
// verifications are still never cached, preserving the full
|
||||
// Argon2id cost as a brute-force deterrent.
|
||||
let result = self
|
||||
.auth_cache
|
||||
.try_get_with(
|
||||
cache_key,
|
||||
self.verify_basic_auth_uncached(username, password),
|
||||
)
|
||||
.await
|
||||
.map_err(
|
||||
|e: std::sync::Arc<DomainError>| match std::sync::Arc::try_unwrap(e) {
|
||||
Ok(err) => err,
|
||||
// Another coalesced waiter still holds the Arc — rebuild
|
||||
// an equivalent error (the source chain isn't clonable).
|
||||
Err(shared) => {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
}
|
||||
},
|
||||
)?;
|
||||
Ok((result.user_id, result.username, result.email, result.role))
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
/// The uncached Basic Auth slow path: user lookup, prefix-scoped
|
||||
/// candidate fetch, Argon2id verification. Runs at most once per
|
||||
/// credential per TTL — `verify_basic_auth` coalesces concurrent
|
||||
/// callers onto a single in-flight instance of this future.
|
||||
async fn verify_basic_auth_uncached(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<CachedBasicAuthResult, DomainError> {
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
@@ -363,15 +414,14 @@ impl AppPasswordService {
|
||||
{
|
||||
let _ = self.repo.touch_last_used(ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
// Caching happens in `verify_basic_auth`: `try_get_with`
|
||||
// stores this value under the blake3 key on return.
|
||||
return Ok(CachedBasicAuthResult {
|
||||
user_id: user.id(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
return Ok((result.user_id, result.username, result.email, result.role));
|
||||
username: Arc::from(user.username().unwrap_or("")),
|
||||
email: Arc::from(user.email()),
|
||||
role: SmolStr::new_static(user.role().as_str()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::application::dtos::user_dto::{
|
||||
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto,
|
||||
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto,
|
||||
UpgradeToInternalDto, UserDto,
|
||||
};
|
||||
use crate::application::ports::auth_ports::{
|
||||
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
|
||||
@@ -7,7 +8,7 @@ use crate::application::ports::auth_ports::{
|
||||
};
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::config::{AuthMethod, OidcConfig};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
@@ -146,8 +147,22 @@ pub struct AuthApplicationService {
|
||||
/// request. The short TTL keeps the "role changes apply without token
|
||||
/// rotation" property within seconds while removing one DB round-trip
|
||||
/// per request; the known mutation paths (`change_user_role`,
|
||||
/// `set_user_active`) also invalidate eagerly.
|
||||
user_flags_cache: Cache<Uuid, UserFlags>,
|
||||
/// `set_user_active`) also invalidate eagerly. `moka::future` so
|
||||
/// concurrent misses for one user coalesce into a single DB lookup
|
||||
/// (`try_get_with` single-flight) — every authenticated request
|
||||
/// calls this, so each 30 s TTL expiry used to fan out one SELECT
|
||||
/// per in-flight request of that user.
|
||||
user_flags_cache: moka::future::Cache<Uuid, UserFlags>,
|
||||
/// Self-service auth-method allowlist (mirrors
|
||||
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
|
||||
/// allowed. Consulted by login / register / magic-link handlers via
|
||||
/// `is_password_login_allowed()` / `is_magic_link_login_allowed()`
|
||||
/// so callers don't have to reach for the app config.
|
||||
allowed_auth_methods: Vec<AuthMethod>,
|
||||
/// Whether `POST /api/auth/login` refuses accounts whose
|
||||
/// `email_verified_at IS NULL`. Mirrors
|
||||
/// `AuthConfig::require_verified_email`.
|
||||
require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
||||
@@ -187,13 +202,99 @@ impl AuthApplicationService {
|
||||
.time_to_live(Duration::from_secs(120))
|
||||
.build(),
|
||||
magic_link_repo: None,
|
||||
user_flags_cache: Cache::builder()
|
||||
user_flags_cache: moka::future::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(USER_FLAGS_CACHE_TTL)
|
||||
.build(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates the auth-method allowlist + `require_verified_email`
|
||||
/// snapshot from the loaded config. Called by the DI factory. If
|
||||
/// left uncalled (test builds), defaults are permissive: both
|
||||
/// methods enabled, verified-email not required.
|
||||
pub fn with_auth_policy(
|
||||
mut self,
|
||||
allowed_methods: Vec<AuthMethod>,
|
||||
require_verified_email: bool,
|
||||
) -> Self {
|
||||
self.allowed_auth_methods = allowed_methods;
|
||||
self.require_verified_email = require_verified_email;
|
||||
self
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/login` is a supported endpoint on this
|
||||
/// deployment. Composes the OIDC `disable_password_login` legacy
|
||||
/// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist.
|
||||
pub fn is_password_login_allowed(&self) -> bool {
|
||||
!self.password_login_disabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::Password))
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/magic-link/send` should mint tokens for
|
||||
/// end-user login on this deployment.
|
||||
///
|
||||
/// Requires ALL of:
|
||||
/// * repo wired (SMTP configured, tokens can actually be minted);
|
||||
/// * allowlist permits `MagicLink` (or is empty = permissive);
|
||||
/// * OIDC is NOT enabled at the deployment level.
|
||||
///
|
||||
/// The OIDC guard is a hard rule: when OIDC is enabled it is the
|
||||
/// master identity provider — magic-link would bypass any 2FA / step-up
|
||||
/// policy that the IdP enforces. An operator running OIDC + local
|
||||
/// accounts hybrid must NOT expose magic-link login for the local
|
||||
/// accounts either, because a user provisioned via OIDC-JIT could
|
||||
/// receive a magic-link on the same mailbox and sidestep MFA. Admin-
|
||||
/// mediated invites use OIDC or password bootstrap instead.
|
||||
pub fn is_magic_link_login_allowed(&self) -> bool {
|
||||
self.magic_link_enabled()
|
||||
&& !self.oidc_enabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::MagicLink))
|
||||
}
|
||||
|
||||
/// True iff login should reject accounts with `email_verified_at IS
|
||||
/// NULL`. Backed by `OXICLOUD_REQUIRE_VERIFIED_EMAIL`.
|
||||
pub fn require_verified_email(&self) -> bool {
|
||||
self.require_verified_email
|
||||
}
|
||||
|
||||
/// Resolve a login-identifier (username OR email) to the account's
|
||||
/// registered email address. Mirrors the `POST /api/auth/login`
|
||||
/// dispatcher (`@` presence → email lookup, else → username
|
||||
/// lookup). Returns `None` when the identifier doesn't match any
|
||||
/// account — callers that need anti-enumeration semantics MUST
|
||||
/// still return their uniform response after logging the reason.
|
||||
///
|
||||
/// The username namespace forbids `@` (PR 16), so the two paths
|
||||
/// are disjoint — no ambiguity.
|
||||
pub async fn resolve_login_identifier_to_email(&self, identifier: &str) -> Option<String> {
|
||||
if identifier.contains('@') {
|
||||
Some(identifier.to_string())
|
||||
} else {
|
||||
self.user_storage
|
||||
.get_user_by_username(identifier)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| u.email().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct lookup helpers used by handlers that need the full `User`
|
||||
/// entity (not just the email). Mirrors the internal `user_storage`
|
||||
/// calls the service already makes in `login`. Currently used by
|
||||
/// the login handler to auto-mint a verification magic-link after
|
||||
/// a successful password check.
|
||||
pub async fn find_user_by_email(&self, email: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_email(email).await
|
||||
}
|
||||
pub async fn find_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_username(username).await
|
||||
}
|
||||
|
||||
/// Wire the magic-link token repository. Called from the DI factory
|
||||
/// when the magic-link feature is configured. Mirrors the
|
||||
/// `with_oidc` / `with_user_lifecycle` builder pattern.
|
||||
@@ -508,6 +609,13 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// First-run admin is authoritative by definition — they set the
|
||||
// password themselves, at the console, on a fresh install. Mark
|
||||
// verified so `OXICLOUD_REQUIRE_VERIFIED_EMAIL` never locks the
|
||||
// sole account with root-level power out of their own instance.
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||
@@ -527,6 +635,26 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||
// Gate: policy may forbid password logins entirely (either the
|
||||
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
|
||||
// allowlist without `password`). Refuse BEFORE the user lookup
|
||||
// so we don't leak account existence via timing on a disabled
|
||||
// endpoint.
|
||||
if !self.is_password_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "password_login_disabled",
|
||||
attempted_username = %dto.username,
|
||||
"🔐 login rejected: password login disabled by policy",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Dispatch on `@` in the input: presence of `@` means an email
|
||||
// was typed, absence means a username. The two namespaces are
|
||||
// provably disjoint (PR 16 forbids `@` in usernames), so this
|
||||
@@ -612,6 +740,45 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
|
||||
// validation so an attacker with only a username cannot probe
|
||||
// account verification state (the response shape is
|
||||
// `Invalid credentials` for bad passwords regardless of whether
|
||||
// the email is verified — a wrong-password observer learns
|
||||
// nothing).
|
||||
//
|
||||
// ADMIN EXEMPTION: admins are trusted by fiat and predate this
|
||||
// gate. Fresh admin accounts (admin_create_user /
|
||||
// setup_create_admin) are stamped verified at creation; the
|
||||
// exemption covers pre-existing admin accounts installed before
|
||||
// the flag shipped.
|
||||
//
|
||||
// The auto-send of a verification magic-link when this branch
|
||||
// fires is done at the handler layer (login handler triggers
|
||||
// `send_verification_link_authenticated`) rather than here —
|
||||
// the service returns the distinguished error and the handler
|
||||
// orchestrates the side effect. Keeps this method side-effect-
|
||||
// free on the audit path.
|
||||
if self.require_verified_email
|
||||
&& !matches!(user.role(), UserRole::Admin)
|
||||
&& !user.is_email_verified()
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "email_not_verified",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔐 login rejected: email not verified for '{}' (password OK)",
|
||||
user.display_for_audit(),
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Email not verified",
|
||||
));
|
||||
}
|
||||
|
||||
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||
// observing `last_login_at().is_none()` see "first ever login"
|
||||
// correctly. See tip #1 in user_lifecycle.rs.
|
||||
@@ -619,9 +786,14 @@ impl AuthApplicationService {
|
||||
lc.dispatch_login(&user).await;
|
||||
}
|
||||
|
||||
// Update last login
|
||||
// Update last login (in-memory only — the DTO below carries it).
|
||||
// The full-row `update_user` this path used to issue was 100%
|
||||
// redundant: `create_session` stamps `last_login_at`/`updated_at`
|
||||
// in its own transaction right below, and nothing re-reads the row
|
||||
// in between. Dropping it removes one transaction + a 17-column
|
||||
// rewrite (incl. the up-to-512 KiB avatar) per password login
|
||||
// (benches/ROUND12.md §2, 4.45x).
|
||||
user.register_login();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
|
||||
// Generate tokens using the injected token service
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
@@ -689,6 +861,17 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Defense-in-depth: if magic-link login was minted under an older
|
||||
// policy and the operator has since flipped OIDC on (or dropped
|
||||
// `MagicLink` from `OXICLOUD_AUTH_METHODS`), we must not honour
|
||||
// pre-existing login tokens. Invitation tokens (resource_kind =
|
||||
// File / Folder) are checked separately below — they represent
|
||||
// an admin-mediated invite, which is a distinct policy question
|
||||
// from "self-service login via email".
|
||||
//
|
||||
// We do the token lookup FIRST so we can classify by
|
||||
// `resource_kind()` before applying the gate — invitations
|
||||
// survive, plain logins do not.
|
||||
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||
// Audit: unknown / forged magic-link redemption. The first
|
||||
// 8 chars of the bogus token are logged so a recurring
|
||||
@@ -710,6 +893,27 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Enforce the login-magic-link policy on stale tokens.
|
||||
// resource_kind = None means "plain login-via-email"; anything
|
||||
// else is an invite (which follows its own admin-mediated
|
||||
// trust chain). Refuse the login case if the current policy
|
||||
// forbids magic-link login.
|
||||
if mlt.resource_kind().is_none() && !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.redemption_rejected",
|
||||
reason = "login_disabled_by_policy",
|
||||
token_id = %mlt.id(),
|
||||
user_id = %mlt.user_id(),
|
||||
"🔗 magic-link rejected: login-via-email disabled by policy (OIDC-master or allowlist)",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"magic-link login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Friendly early-rejection messages. The atomic `mark_used`
|
||||
// below is the canonical single-use guard.
|
||||
if mlt.status() == MagicLinkStatus::Used {
|
||||
@@ -818,9 +1022,12 @@ impl AuthApplicationService {
|
||||
// PR 23: clicking the magic-link IS proof of email control —
|
||||
// stamp the verification (idempotent, preserves the first
|
||||
// timestamp). Applies to both invitation and login-via-email
|
||||
// tokens.
|
||||
// tokens. Narrow single-column write: `last_login_at` is stamped
|
||||
// by `create_session` below, so the full-row `update_user` this
|
||||
// path used to issue only ever contributed the verification
|
||||
// timestamp (benches/ROUND12.md §3, 8.9x).
|
||||
user.mark_email_verified();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
self.user_storage.mark_email_verified(user.id()).await?;
|
||||
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
@@ -903,9 +1110,9 @@ impl AuthApplicationService {
|
||||
|
||||
Ok(crate::application::dtos::user_dto::CurrentUser {
|
||||
id: user.id(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
username: std::sync::Arc::from(user.username().unwrap_or("")),
|
||||
email: std::sync::Arc::from(user.email()),
|
||||
role: smol_str::SmolStr::new_static(user.role().as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -964,15 +1171,15 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Revoke current session before issuing the next token in the family
|
||||
self.session_storage.revoke_session(session.id()).await?;
|
||||
|
||||
// Generate new tokens
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let new_refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// New session inherits the family_id so reuse of any ancestor triggers
|
||||
// full-family revocation
|
||||
// full-family revocation. Revoking the old session and inserting the
|
||||
// new one happen in ONE transaction (`rotate_session`) — this path
|
||||
// used to pay two BEGIN/COMMIT pairs per refresh, and DAV clients
|
||||
// rotate constantly (benches/ROUND12.md §4).
|
||||
let new_session = Session::new(
|
||||
user.id(),
|
||||
new_refresh_token.clone(),
|
||||
@@ -982,7 +1189,9 @@ impl AuthApplicationService {
|
||||
session.family_id(),
|
||||
);
|
||||
|
||||
self.session_storage.create_session(new_session).await?;
|
||||
self.session_storage
|
||||
.rotate_session(session.id(), new_session)
|
||||
.await?;
|
||||
|
||||
Ok(AuthResponseDto {
|
||||
user: UserDto::from(user),
|
||||
@@ -1039,6 +1248,258 @@ impl AuthApplicationService {
|
||||
Ok(revoked_count)
|
||||
}
|
||||
|
||||
/// External → internal account upgrade.
|
||||
///
|
||||
/// Contract:
|
||||
/// * Caller must be authenticated as the user being upgraded.
|
||||
/// Session-elevation is not required — being logged in as
|
||||
/// yourself IS the proof of intent.
|
||||
/// * User must be `is_external = true` — else the entity refuses
|
||||
/// with `UserError::AlreadyInternal`, surfaced as `error_type =
|
||||
/// "AlreadyInternal"` (409).
|
||||
/// * OIDC-linked users are refused (the IdP owns their identity).
|
||||
/// * If `dto.password` is `None`, the deployment MUST have magic-
|
||||
/// link login enabled — otherwise the upgraded user would have
|
||||
/// no login path. Refused with `error_type = "PasswordRequired"`
|
||||
/// (400) in that case.
|
||||
/// * Domain-allowlist check lives at the HANDLER layer, mirroring
|
||||
/// the register handler — the service doesn't hold that config.
|
||||
///
|
||||
/// On success:
|
||||
/// * User's `is_external` flipped to `false`.
|
||||
/// * `password_hash` set from the provided password (Argon2id) or
|
||||
/// left as-is (magic-link-only upgrade).
|
||||
/// * `storage_quota_bytes` set to the default user quota (capped
|
||||
/// by disk).
|
||||
/// * `PersonalDriveLifecycleHook::on_upgraded_to_internal` runs and
|
||||
/// provisions the home drive + root folder + owner grant via the
|
||||
/// atomic CTE. Failure at this step is logged but the row update
|
||||
/// stands — the next login's `on_user_login` safety-net retries
|
||||
/// provisioning.
|
||||
/// * `user_flags_cache` invalidated eagerly so per-request guards
|
||||
/// (WebDAV / CalDAV / CardDAV) observe the new `is_external`
|
||||
/// within cache-round-trip time, not the 30-second TTL.
|
||||
/// * Audit log emits `event="user.upgraded_to_internal"` via the
|
||||
/// `AuditLifecycleHook` on the dispatched event.
|
||||
pub async fn upgrade_to_internal(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
dto: UpgradeToInternalDto,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
// Precondition: caller is currently external. Fast-path 409 so
|
||||
// the audit log carries a clear reason before the entity's own
|
||||
// guard fires.
|
||||
if !user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "already_internal",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"👮🏻♂️ upgrade refused: user is already internal",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
"Account is already internal",
|
||||
));
|
||||
}
|
||||
|
||||
// OIDC-linked: never. The IdP owns identity and role.
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "oidc_user",
|
||||
user_id = %user.id(),
|
||||
"👮🏻♂️ upgrade refused: OIDC-linked user is managed by the IdP",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"SSO/OIDC accounts are managed by your identity provider",
|
||||
));
|
||||
}
|
||||
|
||||
// Password policy composite:
|
||||
// * Provided → validate + hash.
|
||||
// * Omitted → only accepted when magic-link login is on
|
||||
// for this deployment (otherwise no login path post-upgrade).
|
||||
let password_hash = match dto.password.as_deref() {
|
||||
Some(pw) if !pw.is_empty() => {
|
||||
if pw.len() < 8 {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Password must be at least 8 characters long",
|
||||
));
|
||||
}
|
||||
Some(self.password_hasher.hash_password(pw).await?)
|
||||
}
|
||||
_ => {
|
||||
if !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgrade_rejected",
|
||||
reason = "password_required",
|
||||
user_id = %user.id(),
|
||||
"👮🏻♂️ upgrade refused: password omitted but magic-link login is not available on this deployment",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Password is required — magic-link login is not enabled on this deployment",
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Quota policy: same as a fresh regular-user signup.
|
||||
let quota = self.capped_quota(&UserRole::User);
|
||||
|
||||
user.promote_to_internal(password_hash, quota)
|
||||
.map_err(|e| {
|
||||
// The entity refuses `AlreadyInternal` here belt-and-braces
|
||||
// against a race with a concurrent upgrade; the pre-check
|
||||
// above already covers the intended path.
|
||||
DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
format!("Upgrade refused: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
|
||||
// Invalidate the flags cache so subsequent per-request guards
|
||||
// observe the new `is_external=false` without waiting for the
|
||||
// 30-second TTL. Same pattern as `change_user_role`.
|
||||
self.user_flags_cache.invalidate(&caller_id).await;
|
||||
|
||||
// Dispatch — home-drive provisioning happens here. Log-and-
|
||||
// continue: a provisioning failure leaves the row updated and
|
||||
// the next login's safety-net (`on_user_login`) retries.
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
/// Admin-driven external → internal promotion.
|
||||
///
|
||||
/// Same wire outcome as [`Self::upgrade_to_internal`] but the actor
|
||||
/// is an operator, not the target user. The target's password stays
|
||||
/// as it was (usually `None` — magic-link-only accounts) so the
|
||||
/// deployment MUST have magic-link login enabled, otherwise the
|
||||
/// promoted user has no login path at all.
|
||||
///
|
||||
/// Refuses:
|
||||
/// - Target is already internal → 409 `AlreadyInternal`.
|
||||
/// - Target is OIDC-linked → 403 (IdP owns identity).
|
||||
/// - Magic-link login disabled deployment-wide → 400 with a hint.
|
||||
///
|
||||
/// On success:
|
||||
/// - `is_external → false`, `storage_quota_bytes → capped default`.
|
||||
/// - Home-drive provisioning fires via
|
||||
/// `PersonalDriveLifecycleHook::on_upgraded_to_internal` — same
|
||||
/// hook the self-upgrade path uses.
|
||||
/// - `user_flags_cache` invalidated on the target so per-request
|
||||
/// guards observe the new flag within one cache round-trip.
|
||||
/// - Audit line `event = "user.promoted_to_internal_by_admin"`
|
||||
/// with `by = <admin_id>`, `target_id = <user_id>`.
|
||||
pub async fn admin_promote_external_to_internal(
|
||||
&self,
|
||||
admin_id: Uuid,
|
||||
target_id: Uuid,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let mut user = self.user_storage.get_user_by_id(target_id).await?;
|
||||
|
||||
if !user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "already_internal",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: target user is already internal",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
"Account is already internal",
|
||||
));
|
||||
}
|
||||
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "oidc_user",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: OIDC-linked user is managed by the IdP",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"User",
|
||||
"SSO/OIDC accounts are managed by your identity provider",
|
||||
));
|
||||
}
|
||||
|
||||
// Admin can't set a password on the target's behalf, so the
|
||||
// upgraded account MUST have magic-link login available on the
|
||||
// deployment — otherwise no login path exists post-promotion.
|
||||
if !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promote_rejected",
|
||||
reason = "no_login_path",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ admin-promote refused: magic-link login disabled and admin can't set the target's password",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"Cannot promote: magic-link login is disabled on this deployment, so the user would have no login path.",
|
||||
));
|
||||
}
|
||||
|
||||
let quota = self.capped_quota(&UserRole::User);
|
||||
|
||||
user.promote_to_internal(None, quota).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"User",
|
||||
format!("Promote refused: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
|
||||
// Invalidate the target's flags cache — same reason as the
|
||||
// self-upgrade path.
|
||||
self.user_flags_cache.invalidate(&target_id).await;
|
||||
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_upgraded_to_internal(&updated).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.promoted_to_internal_by_admin",
|
||||
by = %admin_id,
|
||||
target_id = %target_id,
|
||||
"👮🏻♂️ external user promoted to internal by admin",
|
||||
);
|
||||
|
||||
Ok(UserDto::from(updated))
|
||||
}
|
||||
|
||||
pub async fn change_password(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -1172,12 +1633,20 @@ impl AuthApplicationService {
|
||||
/// Staleness is bounded by [`USER_FLAGS_CACHE_TTL`]; role and active
|
||||
/// changes made through this service invalidate the entry eagerly.
|
||||
pub async fn get_user_flags(&self, user_id: Uuid) -> Result<UserFlags, DomainError> {
|
||||
if let Some(flags) = self.user_flags_cache.get(&user_id) {
|
||||
return Ok(flags);
|
||||
}
|
||||
let flags = self.user_storage.get_user_flags(user_id).await?;
|
||||
self.user_flags_cache.insert(user_id, flags);
|
||||
Ok(flags)
|
||||
// Single-flight: concurrent misses for the same user coalesce
|
||||
// into ONE storage lookup; errors are never cached (same herd
|
||||
// shape ROUND3 fixed for basic-auth, minus the Argon2 cost).
|
||||
self.user_flags_cache
|
||||
.try_get_with(user_id, async {
|
||||
Ok::<_, DomainError>(self.user_storage.get_user_flags(user_id).await?)
|
||||
})
|
||||
.await
|
||||
// try_get_with hands back `Arc<DomainError>` shared by all
|
||||
// waiters; DomainError isn't Clone, so rebuild a fresh one
|
||||
// preserving the kind / entity / message.
|
||||
.map_err(|shared: std::sync::Arc<DomainError>| {
|
||||
DomainError::new(shared.kind, shared.entity_type, shared.message.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a profile update on behalf of the calling user (PR 24).
|
||||
@@ -1348,12 +1817,51 @@ impl AuthApplicationService {
|
||||
changed.push("notify_on_share");
|
||||
}
|
||||
|
||||
if changed.is_empty() {
|
||||
// ── UI preferences shallow-merge ──────────────────────────
|
||||
// The other fields above modify the in-memory `user` and land
|
||||
// via `update_user(user)` at the end. UI preferences take a
|
||||
// different path because the merge has to happen at write
|
||||
// time in SQL — two devices PATCH'ing partial patches
|
||||
// concurrently would otherwise race and clobber each other if
|
||||
// we did merge-then-write in application code. See
|
||||
// `UserPgRepository::update_ui_preferences` for the SQL.
|
||||
//
|
||||
// Boundary validation only: shape must be a JSON object.
|
||||
// Contents are opaque to the server — no key inspection here.
|
||||
// Size cap is enforced by the schema CHECK constraint; a
|
||||
// violating merge surfaces as a repo error.
|
||||
let ui_prefs_patch = if let Some(patch) = dto.ui_preferences.as_ref() {
|
||||
if !patch.is_object() {
|
||||
return Err(DomainError::validation_error(
|
||||
"ui_preferences must be a JSON object".to_string(),
|
||||
));
|
||||
}
|
||||
Some(patch.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if changed.is_empty() && ui_prefs_patch.is_none() {
|
||||
// No-op — return the current user without a DB write.
|
||||
return Ok(UserDto::from(user));
|
||||
}
|
||||
|
||||
let updated = self.user_storage.update_user(user).await?;
|
||||
// Persist the typed-field changes first (if any). Skip the
|
||||
// `update_user` call entirely when only `ui_preferences`
|
||||
// changed — the shallow-merge SQL below is authoritative for
|
||||
// that field, and running `update_user` unnecessarily would
|
||||
// rewrite every column with its current in-memory value.
|
||||
if !changed.is_empty() {
|
||||
self.user_storage.update_user(user).await?;
|
||||
}
|
||||
|
||||
if let Some(patch) = ui_prefs_patch {
|
||||
self.user_storage
|
||||
.update_ui_preferences(caller_id, &patch)
|
||||
.await?;
|
||||
changed.push("ui_preferences");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.profile_updated",
|
||||
@@ -1362,7 +1870,11 @@ impl AuthApplicationService {
|
||||
"👤 profile updated for {}",
|
||||
caller_id,
|
||||
);
|
||||
Ok(UserDto::from(updated))
|
||||
|
||||
// Refetch so the returned DTO reflects the merged JSONB bag
|
||||
// (the in-memory `user` above holds the pre-merge value).
|
||||
let refreshed = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
Ok(UserDto::from(refreshed))
|
||||
}
|
||||
|
||||
// Alias for consistency with handler method
|
||||
@@ -1420,17 +1932,28 @@ impl AuthApplicationService {
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
|
||||
// (1) Self.
|
||||
// (1) Self — a single fetch suffices (the check compares the input
|
||||
// UUIDs, so the target read is never needed on this path).
|
||||
if caller_id == target_id {
|
||||
let caller = self.user_storage.get_user_by_id(caller_id).await?;
|
||||
return Ok(UserDto::from(caller));
|
||||
}
|
||||
|
||||
// Caller and target are independent point reads (the self-case already
|
||||
// returned; the branch above compares input UUIDs, not fetched data) —
|
||||
// overlap them with `join!` instead of two serial round-trips.
|
||||
// `caller_res?` first preserves the caller-error precedence of the old
|
||||
// sequential form. (benches/ROUND23.md §P1)
|
||||
let (caller_res, target_res) = tokio::join!(
|
||||
self.user_storage.get_user_by_id(caller_id),
|
||||
self.user_storage.get_user_by_id(target_id)
|
||||
);
|
||||
let caller = caller_res?;
|
||||
|
||||
// Anti-enumeration: NotFound for everything that doesn't pass.
|
||||
// Convert a real NotFound on `target` to the same anonymous 404,
|
||||
// so existence isn't leaked through differential responses.
|
||||
let target = match self.user_storage.get_user_by_id(target_id).await {
|
||||
let target = match target_res {
|
||||
Ok(u) => u,
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
tracing::info!(
|
||||
@@ -1533,6 +2056,59 @@ impl AuthApplicationService {
|
||||
))
|
||||
}
|
||||
|
||||
/// Username-keyed sibling of [`Self::get_user_profile`], routing every
|
||||
/// lookup through the same visibility check as the user-profile REST
|
||||
/// endpoint. Preserves the anti-enum shape end-to-end: whether the
|
||||
/// username doesn't exist OR the caller has no visibility path, the
|
||||
/// response is `NotFound`.
|
||||
///
|
||||
/// AuthZ audit #11 (2026-07-12): NextCloud OCS user-provisioning
|
||||
/// (`nextcloud/ocs_handler.rs::user_provisioning_response`) used to
|
||||
/// resolve `userid` via bare `get_user_by_username`, gated only by a
|
||||
/// bespoke `caller.role == "admin"` shortcut. Admins bypassed the
|
||||
/// `expose_system_users` gate; non-admins got a `403 Insufficient
|
||||
/// privileges` for any cross-user probe (leaking existence via the
|
||||
/// differential vs a genuine 404); zero audit lines. This wrapper
|
||||
/// closes all three.
|
||||
///
|
||||
/// The username→id resolution happens here so the target isn't
|
||||
/// leaked through the audit line as a plaintext username on failure:
|
||||
/// the `target_username_not_found` event carries the string
|
||||
/// (unavoidable — we resolved it, we log it), but every other
|
||||
/// downstream event keys off `target_id` after resolution, matching
|
||||
/// the id-based endpoint.
|
||||
pub async fn get_user_profile_by_username_with_perms(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
username: &str,
|
||||
expose_system_users: bool,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<UserDto, DomainError> {
|
||||
let target = match self.user_storage.get_user_by_username(username).await {
|
||||
Ok(u) => u,
|
||||
Err(e) if e.kind == ErrorKind::NotFound => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user_profile.rejected",
|
||||
reason = "target_username_not_found",
|
||||
caller_id = %caller_id,
|
||||
target_username = %username,
|
||||
"👮🏻♂️ user-profile rejected: username '{}' does not exist (caller {})",
|
||||
username,
|
||||
caller_id,
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"User",
|
||||
"User not found",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
self.get_user_profile(caller_id, target.id(), expose_system_users, pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// New method to get user by username - needed for admin user handling
|
||||
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
|
||||
let user = self.user_storage.get_user_by_username(username).await?;
|
||||
@@ -1542,21 +2118,11 @@ impl AuthApplicationService {
|
||||
// Method to count how many admin users exist in the system
|
||||
// Used to determine if we have multiple admins or just the default one
|
||||
pub async fn count_admin_users(&self) -> Result<i64, DomainError> {
|
||||
// Use the list_users_by_role method or similar from user_storage port
|
||||
// For now, we'll use a basic implementation that counts all users with role = "admin"
|
||||
let admin_users = self
|
||||
.user_storage
|
||||
.list_users_by_role("admin")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"User",
|
||||
format!("Error counting admin users: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(admin_users.len() as i64)
|
||||
// Scalar COUNT(*) — the old form fetched every admin's FULL row (incl.
|
||||
// the up-to-512 KiB avatar `image` + `ui_preferences` JSONB) only to
|
||||
// call `.len()`, on a status/init endpoint that is polled at bootstrap
|
||||
// (benches/ROUND29.md §G).
|
||||
self.user_storage.count_users_by_role("admin").await
|
||||
}
|
||||
|
||||
/// Lists internal users only. External (grant-only) users are filtered
|
||||
@@ -1586,6 +2152,24 @@ impl AuthApplicationService {
|
||||
Ok(users.into_iter().map(UserDto::from).collect())
|
||||
}
|
||||
|
||||
/// Username-only search for the NC sharee autocomplete: identical
|
||||
/// predicate / order / limit to [`search_users`], but the repository
|
||||
/// projects just `username` — no 21-column hydration (incl. the
|
||||
/// up-to-512 KiB avatar `image`) per matched row, per keystroke
|
||||
/// (benches/ROUND12.md §1). NULL usernames (email-only signups) are
|
||||
/// filtered app-side, exactly like the wide flow's post-limit filter.
|
||||
pub async fn search_sharee_usernames(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<String>, DomainError> {
|
||||
let names = self
|
||||
.user_storage
|
||||
.search_usernames(query, limit, false)
|
||||
.await?;
|
||||
Ok(names.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Admin User Management Methods
|
||||
// ========================================================================
|
||||
@@ -1717,6 +2301,16 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Admin fiat counts as verification. When
|
||||
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set, admin-created users
|
||||
// still get to log in without a magic-link round-trip — the
|
||||
// operator explicitly vouched for the address at creation. This
|
||||
// mirrors the OIDC-JIT convention (see `redeem_pending_oidc_token`
|
||||
// and `login_oidc_callback` which also stamp
|
||||
// `email_verified_at` on first sight).
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
// Persist
|
||||
let created = self.user_storage.create_user(user).await?;
|
||||
|
||||
@@ -1837,11 +2431,17 @@ impl AuthApplicationService {
|
||||
self.user_storage
|
||||
.set_user_active_status(user_id, active)
|
||||
.await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change user role (admin only)
|
||||
/// Change user role (admin only).
|
||||
///
|
||||
/// Refuses `role = "admin"` when the target is external (grant-only).
|
||||
/// The DB CHECK `users_external_not_admin` would also refuse this at
|
||||
/// COMMIT, but surfacing it here yields a clean `InvalidInput` error
|
||||
/// with an audit line naming the reason, instead of a bare
|
||||
/// constraint-violation stringified out of Postgres.
|
||||
pub async fn change_user_role(&self, user_id: Uuid, role: &str) -> Result<(), DomainError> {
|
||||
if role != "admin" && role != "user" {
|
||||
return Err(DomainError::new(
|
||||
@@ -1850,8 +2450,27 @@ impl AuthApplicationService {
|
||||
format!("Invalid role: {}. Must be 'admin' or 'user'", role),
|
||||
));
|
||||
}
|
||||
|
||||
if role == "admin" {
|
||||
let target = self.user_storage.get_user_by_id(user_id).await?;
|
||||
if target.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.role_change_rejected",
|
||||
reason = "external_cannot_be_admin",
|
||||
target_id = %user_id,
|
||||
"👮🏻♂️ role change refused: external users cannot hold the admin role",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"External accounts cannot hold the admin role. Promote the user to internal first.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.user_storage.change_role(user_id, role).await?;
|
||||
self.user_flags_cache.invalidate(&user_id);
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2153,6 +2772,15 @@ impl AuthApplicationService {
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_login(&existing_user).await;
|
||||
}
|
||||
// Decide BEFORE mutating: the row just fetched already
|
||||
// carries the stored avatar + verification stamp, so the
|
||||
// repeat-login common case (same IdP picture, already
|
||||
// verified) skips the DB entirely — the old shape rewrote
|
||||
// all 17 columns per login, and even a guarded UPDATE
|
||||
// would ship the avatar over the wire just to compare it
|
||||
// (benches/ROUND12.md §3b).
|
||||
let needs_profile_sync = existing_user.email_verified_at().is_none()
|
||||
|| existing_user.image() != claims.picture.as_deref();
|
||||
existing_user.register_login();
|
||||
existing_user.set_image(claims.picture.clone());
|
||||
// PR 23: retroactive email verification for OIDC users
|
||||
@@ -2161,7 +2789,16 @@ impl AuthApplicationService {
|
||||
// any user reaching this branch has a verified email
|
||||
// by the IdP's word; stamping is safe and idempotent.
|
||||
existing_user.mark_email_verified();
|
||||
self.user_storage.update_user(existing_user.clone()).await?;
|
||||
// Narrow guarded sync instead of the 17-column row rewrite:
|
||||
// persists the IdP avatar + the verification stamp only
|
||||
// when either actually changed; `last_login_at` is stamped
|
||||
// by `create_session` at the end of this flow
|
||||
// (benches/ROUND12.md §3).
|
||||
if needs_profile_sync {
|
||||
self.user_storage
|
||||
.sync_oidc_login_profile(existing_user.id(), claims.picture.as_deref())
|
||||
.await?;
|
||||
}
|
||||
existing_user
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
@@ -726,25 +726,46 @@ impl BatchOperationService {
|
||||
let mut items_added: usize = 0;
|
||||
|
||||
// ── Add individual files at the root of the ZIP ──────────────────
|
||||
// Authorize + fetch metadata for the whole multi-select in 2 round-trips
|
||||
// (one batch Read check + one batch get) instead of the per-file
|
||||
// `get_file_with_perms` N+1 (2 round-trips/file). The batch check also
|
||||
// primes the resource→drive cache, so `add_file_entry_streamed`'s
|
||||
// per-file stream-open re-check lands on the cache. A denied / missing /
|
||||
// unparseable id is absent from the map → skipped in the same input
|
||||
// order, exactly as the old per-file loop skipped it. Authorization is
|
||||
// UNCHANGED — still enforced (pre-check here + the stream open's own
|
||||
// Read check + Recents recording) before any ZIP entry is written, so a
|
||||
// denied file never leaks its name into the archive (benches/ROUND24.md).
|
||||
let authorized = self
|
||||
.file_retrieval
|
||||
.get_files_by_ids_with_perms(&file_ids, user_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?;
|
||||
let by_id: HashMap<Uuid, FileDto> = authorized
|
||||
.into_iter()
|
||||
.filter_map(|f| Uuid::parse_str(&f.id).ok().map(|u| (u, f)))
|
||||
.collect();
|
||||
for file_id in &file_ids {
|
||||
let file_dto = match Uuid::parse_str(file_id).ok().and_then(|u| by_id.get(&u)) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
info!("Skipping file {} (not accessible or missing)", file_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self
|
||||
.file_retrieval
|
||||
.get_file_with_perms(file_id, user_id)
|
||||
.add_file_entry_streamed(
|
||||
&mut zip,
|
||||
file_id,
|
||||
&file_dto.name,
|
||||
&file_dto.mime_type,
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(file_dto) => {
|
||||
match self
|
||||
.add_file_entry_streamed(&mut zip, file_id, &file_dto.name, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => items_added += 1,
|
||||
Err(e) => {
|
||||
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => items_added += 1,
|
||||
Err(e) => {
|
||||
info!("Could not get file metadata {}: {}", file_id, e);
|
||||
info!("Could not add file {} to ZIP: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,7 +779,7 @@ impl BatchOperationService {
|
||||
{
|
||||
Ok(root_folder) => {
|
||||
match self
|
||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder, user_id)
|
||||
.add_folder_subtree_to_zip(&mut zip, folder_id, &root_folder)
|
||||
.await
|
||||
{
|
||||
Ok(_) => items_added += 1,
|
||||
@@ -803,24 +824,44 @@ impl BatchOperationService {
|
||||
}
|
||||
|
||||
/// Streams a single file into an async ZIP entry (~64 KB peak RAM per file).
|
||||
///
|
||||
/// Already-compressed content (per its MIME type) is `Stored` — deflating
|
||||
/// JPEG/MP4/… burns ~a CPU core per download for ~0 % size gain.
|
||||
///
|
||||
/// `caller_id = Some(uid)` enforces the per-file Read check and records
|
||||
/// the access in Recents (explicitly-selected top-level files).
|
||||
/// `None` = the file was enumerated from a folder subtree whose ROOT the
|
||||
/// caller already passed `get_folder_with_perms` for — per-file
|
||||
/// re-authorization and per-file Recent spam (2 writes/file via the
|
||||
/// recent hook) are skipped, mirroring `ZipService::create_folder_zip`
|
||||
/// on the native folder-download path (benches/ZIP-BATCH-AUTHZ.md).
|
||||
async fn add_file_entry_streamed(
|
||||
&self,
|
||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||
file_id: &str,
|
||||
entry_name: &str,
|
||||
caller_id: Uuid,
|
||||
mime_type: &str,
|
||||
caller_id: Option<Uuid>,
|
||||
) -> Result<(), BatchOperationError> {
|
||||
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), Compression::Deflate);
|
||||
let compression = crate::common::mime_detect::zip_entry_compression(mime_type);
|
||||
let entry = ZipEntryBuilder::new(entry_name.to_string().into(), compression);
|
||||
let mut writer = zip
|
||||
.write_entry_stream(entry)
|
||||
.await
|
||||
.map_err(|e| BatchOperationError::Internal(format!("zip entry start: {}", e)))?;
|
||||
|
||||
let stream = self
|
||||
.file_retrieval
|
||||
.get_file_stream_with_perms(file_id, caller_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?;
|
||||
let stream = match caller_id {
|
||||
Some(uid) => self
|
||||
.file_retrieval
|
||||
.get_file_stream_with_perms(file_id, uid)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?,
|
||||
None => self
|
||||
.file_retrieval
|
||||
.get_file_stream(file_id)
|
||||
.await
|
||||
.map_err(BatchOperationError::Domain)?,
|
||||
};
|
||||
let mut stream = std::pin::Pin::from(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
@@ -849,7 +890,6 @@ impl BatchOperationService {
|
||||
zip: &mut ZipFileWriter<tokio_util::compat::Compat<BufWriter<tokio::fs::File>>>,
|
||||
folder_id: &str,
|
||||
root_folder: &FolderDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), BatchOperationError> {
|
||||
// Bulk-fetch folder tree (small — one entry per folder)
|
||||
let all_folders = self
|
||||
@@ -903,8 +943,10 @@ impl BatchOperationService {
|
||||
if let Some(files) = files_by_folder.get(&folder.id) {
|
||||
for file in files {
|
||||
let file_path = format!("{}{}", zip_dir, file.name);
|
||||
// Subtree pre-authorized at the root folder — see
|
||||
// `add_file_entry_streamed` docs for why `None`.
|
||||
if let Err(e) = self
|
||||
.add_file_entry_streamed(zip, &file.id, &file_path, caller_id)
|
||||
.add_file_entry_streamed(zip, &file.id, &file_path, &file.mime_type, None)
|
||||
.await
|
||||
{
|
||||
info!("Could not add file {} to ZIP: {}", file.name, e);
|
||||
|
||||
@@ -10,6 +10,7 @@ mod tests {
|
||||
use crate::application::services::batch_operations::{
|
||||
BatchOperationService, BatchResult, BatchStats,
|
||||
};
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::file_management_service::FileManagementService;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
@@ -105,7 +106,12 @@ mod tests {
|
||||
crate::application::services::mount_registry::MountRegistry::empty(),
|
||||
)),
|
||||
);
|
||||
let folder_service = Arc::new(FolderService::new(folder_repo, authz, mount_router));
|
||||
let folder_service = Arc::new(FolderService::new(
|
||||
folder_repo,
|
||||
authz,
|
||||
Arc::new(FileLifecycleService::new()),
|
||||
mount_router,
|
||||
));
|
||||
|
||||
let _batch_service = BatchOperationService::new(
|
||||
file_retrieval,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -6,17 +7,82 @@ use crate::application::dtos::calendar_dto::{
|
||||
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
|
||||
UpdateCalendarDto, UpdateEventDto,
|
||||
};
|
||||
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::calendar_ports::{
|
||||
CalendarStoragePort, CalendarUseCase, UpsertEventsResult,
|
||||
};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Calendar service — the CalDAV / REST entry point for every calendar
|
||||
/// or event operation. Every method routes through `AuthorizationEngine`;
|
||||
/// the pre-Round-3 `check_calendar_access` bespoke helper is gone.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='calendar'`). `caldav.calendars.owner_id` stays for
|
||||
/// provenance and legacy queries but is no longer consulted for access
|
||||
/// decisions.
|
||||
pub struct CalendarService {
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_calendar` also
|
||||
/// uses it to seed an Owner grant for the caller so the common
|
||||
/// "owning my own calendar" case takes a single indexed
|
||||
/// role_grants lookup.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl CalendarService {
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>) -> Self {
|
||||
Self { calendar_storage }
|
||||
pub fn new(calendar_storage: Arc<CalendarStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `calendar_id` and enforce `permission` on `Resource::Calendar(uuid)`.
|
||||
/// On denial `authz.require` returns `NotFound` (anti-enum — same
|
||||
/// shape as "no such calendar") and emits the `authz.denied` audit
|
||||
/// line. Returns the parsed UUID on success so the caller doesn't
|
||||
/// have to parse it a second time.
|
||||
async fn require_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a calendar without throwing. Used by the
|
||||
/// read paths that also allow a public-calendar bypass — they need
|
||||
/// a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_calendar_perm(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(calendar_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Calendar", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Calendar(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +92,30 @@ impl CalendarUseCase for CalendarService {
|
||||
calendar: CreateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
self.calendar_storage
|
||||
// No pre-write gate: creating a calendar is a personal act
|
||||
// (like creating a folder in your own drive). Storage stamps
|
||||
// `owner_id = user_id`; we then seed an Owner role_grant so
|
||||
// the engine's cache warms on first-read.
|
||||
let created = self
|
||||
.calendar_storage
|
||||
.create_calendar(calendar, user_id)
|
||||
.await
|
||||
.await?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Calendar", "storage returned invalid calendar id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key — a re-run (rare — only if storage retried) is a no-op.
|
||||
// `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_calendar(
|
||||
@@ -37,35 +124,28 @@ impl CalendarUseCase for CalendarService {
|
||||
update: UpdateCalendarDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
self.require_calendar_perm(calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.update_calendar(calendar_id, update)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_calendar(&self, calendar_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
let uuid = self
|
||||
.require_calendar_perm(calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_calendar(calendar_id).await
|
||||
self.calendar_storage.delete_calendar(calendar_id).await?;
|
||||
// Wipe every grant on this calendar so a re-used UUID (impossible
|
||||
// today but cheap to defend against) doesn't inherit stale ACLs.
|
||||
// The storage DELETE won't cascade to `storage.role_grants` — the
|
||||
// legacy `caldav.calendar_shares` had an FK, `role_grants`
|
||||
// doesn't (it's cross-schema).
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Calendar(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_calendar(
|
||||
@@ -74,28 +154,48 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarDto, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view this calendar",
|
||||
));
|
||||
// Public-calendar bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(calendar)
|
||||
}
|
||||
|
||||
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage.list_calendars_by_owner(user_id).await
|
||||
}
|
||||
// Post-Round-3 semantics: every calendar the caller has any
|
||||
// grant on — owned + shared, one union. The pre-Round-3
|
||||
// `list_calendars_by_owner` returned owner-only; shared
|
||||
// calendars never surfaced through this method. See
|
||||
// `docs/plan/caldav-carddav-migration-to-authz.md`.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
self.calendar_storage
|
||||
.list_calendars_shared_with_user(user_id)
|
||||
.await
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// calendar (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let calendar_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Calendar(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Hydrate DTOs in ONE `= ANY` round-trip (was one point SELECT
|
||||
// per accessible calendar — K serial round-trips on every
|
||||
// CalDAV discovery poll). Missing rows (deleted/trashed race)
|
||||
// drop out of the result set instead of erroring, so a
|
||||
// lifecycle-race still doesn't turn a PROPFIND into a 5xx.
|
||||
let ids: Vec<Uuid> = calendar_ids.into_iter().collect();
|
||||
self.calendar_storage.get_calendars_by_ids(&ids).await
|
||||
}
|
||||
|
||||
async fn list_public_calendars(
|
||||
@@ -103,6 +203,8 @@ impl CalendarUseCase for CalendarService {
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<CalendarDto>, DomainError> {
|
||||
// No caller gate: public listing by definition. Storage
|
||||
// filters on `is_public = true`.
|
||||
let limit = limit.unwrap_or(100);
|
||||
let offset = offset.unwrap_or(0);
|
||||
self.calendar_storage
|
||||
@@ -110,90 +212,13 @@ impl CalendarUseCase for CalendarService {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn share_calendar(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
access_level: &str,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
match access_level {
|
||||
"read" | "write" | "owner" => {}
|
||||
_ => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Calendar",
|
||||
format!(
|
||||
"Invalid access level: {}. Valid values are: read, write, owner",
|
||||
access_level
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.calendar_storage
|
||||
.share_calendar(calendar_id, target_user_id, access_level)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn remove_calendar_sharing(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
target_user_id: Uuid,
|
||||
caller_user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != caller_user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can change sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage
|
||||
.remove_calendar_sharing(calendar_id, target_user_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_calendar_shares(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<(String, String)>, DomainError> {
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if calendar.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"Only the calendar owner can view sharing settings",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.get_calendar_shares(calendar_id).await
|
||||
}
|
||||
|
||||
async fn create_event(
|
||||
&self,
|
||||
event: CreateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event(event).await
|
||||
}
|
||||
|
||||
@@ -202,54 +227,50 @@ impl CalendarUseCase for CalendarService {
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to add events to this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.create_event_from_ical(event).await
|
||||
}
|
||||
|
||||
async fn upsert_ical_events(
|
||||
&self,
|
||||
event: CreateEventICalDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<UpsertEventsResult, DomainError> {
|
||||
// Same gate as create_event_from_ical — a PUT to the collection
|
||||
// is a write. `Permission::Create` matches the single-event
|
||||
// path; per-instance exception updates ride on the same
|
||||
// permission because from the ACL's perspective it's still
|
||||
// a write to the calendar.
|
||||
self.require_calendar_perm(&event.calendar_id, user_id, Permission::Create)
|
||||
.await?;
|
||||
self.calendar_storage.upsert_ical_events(event).await
|
||||
}
|
||||
|
||||
async fn update_event(
|
||||
&self,
|
||||
event_id: &str,
|
||||
update: UpdateEventDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
// Only the owning calendar id is needed for the gate — skip the
|
||||
// full event hydration (`ical_data` can run to tens of KB).
|
||||
let calendar_id = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.calendar_id_for_event(event_id)
|
||||
.await?;
|
||||
self.require_calendar_perm(&calendar_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to update events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.update_event(event_id, update).await
|
||||
}
|
||||
|
||||
async fn delete_event(&self, event_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
let calendar_id = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.calendar_id_for_event(event_id)
|
||||
.await?;
|
||||
self.require_calendar_perm(&calendar_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to delete events in this calendar",
|
||||
));
|
||||
}
|
||||
self.calendar_storage.delete_event(event_id).await
|
||||
}
|
||||
|
||||
@@ -259,20 +280,17 @@ impl CalendarUseCase for CalendarService {
|
||||
user_id: Uuid,
|
||||
) -> Result<CalendarEventDto, DomainError> {
|
||||
let event = self.calendar_storage.get_event(event_id).await?;
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(&event.calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self
|
||||
.calendar_storage
|
||||
.get_calendar(&event.calendar_id)
|
||||
.await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
// Same public-calendar bypass as `get_calendar`.
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(&event.calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Event", event_id));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
@@ -283,17 +301,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uid: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.find_event_by_ical_uid(calendar_id, ical_uid)
|
||||
@@ -306,17 +320,13 @@ impl CalendarUseCase for CalendarService {
|
||||
ical_uids: &[String],
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if ical_uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -333,17 +343,13 @@ impl CalendarUseCase for CalendarService {
|
||||
offset: Option<i64>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
if limit.is_some() || offset.is_some() {
|
||||
let limit = limit.unwrap_or(100);
|
||||
@@ -358,6 +364,28 @@ impl CalendarUseCase for CalendarService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_events_uid_order(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<CalendarEventDto, DomainError>>,
|
||||
DomainError,
|
||||
> {
|
||||
// Same Read gate as `list_events`, checked ONCE before the
|
||||
// cursor opens — the stream itself carries no further authz
|
||||
// (single request, same caller, same resource).
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
Ok(self.calendar_storage.stream_events_uid_order(calendar_id))
|
||||
}
|
||||
|
||||
async fn get_events_in_range(
|
||||
&self,
|
||||
calendar_id: &str,
|
||||
@@ -365,20 +393,188 @@ impl CalendarUseCase for CalendarService {
|
||||
end: DateTime<Utc>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<CalendarEventDto>, DomainError> {
|
||||
let has_access = self
|
||||
.calendar_storage
|
||||
.check_calendar_access(calendar_id, user_id)
|
||||
.await?;
|
||||
let calendar = self.calendar_storage.get_calendar(calendar_id).await?;
|
||||
if !has_access && !calendar.is_public {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Calendar",
|
||||
"You don't have permission to view events in this calendar",
|
||||
));
|
||||
let allowed = calendar.is_public
|
||||
|| self
|
||||
.has_calendar_perm(calendar_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Calendar", calendar_id));
|
||||
}
|
||||
self.calendar_storage
|
||||
.get_events_in_time_range(calendar_id, &start, &end)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DefaultCalendarLifecycleHook
|
||||
//
|
||||
// Ensures every internal user has at least one owned calendar so CalDAV
|
||||
// clients (Thunderbird, Apple Calendar, DAVx⁵, Gnome Calendar) succeed at
|
||||
// their PROPFIND-based calendar discovery on first connect. Without this,
|
||||
// a fresh user's calendar home collection is empty and every mainstream
|
||||
// client returns "no calendars found" rather than offering to create one
|
||||
// (see AtalayaLabs/OxiCloud#545).
|
||||
//
|
||||
// Idempotency: keyed on "user owns at least one calendar" via
|
||||
// `list_calendars_by_owner`. If the user has any owned calendar — whether
|
||||
// auto-provisioned by an earlier run, manually created by the user, or
|
||||
// migrated in from another source — the hook skips. A user who deletes
|
||||
// their only calendar gets a fresh default on next login (Nextcloud-style
|
||||
// safety-net), matching `PersonalDriveLifecycleHook`. If they don't want
|
||||
// a default, they're free to leave one they never open — it's an entry
|
||||
// in a list, not a bill.
|
||||
//
|
||||
// Skips `is_external = true`. External users don't own resources; they
|
||||
// only receive shares. When an external is later upgraded to internal via
|
||||
// `POST /api/auth/upgrade-to-internal`, `on_upgraded_to_internal` fires
|
||||
// and provisions the default at that point.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||
use crate::domain::entities::user::User;
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct DefaultCalendarLifecycleHook {
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
/// Concrete engine — same reasoning as `PersonalDriveLifecycleHook`:
|
||||
/// `AuthorizationEngine` isn't dyn-compatible (native async-fn-in-
|
||||
/// trait), so we hold the concrete `PgAclEngine`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Display name for the default calendar. Matches the Nextcloud
|
||||
/// convention so switching users don't notice the difference.
|
||||
/// Not user-visible-only — CalDAV clients render this string.
|
||||
default_name: String,
|
||||
}
|
||||
|
||||
impl DefaultCalendarLifecycleHook {
|
||||
pub fn new(
|
||||
calendar_storage: Arc<CalendarStorageAdapter>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
calendar_storage,
|
||||
authorization,
|
||||
// "Personal" mirrors the Nextcloud default. Kept as a
|
||||
// struct field so a future `OXICLOUD_DEFAULT_CALENDAR_NAME`
|
||||
// env var can override without touching the hook body.
|
||||
default_name: "Personal".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotent provisioning. Shared by `on_user_created`,
|
||||
/// `on_user_login` (safety-net for pre-existing users), and
|
||||
/// `on_upgraded_to_internal` (external → internal promotion).
|
||||
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
|
||||
if user.is_external() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ownership-based idempotency check (see hook docstring for
|
||||
// the design rationale). Whether the existing calendar was
|
||||
// auto-provisioned by a prior run, manually created by the
|
||||
// user, or migrated in, we respect it and skip. `EXISTS`
|
||||
// short-circuits at the first owned row instead of hydrating them
|
||||
// all just to test emptiness — this runs on EVERY login
|
||||
// (benches/ROUND13.md §Q2).
|
||||
let has_calendar = self
|
||||
.calendar_storage
|
||||
.has_owned_calendar(user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"DefaultCalendarHook",
|
||||
format!("has_owned_calendar: {e}"),
|
||||
)
|
||||
})?;
|
||||
if has_calendar {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Provision. Two writes: calendar row + Owner role_grant. The
|
||||
// Owner grant makes the CalDAV engine's grant lookup on first
|
||||
// read a cache hit, matching the pattern in
|
||||
// `CalendarService::create_calendar`.
|
||||
let dto = CreateCalendarDto {
|
||||
name: self.default_name.clone(),
|
||||
description: None,
|
||||
color: None,
|
||||
is_public: Some(false),
|
||||
};
|
||||
let created = self
|
||||
.calendar_storage
|
||||
.create_calendar(dto, user.id())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("DefaultCalendarHook", format!("create_calendar: {e}"))
|
||||
})?;
|
||||
let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error(
|
||||
"DefaultCalendarHook",
|
||||
"storage returned invalid calendar id",
|
||||
)
|
||||
})?;
|
||||
self.authorization
|
||||
.set_role(
|
||||
user.id(),
|
||||
Subject::User(user.id()),
|
||||
Role::Owner,
|
||||
Resource::Calendar(calendar_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
target: "user_lifecycle",
|
||||
hook = "default_calendar",
|
||||
user_id = %user.id(),
|
||||
calendar_id = %calendar_uuid,
|
||||
"Default calendar provisioned"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLifecycleHook for DefaultCalendarLifecycleHook {
|
||||
fn name(&self) -> &'static str {
|
||||
"default_calendar"
|
||||
}
|
||||
|
||||
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// Safety-net: fires on every login, provisions if the user has no
|
||||
/// owned calendar. This is what fixes pre-existing users after the
|
||||
/// hook ships — no data migration needed, they get their default on
|
||||
/// their next login. Same pattern as `PersonalDriveLifecycleHook`.
|
||||
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// External → internal upgrade. At creation the user was external
|
||||
/// (guarded off in `provision_if_needed`); now they're internal
|
||||
/// and eligible for a default calendar.
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
_user: &User,
|
||||
_mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// `caldav.calendars.owner_id` has ON DELETE CASCADE on
|
||||
// `auth.users(id)`, and calendar_events cascade off calendar.
|
||||
// The trigger on `role_grants` reaps the token grants. No
|
||||
// hook-side cleanup needed.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -331,6 +331,21 @@ impl DeltaUploadService {
|
||||
.check_storage_quota(caller_id, total_size)
|
||||
.await?;
|
||||
|
||||
// ── Per-drive quota (D4) ─────────────────────────────────
|
||||
// Mirrors the per-user check above on the same `total_size`.
|
||||
// Only on CREATE — Update replaces an existing row's content;
|
||||
// tight size-delta accounting on update is a follow-up (today
|
||||
// the periodic sweep reconciles drift either way). The
|
||||
// single-statement `check_drive_quota_by_folder` lookup is a
|
||||
// PK probe; cost matches the existing per-user check.
|
||||
if let CommitMode::Create { folder_id, .. } = &mode {
|
||||
let folder_uuid = Uuid::parse_str(folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", folder_id.clone()))?;
|
||||
self.quota
|
||||
.check_drive_quota_by_folder(folder_uuid, total_size)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ── Whole-file fast path: caller already owns this exact content ──
|
||||
// Mirrors the instant-upload endpoint: a reference bump, no chunk
|
||||
// work at all. Ownership is required — an existing-but-foreign
|
||||
@@ -383,7 +398,7 @@ impl DeltaUploadService {
|
||||
let verification = self
|
||||
.dedup
|
||||
.hash_chunk_sequence(
|
||||
&request
|
||||
request
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| (c.h.clone(), c.s))
|
||||
@@ -431,8 +446,12 @@ impl DeltaUploadService {
|
||||
ct if ct.is_empty() => "application/octet-stream".to_string(),
|
||||
ct => ct,
|
||||
};
|
||||
let chunk_hashes: Vec<String> = request.chunks.iter().map(|c| c.h.clone()).collect();
|
||||
let chunk_sizes: Vec<u64> = request.chunks.iter().map(|c| c.s).collect();
|
||||
// `request.chunks` is owned and dead after this line (only
|
||||
// `request.file_hash` is read below), so move the hashes out instead of
|
||||
// cloning each 64-char hash a third time — the distinct set and the
|
||||
// verification tuple already materialized it twice (benches/ROUND25.md §M2).
|
||||
let (chunk_hashes, chunk_sizes): (Vec<String>, Vec<u64>) =
|
||||
request.chunks.into_iter().map(|c| (c.h, c.s)).unzip();
|
||||
let attached = self
|
||||
.dedup
|
||||
.attach_manifest(
|
||||
@@ -534,7 +553,10 @@ impl DeltaUploadService {
|
||||
self.max_chunk_count()
|
||||
)));
|
||||
}
|
||||
let mut distinct_seen = HashSet::new();
|
||||
// foldhash::quality::RandomState — a fast, per-instance random-seeded
|
||||
// hasher, DoS-safe for these attacker-controlled client hashes (up to
|
||||
// max_chunk_count() of them per request) — benches/ROUND26.md §G1.
|
||||
let mut distinct_seen: HashSet<&str, foldhash::quality::RandomState> = HashSet::default();
|
||||
for hash in &request.hashes {
|
||||
if !is_valid_hash(hash) {
|
||||
return Err(DomainError::validation_error(
|
||||
@@ -592,6 +614,12 @@ impl DeltaUploadService {
|
||||
Ok(DeltaDownloadOutcome::Ready(ordered))
|
||||
}
|
||||
|
||||
/// Backend-recommended read-ahead depth for multi-chunk drains
|
||||
/// (see `DedupService::read_prefetch`).
|
||||
pub fn read_prefetch(&self) -> usize {
|
||||
self.dedup.read_prefetch()
|
||||
}
|
||||
|
||||
/// Stream one authorized chunk's bytes (entitlement was established by
|
||||
/// [`authorize_chunk_download_with_perms`]).
|
||||
pub async fn chunk_stream(
|
||||
@@ -659,7 +687,9 @@ fn sanitize_file_name(name: &str) -> Result<String, DomainError> {
|
||||
|
||||
/// Distinct hashes in first-occurrence order.
|
||||
fn distinct_hashes(chunks: &[ChunkRef]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
// foldhash::quality::RandomState — fast, per-instance random-seeded and thus
|
||||
// DoS-safe for these attacker-controlled client hashes (benches/ROUND26.md §G1).
|
||||
let mut seen: HashSet<&str, foldhash::quality::RandomState> = HashSet::default();
|
||||
chunks
|
||||
.iter()
|
||||
.filter(|c| seen.insert(c.h.as_str()))
|
||||
|
||||
@@ -24,11 +24,13 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::entities::drive::DriveKind;
|
||||
use crate::domain::repositories::drive_repository::{DriveRepository, DriveRepositoryError};
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
pub struct DriveManagementService {
|
||||
@@ -39,6 +41,11 @@ pub struct DriveManagementService {
|
||||
/// constructing an orphan-owned drive (the "drive must always have
|
||||
/// ≥1 effective Owner-user" invariant from day one).
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
/// D5: `set_member_role` reads `users.is_external` to enforce
|
||||
/// `forbid_external_sharing` on the drive — closes the gap that the
|
||||
/// `POST /api/drives/{id}/members` route would otherwise open
|
||||
/// (the grant_handler check only catches `POST /api/grants`).
|
||||
user_repo: Arc<UserPgRepository>,
|
||||
}
|
||||
|
||||
impl DriveManagementService {
|
||||
@@ -46,11 +53,13 @@ impl DriveManagementService {
|
||||
drive_repo: Arc<DrivePgRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
group_repo: Arc<SubjectGroupPgRepository>,
|
||||
user_repo: Arc<UserPgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
drive_repo,
|
||||
authz,
|
||||
group_repo,
|
||||
user_repo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +210,30 @@ impl DriveManagementService {
|
||||
|
||||
self.refuse_if_personal(drive_id, "set_member_role").await?;
|
||||
|
||||
// D5: `forbid_external_sharing` on a shared drive — refuses
|
||||
// grant writes whose User subject is `is_external = true`.
|
||||
// Closes the `POST /api/drives/{id}/members` gap that
|
||||
// grant_handler's same-shaped check (covering `POST /api/grants`
|
||||
// only) doesn't reach. Group/Token subjects can't be external
|
||||
// by construction, so the lookup runs only for User subjects.
|
||||
// See `docs/plan/drive.md` §8.
|
||||
self.refuse_if_forbid_external_sharing(drive_id, subject, caller_id)
|
||||
.await?;
|
||||
|
||||
// D5: `forbid_owner_role_change` — locks the Owner roster
|
||||
// against non-admin callers. Fires when this write would add a
|
||||
// new Owner (role == Owner) OR demote a current Owner
|
||||
// (subject is currently Owner and role != Owner).
|
||||
self.refuse_if_forbid_owner_role_change(
|
||||
drive_id,
|
||||
subject,
|
||||
Some(role),
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
"set_member_role",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Demotion of the last owner = last-owner protection trips. A fresh
|
||||
// owner-role write or any non-owner subject is fine; only the case
|
||||
// "this subject is currently the only owner AND the new role is not
|
||||
@@ -217,18 +250,45 @@ impl DriveManagementService {
|
||||
.set_role(caller_id, subject, role, resource, expires_at)
|
||||
.await?;
|
||||
|
||||
if caller_is_admin {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.set_via_admin",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin set drive member role bypassing Manage check",
|
||||
);
|
||||
// Drop the entire drive-role cache for this drive so the new
|
||||
// grant is visible on the very next `check` — without this, a
|
||||
// caller that gets Owner via `POST /api/drives/{id}/members`
|
||||
// then immediately acts on drive content (WebDAV cross-drive
|
||||
// MOVE, admin-driven cleanup, drive management) hits the
|
||||
// stale "no role for this subject on this drive" entry
|
||||
// seeded at some earlier `check`. TTL rescues eventually,
|
||||
// but the storage_cleanup_check.sh drain pattern hits this
|
||||
// race within a single test-second and fails on `authz.denied`
|
||||
// for admin's cascade to files inside.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// Same freshness contract for the repo's readable-drives cache:
|
||||
// the subject's drive list changed with this grant.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_added` audit event covers
|
||||
// every successful membership write (add + role-refresh, since
|
||||
// the underlying `set_role` is UPSERT — distinguishing the two
|
||||
// would require an additional read and bring no extra ops
|
||||
// value). `via_admin` carries the bypass signal that used to
|
||||
// live in a separate `drive_membership.set_via_admin` event;
|
||||
// log aggregators now have one canonical name per operation.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_added",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
role = role.as_str(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
expires_at = ?expires_at,
|
||||
"🤝 drive member added",
|
||||
);
|
||||
Ok(grant)
|
||||
}
|
||||
|
||||
@@ -255,25 +315,378 @@ impl DriveManagementService {
|
||||
|
||||
self.refuse_if_personal(drive_id, "remove_member").await?;
|
||||
|
||||
// D5: `forbid_owner_role_change` — locks the Owner roster
|
||||
// against non-admin callers. Fires when this would remove a
|
||||
// current Owner.
|
||||
self.refuse_if_forbid_owner_role_change(
|
||||
drive_id,
|
||||
subject,
|
||||
None, // None = removal, not a role write
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
"remove_member",
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.refuse_if_last_owner_change(drive_id, subject, caller_id)
|
||||
.await?;
|
||||
|
||||
self.authz.clear_role(subject, resource).await?;
|
||||
|
||||
if caller_is_admin {
|
||||
// Mirror of `set_member_role`'s cache invalidation: after
|
||||
// clearing a role we MUST drop the `drive_role_cache` entries
|
||||
// targeting this drive, otherwise the just-removed subject's
|
||||
// former role stays visible until TTL expires. Same anti-drift
|
||||
// reason as the sibling add path above.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
// And the repo's readable-drives cache: the drive must vanish
|
||||
// from the removed subject's list immediately.
|
||||
match subject {
|
||||
Subject::User(uid) => self.drive_repo.invalidate_readable_for_user(uid).await,
|
||||
_ => self.drive_repo.invalidate_readable_all(),
|
||||
}
|
||||
|
||||
// D6 §11: canonical `drive.member_removed` audit event covers
|
||||
// every successful removal (owner-driven or admin bypass).
|
||||
// `via_admin` replaces the separate
|
||||
// `drive_membership.removed_via_admin` event — single name,
|
||||
// one boolean field for the bypass signal.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.member_removed",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
via_admin = caller_is_admin,
|
||||
by = %caller_id,
|
||||
"👋 drive member removed",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `DELETE /api/drives/{id}` and `DELETE /api/admin/drives/{id}`.
|
||||
///
|
||||
/// Policy (drive.md §6 + memos):
|
||||
/// - Caller must hold `Permission::Manage` on the drive — typically
|
||||
/// the Owner. `caller_is_admin = true` bypasses this check; the
|
||||
/// route gate is the access control then. Audit emits
|
||||
/// `drive.deleted_via_admin` when the bypass fires.
|
||||
/// - The user's default Personal drive (`drives.default_for_user
|
||||
/// IS NOT NULL`) is refused with `405` — deleting your home is a
|
||||
/// category error. Secondary personal drives + shared drives
|
||||
/// follow the same content-empty rule below.
|
||||
/// - The drive must be empty (no live folders other than the root,
|
||||
/// no live files). Trashed rows are excluded — owners can
|
||||
/// delete a drive whose trash bin still holds rows; the trash GC
|
||||
/// cleans them up after the retention window. Non-empty drives
|
||||
/// return `409 Conflict` so the UI can prompt the owner to
|
||||
/// move/trash content first.
|
||||
///
|
||||
/// On success the drive row, its root folder, and every
|
||||
/// `role_grants` row scoped to the drive are removed in one
|
||||
/// transaction.
|
||||
pub async fn delete_drive(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
drive_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let resource = Resource::Drive(drive_id);
|
||||
if !caller_is_admin {
|
||||
self.authz
|
||||
.require(Subject::User(caller_id), Permission::Manage, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
|
||||
if drive.drive.default_for_user.is_some() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.removed_via_admin",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "default_personal_drive",
|
||||
drive_id = %drive_id,
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ admin removed drive member bypassing Manage check",
|
||||
"👮🏻♂️ refused delete on default personal drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::operation_not_supported(
|
||||
"Drive",
|
||||
"The default Personal drive cannot be deleted.",
|
||||
));
|
||||
}
|
||||
|
||||
let empty = self.drive_repo.is_empty(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to check emptiness: {e:?}"))
|
||||
})?;
|
||||
if !empty {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_delete.rejected",
|
||||
reason = "drive_not_empty",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused delete on non-empty drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::Conflict,
|
||||
"Drive",
|
||||
"Drive is not empty — move or trash its contents before deleting.",
|
||||
));
|
||||
}
|
||||
|
||||
self.drive_repo
|
||||
.delete_atomic(drive_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("Drive", format!("delete failed: {e:?}")))?;
|
||||
|
||||
// Drop every cached drive-role entry for this drive so the next
|
||||
// /api/drives listing for any subject doesn't show a row pointing
|
||||
// at a deleted drive_id. Single-key cache invalidations are safe
|
||||
// even when no entry matches.
|
||||
self.authz
|
||||
.invalidate_drive_role_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if caller_is_admin {
|
||||
"drive.deleted_via_admin"
|
||||
} else {
|
||||
"drive.deleted"
|
||||
},
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"🗑 drive deleted",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies`. OxiCloud-admin only.
|
||||
///
|
||||
/// The drive's `policies` JSONB bag is a compliance surface — same
|
||||
/// category as `drives.quota_bytes` and `users.storage_quota_bytes`
|
||||
/// (§7). Owner mutation would make the policies self-policing
|
||||
/// (an owner could disable `forbid_external_sharing`, share, and
|
||||
/// re-enable), so mutation is restricted to the tenant operator.
|
||||
/// The handler is the gate (refuses non-admin callers with 404 for
|
||||
/// anti-enumeration); this method trusts that gate and writes
|
||||
/// unconditionally.
|
||||
///
|
||||
/// JSONB-level merge preserves unknown keys; only the partial
|
||||
/// supplied is overwritten. Returns the post-merge typed view.
|
||||
/// Audit emits `drive.policy_changed` with the post-merge bag for
|
||||
/// steady-state observability.
|
||||
///
|
||||
/// Ed's call, 2026-07-17: intentional deviation from the AGENTS.md
|
||||
/// "AuthZ in service layer" rule for this specific endpoint —
|
||||
/// the handler-layer admin check stays, this method stays trusting.
|
||||
/// See memory `feedback_drive_policies_admin_at_handler`.
|
||||
pub async fn update_policies(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
partial: serde_json::Value,
|
||||
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
|
||||
let merged = self
|
||||
.drive_repo
|
||||
.update_policies(drive_id, &partial)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("update_policies failed: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
// Flush the cached typed policy view so the very next mutating
|
||||
// authz check on any resource in this drive sees the fresh
|
||||
// `read_only` value (and every other policy field). Without this,
|
||||
// a policy change would take up to `DRIVE_POLICIES_CACHE_TTL` (30 s)
|
||||
// to take effect on the hot path — unacceptable for the read_only
|
||||
// freeze, which admins expect to be effective immediately.
|
||||
self.authz
|
||||
.invalidate_drive_policies_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.policy_changed",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
forbid_sharing = merged.forbid_sharing,
|
||||
forbid_external_sharing = merged.forbid_external_sharing,
|
||||
forbid_public_links = merged.forbid_public_links,
|
||||
forbid_cross_drive_move = merged.forbid_cross_drive_move,
|
||||
forbid_owner_role_change = merged.forbid_owner_role_change,
|
||||
include_in_photo_index = merged.include_in_photo_index,
|
||||
include_in_music_index = merged.include_in_music_index,
|
||||
read_only = merged.read_only,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/quota`. OxiCloud-admin only.
|
||||
///
|
||||
/// `quota_bytes = None` (or ≤ 0 from the wire, normalised to None
|
||||
/// here) means unlimited — matches the DB convention where a NULL
|
||||
/// `drives.quota_bytes` row is treated as no cap by
|
||||
/// `storage_usage_service`.
|
||||
///
|
||||
/// **Refuses personal drives** with `InvalidInput`. Personal
|
||||
/// drives carry `NULL` on the row by design (memory
|
||||
/// `project_user_envelope_quota_model`) — the effective cap comes
|
||||
/// from the owner user's `storage_quota_bytes`, editable via
|
||||
/// `PUT /api/admin/users/{id}/quota`. Allowing a per-personal-drive
|
||||
/// quota here would fork the model into two competing enforcement
|
||||
/// paths; keep the envelope model intact.
|
||||
///
|
||||
/// **Soft-quota semantic on reduction.** A newly-lowered quota
|
||||
/// can land BELOW the drive's current `used_bytes` — this method
|
||||
/// accepts that without failing. `storage_usage_service` gates
|
||||
/// new writes on `used + delta ≤ quota`, so a shared drive
|
||||
/// already over its freshly-reduced cap can only shrink (delete)
|
||||
/// until it comes back under; no existing content is retroactively
|
||||
/// touched. Ed's call: intentional design, matches how filesystems
|
||||
/// treat quota shrink (Linux xfs quota tools do the same).
|
||||
///
|
||||
/// Follows the same handler-gates-admin deviation from AGENTS.md
|
||||
/// as `update_policies` — see memory
|
||||
/// `feedback_drive_policies_admin_at_handler`. The handler
|
||||
/// refuses non-admin callers with 404 anti-enumeration; this
|
||||
/// method trusts that gate and writes unconditionally on
|
||||
/// shared-kind drives.
|
||||
///
|
||||
/// Emits `drive.quota_changed` for steady-state observability.
|
||||
/// Returns the persisted post-mutation quota so the handler can
|
||||
/// echo it in the API response.
|
||||
pub async fn update_quota(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
quota_bytes: Option<i64>,
|
||||
) -> Result<Option<i64>, DomainError> {
|
||||
// Normalise sentinel values: `0` and negative numbers on the
|
||||
// wire all mean "unlimited" — same convention the storage
|
||||
// service uses on the query side (see `check_drive_quota`).
|
||||
// Doing this once here (rather than in every caller) keeps the
|
||||
// audit line + DB row consistent.
|
||||
let quota_bytes = quota_bytes.filter(|&q| q > 0);
|
||||
|
||||
let drive = self
|
||||
.drive_repo
|
||||
.get_by_id(drive_id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => DomainError::internal_error(
|
||||
"Drive",
|
||||
format!("Failed to fetch drive: {other:?}"),
|
||||
),
|
||||
})?;
|
||||
|
||||
// Personal drives are refused with `InvalidInput` — a 400 that
|
||||
// the handler doesn't need to translate specially. Audit line
|
||||
// captures the attempt so an operator can see if someone is
|
||||
// trying to circumvent the envelope model.
|
||||
if drive.drive.kind == crate::domain::entities::drive::DriveKind::Personal {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_change_rejected",
|
||||
reason = "personal_drive_uses_user_envelope",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused quota edit on personal drive {drive_id} — use PUT /api/admin/users/{{id}}/quota",
|
||||
);
|
||||
return Err(DomainError::validation_error(
|
||||
"Personal drive quota is not editable here — set the owner user's storage envelope via PUT /api/admin/users/{id}/quota instead.",
|
||||
));
|
||||
}
|
||||
|
||||
let persisted = self
|
||||
.drive_repo
|
||||
.update_quota(drive_id, quota_bytes)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DriveRepositoryError::NotFound(_) => {
|
||||
DomainError::not_found("Drive", drive_id.to_string())
|
||||
}
|
||||
other => {
|
||||
DomainError::internal_error("Drive", format!("update_quota failed: {other:?}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Under-usage note in the audit line: an admin should be able
|
||||
// to spot from `grep audit drive.quota_changed` whether the
|
||||
// new cap put the drive into the "over quota, delete-only"
|
||||
// state, so the numbers (used, new quota) are both present.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.quota_changed",
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
new_quota_bytes = ?persisted,
|
||||
used_bytes = drive.drive.used_bytes,
|
||||
over_quota = persisted.map(|q| drive.drive.used_bytes > q).unwrap_or(false),
|
||||
"💾 drive quota updated",
|
||||
);
|
||||
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
/// D5 `forbid_external_sharing` for `set_member_role`. Fetches the
|
||||
/// data this surface has but grant_handler doesn't (drive policies +
|
||||
/// user flags), then defers the decision + audit + canonical error
|
||||
/// to `DrivePolicies::refuse_external_sharing` — the same gate
|
||||
/// `grant_handler::create_grant` runs for File/Folder resources. One
|
||||
/// rejection shape across both entry points.
|
||||
///
|
||||
/// Group / Token subjects can't be external by construction, so the
|
||||
/// user lookup is skipped (the gate handles those branches too, but
|
||||
/// returning early avoids a wasted SELECT on the drive row).
|
||||
async fn refuse_if_forbid_external_sharing(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
subject: Subject,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let Subject::User(uid) = subject else {
|
||||
return Ok(());
|
||||
};
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
let policies = drive.drive.typed_policies();
|
||||
if !policies.forbid_external_sharing {
|
||||
return Ok(());
|
||||
}
|
||||
let flags = self
|
||||
.user_repo
|
||||
.get_user_flags(uid)
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("User", format!("flags lookup: {e:?}")))?;
|
||||
policies.refuse_external_sharing(
|
||||
subject,
|
||||
flags.is_external,
|
||||
crate::domain::entities::drive::ExternalSharingGateContext {
|
||||
caller_id,
|
||||
stage: "drive_member",
|
||||
drive_id: Some(drive_id),
|
||||
resource_type: None,
|
||||
resource_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Business rules ──────────────────────────────────────────────────────
|
||||
|
||||
/// Personal drives are single-user single-owner; any member mutation is
|
||||
@@ -282,7 +695,7 @@ impl DriveManagementService {
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
if drive.drive.is_personal() {
|
||||
if matches!(drive.drive.kind, DriveKind::Personal) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive_membership.rejected",
|
||||
@@ -299,6 +712,73 @@ impl DriveManagementService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// D5 `forbid_owner_role_change`. Fetches drive policies (one PK
|
||||
/// probe), bails out early when the policy is off or the caller is
|
||||
/// admin, then determines whether the requested op actually
|
||||
/// mutates the Owner roster:
|
||||
///
|
||||
/// - `new_role = Some(Role::Owner)` — Owner add or refresh. Owner
|
||||
/// roster mutation.
|
||||
/// - `new_role = Some(Role::X)` and subject is currently Owner —
|
||||
/// demotion. Owner roster mutation.
|
||||
/// - `new_role = None` (remove) and subject is currently Owner —
|
||||
/// removal. Owner roster mutation.
|
||||
///
|
||||
/// In any of those cases, defers to
|
||||
/// `DrivePolicies::refuse_owner_role_change` for the audit + error.
|
||||
async fn refuse_if_forbid_owner_role_change(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
subject: Subject,
|
||||
new_role: Option<Role>,
|
||||
caller_id: Uuid,
|
||||
caller_is_admin: bool,
|
||||
operation: &'static str,
|
||||
) -> Result<(), DomainError> {
|
||||
// Fast bypass for the tenant operator.
|
||||
if caller_is_admin {
|
||||
return Ok(());
|
||||
}
|
||||
let drive = self.drive_repo.get_by_id(drive_id).await.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("Failed to fetch drive: {e:?}"))
|
||||
})?;
|
||||
let policies = drive.drive.typed_policies();
|
||||
if !policies.forbid_owner_role_change {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Determine whether this op touches the Owner roster. An Owner
|
||||
// add (role == Owner) always does; a non-Owner write or a
|
||||
// removal only does when the subject currently holds Owner —
|
||||
// fetched lazily on the second case to skip the round-trip
|
||||
// when we already know the answer.
|
||||
let touches_owner = if matches!(new_role, Some(Role::Owner)) {
|
||||
true
|
||||
} else {
|
||||
let grants = self
|
||||
.authz
|
||||
.list_grants_on_resource(Resource::Drive(drive_id))
|
||||
.await?;
|
||||
grants
|
||||
.iter()
|
||||
.any(|g| g.subject == subject && matches!(g.role, Role::Owner))
|
||||
};
|
||||
if !touches_owner {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
policies.refuse_owner_role_change(
|
||||
crate::domain::entities::drive::OwnerRoleChangeGateContext {
|
||||
caller_id,
|
||||
caller_is_admin,
|
||||
drive_id,
|
||||
operation,
|
||||
subject_type: subject.type_str(),
|
||||
subject_id: subject.id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse the change if `subject` is currently the sole `Owner` on the
|
||||
/// drive and the operation would remove or demote them. A shared drive
|
||||
/// must always have at least one Owner — otherwise it becomes orphaned
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoriteResourceRow,
|
||||
FavoritesCursor,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::common::errors::Result;
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Implementation of the FavoritesUseCase for managing user favorites.
|
||||
///
|
||||
@@ -20,12 +22,22 @@ use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
/// accessing the database directly, following hexagonal architecture.
|
||||
pub struct FavoritesService {
|
||||
repo: Arc<FavoritesPgRepository>,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's favorites.
|
||||
/// Without this gate the write path is an information oracle:
|
||||
/// listing endpoints JOIN back to `storage.files/folders` and
|
||||
/// return name/mime/size/drive_id for any UUID the caller was
|
||||
/// able to enroll. See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
|
||||
Self { repo }
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>, authorization: Arc<PgAclEngine>) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authorization,
|
||||
}
|
||||
}
|
||||
|
||||
/// Subset of `(item_id, item_type)` pairs the user has favorited — used to
|
||||
@@ -60,13 +72,15 @@ impl FavoritesUseCase for FavoritesService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum, matches the listing shape) + `authz.denied`
|
||||
// audit line. Without this gate the write path was an
|
||||
// information oracle over the whole tenant.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
info!(
|
||||
@@ -125,18 +139,23 @@ impl FavoritesUseCase for FavoritesService {
|
||||
user_id
|
||||
);
|
||||
|
||||
// Validate all item types
|
||||
// AuthZ pre-write: caller must have Read on every referenced
|
||||
// resource. Fail the whole batch on the first denial so the
|
||||
// response shape doesn't tell an attacker which items were
|
||||
// valid (partial success would leak the same oracle we
|
||||
// closed on the single-item path). See
|
||||
// `docs/plan/authz_audit/rest_storage.md`.
|
||||
//
|
||||
// Deliberately serial: a `try_join_all` fan-out measured WORSE
|
||||
// on both the cold (drive_of point-SELECTs) and warm (all-moka)
|
||||
// paths — future orchestration + pool-acquire contention cost
|
||||
// more than the local round trips they overlap. Rejected by
|
||||
// `bench_favorites_authz`; numbers in benches/ROUND6.md.
|
||||
for (item_id, item_type) in items {
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Favorites",
|
||||
format!(
|
||||
"Item type must be 'file' or 'folder' for item '{}'",
|
||||
item_id
|
||||
),
|
||||
));
|
||||
}
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let requested = items.len();
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
|
||||
@@ -38,6 +39,24 @@ pub struct FileManagementService {
|
||||
/// External-mount classifier. `None` in stub/test construction → all ids
|
||||
/// are treated as native.
|
||||
mount_router: Option<Arc<MountRouter>>,
|
||||
/// Read/write access hook — fired so Recent reflects "this is the file
|
||||
/// I just copied / renamed / moved", same way the read paths surface
|
||||
/// downloads. Distinct from the lifecycle hook because lifecycle hooks
|
||||
/// don't carry the `caller_id` the recording side needs.
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
/// Drive repository — used by D5's `forbid_cross_drive_move` gate
|
||||
/// on `move_file_with_perms`. Optional so stubs / test factories
|
||||
/// can build the service without wiring the full drive repo; in
|
||||
/// that case the cross-drive move check is skipped (the policy
|
||||
/// is silently off). Production DI wires it in.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + delta ≤ quota_bytes` invariant on
|
||||
/// cross-drive MOVE, matching the pre-write check the upload path
|
||||
/// already performs. Without it, the check is silently skipped
|
||||
/// (stub/test builders); production DI wires it in.
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
@@ -61,6 +80,9 @@ impl FileManagementService {
|
||||
authz,
|
||||
file_lifecycle_hook: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +145,42 @@ impl FileManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the read/write access hook (Recent list recorder).
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Internal helper: fire the access hook if registered.
|
||||
fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository, enabling D5 `forbid_cross_drive_move`
|
||||
/// enforcement on `move_file_with_perms`. Without it, the gate is
|
||||
/// silently skipped.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_file_with_perms` can
|
||||
/// pre-check the destination drive's quota on cross-drive moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Engine check for a file resource. Parses the id into a `Uuid` and
|
||||
/// requires the specified permission.
|
||||
async fn require_file_perm(
|
||||
@@ -217,6 +275,9 @@ impl FileManagementService {
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_copied(&dto.id, &dto.content_hash, &dto.mime_type, file_id);
|
||||
}
|
||||
// The caller just spawned a fresh file — show it in their Recent
|
||||
// list. The source file isn't recorded; only the visible target.
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
@@ -344,7 +405,96 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
self.move_file(file_id, folder_id, caller_id).await
|
||||
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
|
||||
// share the same src/dst drive_id lookup: the gate refuses
|
||||
// before the move; the audit fires after a successful move
|
||||
// when the two drives differ. Silently skipped if the drive
|
||||
// repo isn't wired (stub builders) or the move target is None
|
||||
// (root namespace — same-drive semantics).
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(target_folder_id) = folder_id.as_deref()
|
||||
{
|
||||
let file_uuid =
|
||||
Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
let dst_folder_uuid = Uuid::parse_str(target_folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_folder_id))?;
|
||||
// Independent point reads — overlapped so the pre-move drive
|
||||
// resolution pays one round-trip, not two (ROUND10).
|
||||
let (src_res, dst_res) = tokio::join!(
|
||||
drive_repo.get_drive_id_and_policies_for_file(file_uuid),
|
||||
drive_repo.drive_id_for_folder(dst_folder_uuid),
|
||||
);
|
||||
let (src_drive_id, src_policies) = src_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("source drive lookup: {e:?}"))
|
||||
})?;
|
||||
let dst_drive_id = dst_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}"))
|
||||
})?;
|
||||
if src_drive_id != dst_drive_id {
|
||||
src_policies.refuse_cross_drive_move(
|
||||
crate::domain::entities::drive::CrossDriveMoveGateContext {
|
||||
caller_id,
|
||||
resource_type: "file",
|
||||
resource_id: file_uuid,
|
||||
src_drive_id,
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: same pre-write check the
|
||||
// upload path already runs (`file_upload_service.rs`
|
||||
// `check_storage_quota`), applied here so a caller
|
||||
// can't sneak content past the drive cap via MOVE.
|
||||
// Denial → `DomainError::QuotaExceeded` → 507
|
||||
// Insufficient Storage. Skipped when `storage_usage`
|
||||
// isn't wired (stub builders) — same shape as the
|
||||
// upload path's skip semantics.
|
||||
if let Some(storage_usage) = &self.storage_usage
|
||||
&& let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, size_u64)
|
||||
.await?;
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
let dto = self.move_file(file_id, folder_id, caller_id).await?;
|
||||
|
||||
// Cross-drive move invalidates the file's `owner_cache` entry
|
||||
// in the authz engine — the cache assumed drive_id stability
|
||||
// that no longer holds. Without this call the drive-role
|
||||
// precheck at `check_inner` steers to the (stale) source
|
||||
// drive and legitimate Delete/Update by a destination-drive
|
||||
// role-holder returns 404 for up to the cache TTL.
|
||||
if cross_drive.is_some()
|
||||
&& let Ok(file_uuid) = Uuid::parse_str(file_id)
|
||||
{
|
||||
self.authz
|
||||
.invalidate_owner_cache_for_resource(Resource::File(file_uuid))
|
||||
.await;
|
||||
}
|
||||
|
||||
// D6 §11 audit: emit only when the move actually crossed a
|
||||
// drive boundary. Same-drive moves are too noisy to audit at
|
||||
// info — operators care about the cross-drive case for
|
||||
// exfiltration / quota tracking.
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "file",
|
||||
resource_id = %dto.id,
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 file moved between drives",
|
||||
);
|
||||
}
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
async fn copy_file_with_perms(
|
||||
@@ -359,6 +509,31 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: COPY creates a new file row that
|
||||
// counts against the destination drive's `used_bytes` even
|
||||
// though blob dedup means no new bytes hit the store. Same
|
||||
// pre-flight shape the delta-upload path already uses.
|
||||
// Skipped when `storage_usage` isn't wired (stub builders) or
|
||||
// `target_folder_id` is None (root namespace — same-drive
|
||||
// semantics inherit the source's cap coverage). Denial →
|
||||
// `QuotaExceeded` → 507.
|
||||
if let (Some(storage_usage), Some(target_folder)) =
|
||||
(&self.storage_usage, target_folder_id.as_deref())
|
||||
{
|
||||
let file_uuid =
|
||||
Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
||||
let target_folder_uuid = Uuid::parse_str(target_folder)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_folder))?;
|
||||
if let Some(size_bytes) = storage_usage.file_bytes(file_uuid).await?
|
||||
&& let Ok(size_u64) = u64::try_from(size_bytes)
|
||||
{
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_folder_uuid, size_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id)
|
||||
.await
|
||||
}
|
||||
@@ -466,6 +641,26 @@ impl FileManagementUseCase for FileManagementService {
|
||||
.await?;
|
||||
self.require_target_folder_perm(target_parent_id.as_deref(), Permission::Create, caller_id)
|
||||
.await?;
|
||||
|
||||
// Destination drive quota: sum the subtree's non-trashed files
|
||||
// and refuse if the destination couldn't hold them. Skipped
|
||||
// when `storage_usage` isn't wired or the target is root
|
||||
// (same rationale as `copy_file_with_perms`).
|
||||
if let (Some(storage_usage), Some(target_parent)) =
|
||||
(&self.storage_usage, target_parent_id.as_deref())
|
||||
{
|
||||
let source_uuid = Uuid::parse_str(source_folder_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", source_folder_id))?;
|
||||
let target_parent_uuid = Uuid::parse_str(target_parent)
|
||||
.map_err(|_| DomainError::not_found("Folder", target_parent))?;
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(source_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota_by_folder(target_parent_uuid, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
self.copy_folder_tree(source_folder_id, target_parent_id, dest_name)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::blob_storage_ports::BlobStream;
|
||||
use crate::application::ports::external_mount_ports::MountStat;
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
|
||||
use crate::application::ports::file_ports::{
|
||||
FileRetrievalUseCase, OptimizedFileContent, RangeContent,
|
||||
};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::application::services::mount_registry::MountConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -40,6 +43,11 @@ pub struct FileRetrievalService {
|
||||
/// External-mount classifier for path-based resolution (WebDAV/NextCloud).
|
||||
/// `None` in the simple/test constructor → no mount support.
|
||||
mount_router: Option<Arc<crate::application::services::external_mount_router::MountRouter>>,
|
||||
/// Optional read-event observer. Currently fans out to the Recent-list
|
||||
/// recorder; future observers (audit trail, "last seen by", …) attach
|
||||
/// to the same hook so service code only knows the trait, not the impl.
|
||||
/// `None` for the test/stub path that constructs via [`Self::new`].
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
}
|
||||
|
||||
impl FileRetrievalService {
|
||||
@@ -53,6 +61,7 @@ impl FileRetrievalService {
|
||||
transcode: None,
|
||||
authz: None,
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +79,7 @@ impl FileRetrievalService {
|
||||
transcode: Some(transcode),
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +93,14 @@ impl FileRetrievalService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: attach a [`ResourceAccessHook`] that fires after every
|
||||
/// authorised `_with_perms` read. Without it the service is silent —
|
||||
/// existing behaviour for stub / test paths.
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Test-only constructor: authorization engine without the cache/transcode
|
||||
/// tiers. The external-mount read methods only consult `authz` + the
|
||||
/// provider, so this is sufficient to exercise their authorization.
|
||||
@@ -97,6 +115,24 @@ impl FileRetrievalService {
|
||||
transcode: None,
|
||||
authz: Some(authz),
|
||||
mount_router: None,
|
||||
resource_access_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the access hook if registered. Called from every `_with_perms`
|
||||
/// read after the authZ + lookup has succeeded (never on failure
|
||||
/// paths — denied reads must not surface in Recent).
|
||||
///
|
||||
/// `pub` because the WebDAV / NextCloud DAV handlers resolve files
|
||||
/// by path and authorise via that resolver, not via the
|
||||
/// `*_with_perms` service methods — they then serve content through
|
||||
/// the no-perms `get_file_stream` / `get_file_range_stream`. Those
|
||||
/// handlers must call this directly after their own authZ has
|
||||
/// passed so cross-protocol downloads (NC desktop, davx5, native
|
||||
/// `/webdav/`) also surface in Recent.
|
||||
pub fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +148,26 @@ impl FileRetrievalService {
|
||||
) -> Result<Bytes, DomainError> {
|
||||
let stream = file_read.get_file_stream(id).await?;
|
||||
let mut stream = Pin::from(stream);
|
||||
let mut buf = BytesMut::with_capacity(capacity);
|
||||
// Most sub-threshold reads arrive as ONE owned contiguous frame from the
|
||||
// backend (the local ReaderStream emits ≤256 KiB frames, and a
|
||||
// sub-threshold blob fits in one). Return that frame directly instead of
|
||||
// copying the whole payload a second time into a fresh BytesMut; only a
|
||||
// multi-frame read pays the pre-sized concat — byte-identical output
|
||||
// (benches/ROUND29.md §C).
|
||||
let Some(first) = stream.next().await else {
|
||||
return Ok(Bytes::new());
|
||||
};
|
||||
let first = first.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?;
|
||||
let Some(second) = stream.next().await else {
|
||||
return Ok(first);
|
||||
};
|
||||
let mut buf = BytesMut::with_capacity(capacity.max(first.len()));
|
||||
buf.extend_from_slice(&first);
|
||||
buf.extend_from_slice(&second.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
})?);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
buf.extend_from_slice(&chunk.map_err(|e| {
|
||||
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
||||
@@ -263,7 +318,6 @@ impl FileRetrievalService {
|
||||
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
||||
let mime_type = dto.mime_type.clone();
|
||||
let file_size = dto.size;
|
||||
let file_name = dto.name.clone();
|
||||
// The content cache is content-addressed: keyed by the blob hash, not
|
||||
// the file id. Identical content deduplicated to one blob on disk is
|
||||
// then cached ONCE in RAM and shared by every file/user that references
|
||||
@@ -271,34 +325,39 @@ impl FileRetrievalService {
|
||||
// construction, so entries never go stale (no invalidation needed). A
|
||||
// stub DTO without a hash disables caching for that request rather than
|
||||
// colliding every hash-less file on the key "".
|
||||
let cache_key = dto.content_hash.clone();
|
||||
let cacheable = !cache_key.is_empty();
|
||||
let cacheable = !dto.content_hash.is_empty();
|
||||
let do_transcode = accept_webp && !prefer_original;
|
||||
|
||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||
if file_size < CACHE_THRESHOLD {
|
||||
// Fetch the raw blob bytes. When cacheable, `get_or_load` serves
|
||||
// from the content cache on a hit and, on a miss, coalesces every
|
||||
// concurrent request for the same blob hash into a SINGLE disk read
|
||||
// (single-flight) — no thundering herd under load. Hash-less stub
|
||||
// DTOs are uncacheable and stream straight from disk.
|
||||
// Probe the content cache with a BORROW first: a hit serves the blob
|
||||
// straight from RAM, and only a miss builds the owned load arguments
|
||||
// (the quoted-etag / key / id Strings) that a hit would otherwise
|
||||
// allocate and immediately discard (benches/ROUND29.md §B). On a miss
|
||||
// `load_and_cache` still coalesces concurrent requests for the same
|
||||
// blob hash into a SINGLE disk read (single-flight) — no thundering
|
||||
// herd. Hash-less stub DTOs are uncacheable and stream from disk.
|
||||
let content_bytes = if cacheable && let Some(cache) = &self.content_cache {
|
||||
let etag: Arc<str> = format!("\"{}\"", cache_key).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, _etag, _ct) = cache
|
||||
.get_or_load(cache_key.clone(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = id.to_string();
|
||||
let cap = file_size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"💾 TIER 1 (uncacheable): {} – streaming from disk",
|
||||
file_name
|
||||
dto.name
|
||||
);
|
||||
Self::read_full(&self.file_read, id, file_size as usize).await?
|
||||
};
|
||||
@@ -330,7 +389,7 @@ impl FileRetrievalService {
|
||||
// ── Tier 2 + 3: Streaming (≥10 MB) ──────────────────
|
||||
info!(
|
||||
"📡 TIER 2 STREAMING: {} ({} MB)",
|
||||
file_name,
|
||||
dto.name,
|
||||
file_size / (1024 * 1024)
|
||||
);
|
||||
let stream = self.file_read.get_file_stream(id).await?;
|
||||
@@ -347,6 +406,109 @@ impl FileRetrievalService {
|
||||
let files = self.file_read.get_files_by_ids(ids).await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Batched, authorized multi-get for the ZIP-download multi-select — the
|
||||
/// batch form of [`FileRetrievalUseCase::get_file_with_perms`] over an
|
||||
/// explicit id list.
|
||||
///
|
||||
/// Authorizes `Read` on every id in ONE `check_files_read_batch`
|
||||
/// round-trip (which resolves all drives in a single query AND primes the
|
||||
/// resource→drive cache, so the per-file re-check the subsequent stream
|
||||
/// open performs becomes a cache hit), then fetches only the authorized ids
|
||||
/// in ONE `get_files_by_ids` query. Replaces `download_zip`'s per-file
|
||||
/// `require_file` + `get_file` loop — 2 round-trips/file → 2 total.
|
||||
///
|
||||
/// Returns the authorized, existing files; a denied / missing / unparseable
|
||||
/// id is simply **absent** from the result (the caller re-associates by id
|
||||
/// and skips the rest, exactly as the per-file loop skipped a denied /
|
||||
/// missing `get_file_with_perms`). Read-authorization is identical to the
|
||||
/// per-file path (`check_files_read_batch` is documented and gated as
|
||||
/// semantically identical to looping `require`). Recents recording is left
|
||||
/// to the subsequent per-file stream open (`get_file_stream_with_perms`),
|
||||
/// which records it (throttle-coalesced) — same net effect as the old
|
||||
/// loop's `notify_file_accessed` + stream double-notify. Fail-closed if no
|
||||
/// engine was injected, mirroring [`Self::require_file`].
|
||||
pub async fn get_files_by_ids_with_perms(
|
||||
&self,
|
||||
ids: &[String],
|
||||
caller_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let authz = self.authz.as_ref().ok_or_else(|| {
|
||||
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
||||
})?;
|
||||
// Unparseable ids can't be authorized (the per-file path 404s on them),
|
||||
// so drop them here — they stay absent from the authorized set.
|
||||
let uuids: Vec<Uuid> = ids.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect();
|
||||
if uuids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let allowed = authz
|
||||
.check_files_read_batch(Subject::User(caller_id), &uuids)
|
||||
.await?;
|
||||
if allowed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let allowed_ids: Vec<String> = allowed.iter().map(Uuid::to_string).collect();
|
||||
let files = self.file_read.get_files_by_ids(&allowed_ids).await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
/// Range read for HTTP Range Requests, cache-aware.
|
||||
///
|
||||
/// Media players and PDF viewers fetch these files *exclusively* through
|
||||
/// Range requests (a `bytes=0-` probe, then seeks) — the plain streaming
|
||||
/// path paid 1 PG round-trip (blob-hash resolve) + a chunk open/seek for
|
||||
/// EVERY seek, even when the whole blob was already sitting in the moka
|
||||
/// content cache as one contiguous `Bytes`. For sub-`CACHE_THRESHOLD`
|
||||
/// files this now answers from the cache: `Bytes::slice` is a refcount
|
||||
/// bump — zero copy, zero I/O, zero PG (benches/RANGE-CACHE.md). A miss
|
||||
/// populates the cache via the same single-flight `get_or_load` Tier 1
|
||||
/// uses, so one probe warms every subsequent seek. `end` is exclusive
|
||||
/// (callers pass `Some(last_byte + 1)`), matching the streaming variant.
|
||||
pub async fn get_file_range_preloaded(
|
||||
&self,
|
||||
dto: &FileDto,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<RangeContent, DomainError> {
|
||||
let cacheable = dto.size < CACHE_THRESHOLD && !dto.content_hash.is_empty();
|
||||
if cacheable && let Some(cache) = &self.content_cache {
|
||||
// Probe with a BORROW first: the video-scrub steady state is a cache
|
||||
// hit, and a hit must not allocate the owned load args (quoted-etag /
|
||||
// key / id Strings) it would immediately discard — those are built
|
||||
// only on the miss branch (benches/ROUND29.md §B). A miss still
|
||||
// populates via the same single-flight coalescing.
|
||||
let bytes = if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
||||
bytes
|
||||
} else {
|
||||
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
||||
let ct: Arc<str> = dto.mime_type.clone();
|
||||
let file_read = Arc::clone(&self.file_read);
|
||||
let id_owned = dto.id.clone();
|
||||
let cap = dto.size as usize;
|
||||
let (bytes, ..) = cache
|
||||
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
||||
debug!("💾 Range cache MISS: {} – loading from disk", id_owned);
|
||||
Self::read_full(&file_read, &id_owned, cap).await
|
||||
})
|
||||
.await?;
|
||||
bytes
|
||||
};
|
||||
let len = bytes.len() as u64;
|
||||
let s = start.min(len) as usize;
|
||||
let e = end.unwrap_or(len).min(len) as usize;
|
||||
if s <= e {
|
||||
return Ok(RangeContent::Bytes(bytes.slice(s..e)));
|
||||
}
|
||||
// Degenerate range the validator should have rejected — fall
|
||||
// through to the streaming path rather than panic on slice.
|
||||
}
|
||||
let stream = self
|
||||
.file_read
|
||||
.get_file_range_stream(&dto.id, start, end)
|
||||
.await?;
|
||||
Ok(RangeContent::Stream(stream))
|
||||
}
|
||||
}
|
||||
|
||||
impl FileRetrievalUseCase for FileRetrievalService {
|
||||
@@ -358,6 +520,11 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
// After authZ + lookup succeed: this caller has just inspected the
|
||||
// file. Recent listing observes via the hook. The throttle in the
|
||||
// recording impl coalesces repeat metadata fetches against the same
|
||||
// file (file viewer poll, browse-then-download pattern).
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
Ok(FileDto::from(file))
|
||||
}
|
||||
|
||||
@@ -421,19 +588,17 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
} else {
|
||||
// no folder id, get owners's files' root
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_for_owner(folder_id, owner_id)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
// Files always have a `folder_id` in the D0+ model — there is no
|
||||
// longer any concept of "root-level files". A `None` from the
|
||||
// caller means the query string was missing `folder_id`; reject
|
||||
// with a clear error rather than returning an empty set from a
|
||||
// meaningless root-level query.
|
||||
if folder_id.is_none() {
|
||||
return Err(DomainError::validation_error("folder_id is required"));
|
||||
}
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
self.list_files(folder_id).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
@@ -454,6 +619,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
caller_id: Uuid,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.file_read.get_file_stream(id).await
|
||||
}
|
||||
|
||||
@@ -480,6 +646,7 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
let file = self.file_read.get_file(id).await?;
|
||||
let dto = FileDto::from(file);
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.optimized_inner(id, dto, accept_webp, prefer_original)
|
||||
.await
|
||||
}
|
||||
@@ -521,6 +688,10 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
self.require_file(id, Permission::Read, caller_id).await?;
|
||||
// Range requests are bursty (video seeks, NC chunked downloads) —
|
||||
// the recording hook's per-(caller, file) throttle absorbs the
|
||||
// storm so one watched video lands as one Recent row, not 1000.
|
||||
self.notify_file_accessed(caller_id, id);
|
||||
self.file_read.get_file_range_stream(id, start, end).await
|
||||
}
|
||||
|
||||
@@ -537,12 +708,12 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
async fn list_files_batch(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.list_files_batch(folder_id, after_name, limit)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
@@ -551,21 +722,21 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
owner_id: Uuid,
|
||||
offset: i64,
|
||||
after_name: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<FileDto>, DomainError> {
|
||||
// External mount: list files from the provider (WebDAV/NextCloud
|
||||
// PROPFIND Depth:1 file loop). Authz collapses on the mount root.
|
||||
// Keyset pagination by name mirrors `paginate_mount_entries` —
|
||||
// provider order isn't guaranteed, so sort before slicing on
|
||||
// `after_name`.
|
||||
if let Some(fid) = folder_id
|
||||
&& let Some(router) = &self.mount_router
|
||||
{
|
||||
use crate::application::services::external_mount_router::ResolvedId;
|
||||
let resolved = match router.classify(fid) {
|
||||
ResolvedId::Regular => None,
|
||||
ResolvedId::MountRoot { cfg } => Some((
|
||||
cfg,
|
||||
crate::domain::services::external_mount_id::NodeId::default(),
|
||||
)),
|
||||
ResolvedId::MountRoot { cfg } => Some((cfg, NodeId::default())),
|
||||
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
||||
};
|
||||
if let Some((cfg, node)) = resolved {
|
||||
@@ -578,34 +749,49 @@ impl FileRetrievalUseCase for FileRetrievalService {
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let entries = cfg.provider.list_dir(&node).await?;
|
||||
let files: Vec<FileDto> = entries
|
||||
.iter()
|
||||
let mut entries: Vec<_> = cfg
|
||||
.provider
|
||||
.list_dir(&node)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.skip(offset.max(0) as usize)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.name.to_lowercase());
|
||||
let start = match after_name {
|
||||
Some(name) => entries
|
||||
.iter()
|
||||
.position(|e| name.eq_ignore_ascii_case(&e.name))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let files: Vec<FileDto> = entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(limit.max(0) as usize)
|
||||
.map(|e| {
|
||||
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, e)
|
||||
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, &e)
|
||||
})
|
||||
.collect();
|
||||
return Ok(files);
|
||||
}
|
||||
}
|
||||
|
||||
if folder_id.is_some() {
|
||||
// folder id is defined, check permissions
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch(folder_id, offset, limit)
|
||||
.await?;
|
||||
return Ok(files.into_iter().map(FileDto::from).collect());
|
||||
}
|
||||
|
||||
// Post-D0: every file lives in a folder — `storage.files.folder_id`
|
||||
// is NOT NULL. `folder_id = None` means the caller is asking for
|
||||
// "root-level files", which by design return an empty set: the
|
||||
// WebDAV synthetic root only lists drive-root folders as
|
||||
// children. Skip the DB round-trip and the pre-D7 owner-fallback
|
||||
// query (which used to hit `_for_owner` and would have driven
|
||||
// the `files.user_id` filter this refactor is retiring).
|
||||
let Some(_) = folder_id else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
||||
.await?;
|
||||
let files = self
|
||||
.file_read
|
||||
.list_files_batch_for_owner(folder_id, owner_id, offset, limit)
|
||||
.list_files_batch(folder_id, after_name, limit)
|
||||
.await?;
|
||||
Ok(files.into_iter().map(FileDto::from).collect())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::file_ports::{FileUploadUseCase, StoredBlob};
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort, StorageUsagePort};
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
use crate::common::errors::DomainError;
|
||||
@@ -14,7 +15,7 @@ use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{Instrument, info, warn};
|
||||
|
||||
/// Service for file upload operations.
|
||||
///
|
||||
@@ -35,6 +36,22 @@ pub struct FileUploadService {
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
/// Single lifecycle dispatcher — fires on_file_created / on_file_updated.
|
||||
file_lifecycle_hook: Option<Arc<dyn FileLifecycleHook>>,
|
||||
/// Read-event hook — fires "caller just touched this file" so Recent
|
||||
/// records uploads / overwrites alongside reads. Distinct from
|
||||
/// `file_lifecycle_hook` because the lifecycle dispatcher only knows
|
||||
/// `(file_id, blob_hash, content_type)`; the recording side needs the
|
||||
/// `caller_id` the service already has in hand.
|
||||
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
||||
/// ReBAC engine — enforces `Permission::Update` on
|
||||
/// overwrite-existing and `Permission::Create` on new-file paths
|
||||
/// inside `update_file_streaming_with_perms`. Optional at the
|
||||
/// struct level for the minimal test constructors (`new`,
|
||||
/// `new_with_read`) but the WebDAV/NC/WOPI put paths refuse
|
||||
/// (fail-closed internal error) if this isn't wired. Set by
|
||||
/// either `with_instant_upload` or `with_authorization` — both
|
||||
/// stash the same Arc so DI callers wiring instant upload get
|
||||
/// the streaming gate for free.
|
||||
authorization: Option<Arc<PgAclEngine>>,
|
||||
/// Dependencies of the instant-upload path
|
||||
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
|
||||
/// wiring.
|
||||
@@ -58,6 +75,8 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
@@ -73,18 +92,35 @@ impl FileUploadService {
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_lifecycle_hook: None,
|
||||
resource_access_hook: None,
|
||||
authorization: None,
|
||||
instant_upload: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the authorization engine used by
|
||||
/// `update_file_streaming_with_perms` on the WebDAV / NC / WOPI
|
||||
/// PUT path. Independent of `with_instant_upload` so callers can
|
||||
/// enable the streaming gate without also opting into the
|
||||
/// dedup-instant-upload check (test wiring, minimal deployments).
|
||||
pub fn with_authorization(mut self, authz: Arc<PgAclEngine>) -> Self {
|
||||
self.authorization = Some(authz);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the authorization engine, dedup index and quota service that
|
||||
/// power the instant-upload path.
|
||||
///
|
||||
/// Also stashes the `authz` handle in `self.authorization` so
|
||||
/// DI callers wiring instant upload get the streaming-put gate
|
||||
/// for free — a single `Arc` clone, no behavioural coupling.
|
||||
pub fn with_instant_upload(
|
||||
mut self,
|
||||
authz: Arc<PgAclEngine>,
|
||||
dedup: Arc<DedupService>,
|
||||
quota: Arc<StorageUsageService>,
|
||||
) -> Self {
|
||||
self.authorization = Some(authz.clone());
|
||||
self.instant_upload = Some(InstantUploadDeps {
|
||||
authz,
|
||||
dedup,
|
||||
@@ -105,6 +141,19 @@ impl FileUploadService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers the read/write access hook (Recent list recorder).
|
||||
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
||||
self.resource_access_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Internal helper: fire the access hook if registered.
|
||||
fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
||||
if let Some(hook) = &self.resource_access_hook {
|
||||
hook.on_file_accessed(caller_id, file_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures the storage usage service
|
||||
pub fn with_storage_usage_service(
|
||||
mut self,
|
||||
@@ -256,7 +305,7 @@ impl FileUploadService {
|
||||
let file = file_read.get_file(file_id).await?;
|
||||
let (new_hash, updated_at) = self
|
||||
.file_write
|
||||
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id)
|
||||
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id, None)
|
||||
.await?;
|
||||
// The file maps to a different blob now — stale cached content must
|
||||
// never be served for the rest of its TTI window.
|
||||
@@ -274,7 +323,6 @@ impl FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileUpload", format!("rebuild entity: {e}")))?;
|
||||
@@ -282,6 +330,9 @@ impl FileUploadService {
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_updated(file_id, &dto.content_hash, &dto.mime_type);
|
||||
}
|
||||
// Delta-upload commit path — record the swap so Recent reflects
|
||||
// "this is the file I just delta-updated".
|
||||
self.notify_file_accessed(caller_id, file_id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
@@ -292,30 +343,81 @@ impl FileUploadService {
|
||||
/// Incremental (`+size`, O(1)) and fire-and-forget on a background task, so
|
||||
/// it adds neither latency nor a `SUM(size)` over the user's whole library
|
||||
/// to the upload path (the previous full recompute was O(N) per upload,
|
||||
/// O(N²) for a bulk upload). Keyed by the file's `owner_id`; drift — e.g.
|
||||
/// deletes, which don't decrement — is reconciled by the periodic sweep. A
|
||||
/// DTO without a resolvable owner is simply left to that sweep.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
||||
/// O(N²) for a bulk upload). Drift — e.g. deletes, which don't decrement —
|
||||
/// is reconciled by the periodic sweep.
|
||||
///
|
||||
/// Post-D7: `file.owner_id` is now nullable and unpopulated on new
|
||||
/// rows, so the envelope owner comes from `caller_id` (the user who
|
||||
/// just did the upload). The user-side delta is guarded by
|
||||
/// `add_user_storage_usage_delta_if_personal` — it only fires when
|
||||
/// the target drive is `kind='personal'`, so a shared-drive upload
|
||||
/// still doesn't touch any user envelope.
|
||||
fn maybe_update_storage_usage(&self, file: &FileDto, caller_id: Uuid) {
|
||||
self.apply_storage_usage_delta(file.size as i64, &file.folder_id, caller_id);
|
||||
}
|
||||
|
||||
/// Same as [`Self::maybe_update_storage_usage`] but takes an explicit
|
||||
/// `delta` instead of assuming "whole file size" — the overwrite path
|
||||
/// (`update_file_streaming_with_perms`) needs `new_size - old_size`,
|
||||
/// not the new size added a second time on top of what the old
|
||||
/// content already contributed.
|
||||
fn apply_storage_usage_delta(&self, delta: i64, folder_id: &Option<String>, caller_id: Uuid) {
|
||||
let Some(storage_service) = &self.storage_usage_service else {
|
||||
return;
|
||||
};
|
||||
let Some(owner) = file
|
||||
.owner_id
|
||||
.as_deref()
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
else {
|
||||
if delta == 0 {
|
||||
return;
|
||||
};
|
||||
let delta = file.size as i64;
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_user_storage_usage_delta(owner, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump storage usage for {owner}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let owner = Some(caller_id);
|
||||
let folder = folder_id.as_deref().and_then(|s| Uuid::parse_str(s).ok());
|
||||
|
||||
// Per-user delta — only when the target drive is `kind='personal'`.
|
||||
// The user envelope (`auth.users.storage_quota_bytes`) caps the SUM
|
||||
// of `used_bytes` across the user's personal drives; shared-drive
|
||||
// uploads do NOT count against any user. See
|
||||
// `docs/plan/drive.md` §7.
|
||||
//
|
||||
// The discrimination happens in one SQL statement via an EXISTS
|
||||
// subquery on the folder's drive kind — no extra round-trip vs
|
||||
// the unconditional delta. Without a folder id (root-level
|
||||
// upload — folder service refuses these) the user-side delta is
|
||||
// simply skipped; the sweep reconciles regardless.
|
||||
if let (Some(owner), Some(folder)) = (owner, folder) {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_user_storage_usage_delta_if_personal(owner, folder, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump user storage for {owner} (folder {folder}): {e}");
|
||||
}
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
|
||||
// Per-drive delta (D4) — same fire-and-forget shape, resolves
|
||||
// the drive id from the file's parent folder in one SQL
|
||||
// statement. `storage.drives.used_bytes` is what the per-drive
|
||||
// quota check and the picker quota bar read; drift from
|
||||
// deletes / trash is reconciled by the same sweep that handles
|
||||
// user-side drift.
|
||||
if let Some(folder) = folder {
|
||||
let service_clone = Arc::clone(storage_service);
|
||||
tokio::spawn(
|
||||
async move {
|
||||
if let Err(e) = service_clone
|
||||
.add_drive_storage_usage_delta_by_folder(folder, delta)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to bump drive usage for folder {folder}: {e}");
|
||||
}
|
||||
}
|
||||
.in_current_span(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,16 +447,67 @@ impl FileUploadUseCase for FileUploadService {
|
||||
"📡 STREAMING UPLOAD: {} ({} bytes, ID: {})",
|
||||
name, blob.size, dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
self.maybe_update_storage_usage(&dto, caller_id);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_created(&dto.id, &dto.content_hash, &dto.mime_type, blob.is_new_blob);
|
||||
}
|
||||
// The caller just created this file — surface it in Recent so the
|
||||
// "I just uploaded X" UX matches the pre-SvelteKit behaviour.
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
/// AuthZ audit #17 — `Create` on target folder is re-verified here
|
||||
/// so mid-session grant revocations take effect at finalize. When
|
||||
/// `folder_id` is `None` the write lands at drive-root; the drive
|
||||
/// resolution for that case isn't plumbed through the chunked-
|
||||
/// upload session (`UploadSession.folder_id` alone), so we fall
|
||||
/// back to the pre-audit behaviour there. That drive-root path is
|
||||
/// tracked separately as part of the D0 folder-id-walking work;
|
||||
/// closing it here would require session-scoped drive_id.
|
||||
async fn upload_file_streaming_with_perms(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
blob: StoredBlob,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
if let Some(fid) = folder_id.as_deref() {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"upload_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
let folder_uuid = Uuid::parse_str(fid)
|
||||
.map_err(|_| DomainError::not_found("Folder", fid.to_string()))?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
Resource::Folder(folder_uuid),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.upload_file_streaming(name, folder_id, content_type, blob, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Swap the content of the file at `path` to an already-ingested blob,
|
||||
/// creating the file when it doesn't exist (WebDAV/NextCloud/WOPI PUT).
|
||||
async fn update_file_streaming(
|
||||
///
|
||||
/// AuthZ (post-Drive audit Round 2 fix): overwrite path requires
|
||||
/// `Update` on the target file; new-file path requires `Create`
|
||||
/// on the parent folder (or on the drive when writing at drive
|
||||
/// root). Fail-closed if the engine wasn't wired — this method
|
||||
/// is the last line of defence between a Viewer/Commenter drive
|
||||
/// member and cross-tenant PUT. See
|
||||
/// `docs/plan/authz_audit/nextcloud.md` and the sibling native
|
||||
/// `/webdav/*` handler.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update_file_streaming_with_perms(
|
||||
&self,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
@@ -362,11 +515,36 @@ impl FileUploadUseCase for FileUploadService {
|
||||
content_type: &str,
|
||||
modified_at: Option<i64>,
|
||||
caller_id: Uuid,
|
||||
expected_hash: Option<&str>,
|
||||
) -> Result<FileDto, DomainError> {
|
||||
let Some(authz) = &self.authorization else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FileUpload",
|
||||
"update_file_streaming_with_perms called without authorization engine wired",
|
||||
));
|
||||
};
|
||||
|
||||
// Try to find the existing file first
|
||||
if let Some(file_read) = &self.file_read
|
||||
&& let Some(file) = file_read.find_file_by_path(path, drive_id).await?
|
||||
{
|
||||
// Overwrite branch — caller must have `Update` on the
|
||||
// target file. Denial routes through `require` → 404
|
||||
// (anti-enum, matches read-side shape). Before the D7
|
||||
// audit this whole branch ran unchecked; Viewer members
|
||||
// of shared drives could PUT freely.
|
||||
let file_uuid = Uuid::parse_str(file.id()).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid file id from repository")
|
||||
})?;
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Update,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let old_size = file.size();
|
||||
let file_id = file.id().to_string();
|
||||
let (new_hash, updated_at) = self
|
||||
.file_write
|
||||
@@ -376,6 +554,7 @@ impl FileUploadUseCase for FileUploadService {
|
||||
blob.size,
|
||||
modified_at,
|
||||
caller_id,
|
||||
expected_hash,
|
||||
)
|
||||
.await?;
|
||||
// Invalidate content cache — file content has changed.
|
||||
@@ -396,16 +575,21 @@ impl FileUploadUseCase for FileUploadService {
|
||||
parts.folder_id,
|
||||
parts.created_at,
|
||||
updated_at as u64,
|
||||
parts.owner_id,
|
||||
new_hash,
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileUpload", format!("rebuild entity: {e}"))
|
||||
})?;
|
||||
let dto = FileDto::from(updated);
|
||||
self.apply_storage_usage_delta(
|
||||
blob.size as i64 - old_size as i64,
|
||||
&dto.folder_id,
|
||||
caller_id,
|
||||
);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_updated(&file_id, &dto.content_hash, content_type);
|
||||
}
|
||||
self.notify_file_accessed(caller_id, &file_id);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
@@ -435,6 +619,32 @@ impl FileUploadUseCase for FileUploadService {
|
||||
None
|
||||
};
|
||||
|
||||
// Create branch — caller must have `Create` on the parent
|
||||
// scope. Two cases:
|
||||
// * `parent_id.is_some()` → caller needs Create on the
|
||||
// parent Folder resource.
|
||||
// * `parent_id.is_none()` → the write lands at the drive
|
||||
// root (either the path was single-segment, or the
|
||||
// parent-folder lookup failed). We require Create on
|
||||
// the Drive itself — bundled with owner/editor/contributor
|
||||
// role_grants, refused for viewer/commenter.
|
||||
let create_resource = match &parent_id {
|
||||
Some(pid) => {
|
||||
let uuid = Uuid::parse_str(pid).map_err(|_| {
|
||||
DomainError::internal_error("FileUpload", "invalid parent folder id")
|
||||
})?;
|
||||
Resource::Folder(uuid)
|
||||
}
|
||||
None => Resource::Drive(drive_id),
|
||||
};
|
||||
authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Create,
|
||||
create_resource,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let is_new_blob = blob.is_new_blob;
|
||||
let created = self
|
||||
.file_write
|
||||
@@ -448,9 +658,11 @@ impl FileUploadUseCase for FileUploadService {
|
||||
)
|
||||
.await?;
|
||||
let dto = FileDto::from(created);
|
||||
self.maybe_update_storage_usage(&dto, caller_id);
|
||||
if let Some(hook) = &self.file_lifecycle_hook {
|
||||
hook.on_file_created(&dto.id, &dto.content_hash, content_type, is_new_blob);
|
||||
}
|
||||
self.notify_file_accessed(caller_id, &dto.id);
|
||||
Ok(dto)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ use crate::application::dtos::folder_dto::{
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::external_mount_ports::MountEntry;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
|
||||
use crate::application::services::file_lifecycle_service::FileLifecycleService;
|
||||
use crate::application::services::mount_dto::{
|
||||
audit_mount_write, mount_entry_folder_dto, mount_folder_dto, mount_parent_id,
|
||||
};
|
||||
@@ -28,6 +30,22 @@ pub struct FolderService {
|
||||
/// External-mount classifier. Lets folder operations branch a mount-root or
|
||||
/// `ext:` id onto the provider instead of the PostgreSQL repositories.
|
||||
mount_router: Arc<MountRouter>,
|
||||
/// File lifecycle dispatcher. Carried so `delete_folder_with_perms`
|
||||
/// can fire `on_file_deleted` for every file the PG cascade is about
|
||||
/// to reap. Always present — the dispatcher itself is a no-op when
|
||||
/// no hooks are registered, so callers don't need an Option branch.
|
||||
file_lifecycle: Arc<FileLifecycleService>,
|
||||
/// Drive repository — used by D5's `forbid_cross_drive_move` gate
|
||||
/// on `move_folder_with_perms`. Optional so stubs / test factories
|
||||
/// can build the service without wiring the full drive repo; in
|
||||
/// that case the cross-drive move check is skipped (the policy is
|
||||
/// silently off). Production DI wires it via `with_drive_repo`.
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
/// Storage-usage service — used to pre-check the destination
|
||||
/// drive's `used_bytes + subtree_bytes ≤ quota_bytes` invariant
|
||||
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
|
||||
storage_usage:
|
||||
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
|
||||
}
|
||||
|
||||
impl FolderService {
|
||||
@@ -35,12 +53,16 @@ impl FolderService {
|
||||
pub fn new(
|
||||
folder_storage: Arc<FolderDbRepository>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
file_lifecycle: Arc<FileLifecycleService>,
|
||||
mount_router: Arc<MountRouter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
folder_storage,
|
||||
authz,
|
||||
mount_router,
|
||||
file_lifecycle,
|
||||
drive_repo: None,
|
||||
storage_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +121,31 @@ impl FolderService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository, enabling D5
|
||||
/// `forbid_cross_drive_move` enforcement on
|
||||
/// `move_folder_with_perms`. Without it, the gate is silently
|
||||
/// skipped.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wires the storage-usage service so `move_folder_with_perms`
|
||||
/// can pre-check the destination drive's quota on cross-drive
|
||||
/// folder moves.
|
||||
pub fn with_storage_usage(
|
||||
mut self,
|
||||
storage_usage: Arc<
|
||||
crate::application::services::storage_usage_service::StorageUsageService,
|
||||
>,
|
||||
) -> Self {
|
||||
self.storage_usage = Some(storage_usage);
|
||||
self
|
||||
}
|
||||
|
||||
/// Batch counterpart of `get_folder`: resolve many folder ids in ONE
|
||||
/// query instead of one per id. Like `get_folder` it performs no
|
||||
/// per-folder authorization — both current callers (ACL grant listing,
|
||||
@@ -435,18 +482,18 @@ impl FolderUseCase for FolderService {
|
||||
.await?;
|
||||
return self.list_folders(parent_id).await;
|
||||
}
|
||||
// No parent → list the user's root folders.
|
||||
// No parent → list the caller's readable root folders. The
|
||||
// predicate scopes by drive-membership grants (post-PR-B),
|
||||
// closing the pre-D7 gap where the legacy `user_id` filter
|
||||
// surfaced admin-created folders that admin had no role on.
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner(parent_id, caller_id)
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' in parent {:?}: {}",
|
||||
caller_id, parent_id, e
|
||||
),
|
||||
format!("Failed to list root folders for caller '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
@@ -487,6 +534,62 @@ impl FolderUseCase for FolderService {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Keyset-paged sub-folder listing (name order), caller-scoped.
|
||||
///
|
||||
/// AuthZ mirrors `list_folders_paginated_with_perms`: one
|
||||
/// `authz.require(Read)` on the parent per batch; root scope goes
|
||||
/// through the caller's drive-membership listing.
|
||||
async fn list_folders_batch_with_perms(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
after_name: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<FolderDto>, DomainError> {
|
||||
match parent_id {
|
||||
Some(pid) => {
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(pid)?,
|
||||
)
|
||||
.await?;
|
||||
let folders = self
|
||||
.folder_storage
|
||||
.list_folders_batch(parent_id, after_name, limit)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list folders in parent {pid}: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(folders.into_iter().map(FolderDto::from).collect())
|
||||
}
|
||||
None => {
|
||||
// Root scope: one row per readable drive — a handful.
|
||||
let mut all = self
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller(caller_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to batch-list root folders for '{caller_id}': {e}"),
|
||||
)
|
||||
})?;
|
||||
all.sort_by(|a, b| a.name().cmp(b.name()));
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
||||
.take(limit)
|
||||
.map(FolderDto::from)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists folders with pagination, scoped to a specific owner.
|
||||
async fn list_folders_paginated_with_perms(
|
||||
&self,
|
||||
@@ -534,24 +637,23 @@ impl FolderUseCase for FolderService {
|
||||
return self.list_folders_paginated(parent_id, &pagination).await;
|
||||
} else {
|
||||
let (folders, total_items) = self
|
||||
.folder_storage
|
||||
.list_folders_by_owner_paginated(
|
||||
parent_id,
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list folders for owner '{}' with pagination in parent {:?}: {}",
|
||||
owner_id, parent_id, e
|
||||
),
|
||||
.folder_storage
|
||||
.list_root_folders_for_caller_paginated(
|
||||
owner_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
})?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!(
|
||||
"Failed to list root folders for caller '{}' with pagination: {}",
|
||||
owner_id, e
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total = total_items.unwrap_or(folders.len());
|
||||
|
||||
@@ -630,7 +732,7 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
let renamed = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name, caller_id)
|
||||
.await
|
||||
@@ -641,7 +743,26 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
// Root folders double as the drive's display name (see the
|
||||
// `required_perm` branch above and `drive_pg_repository.rs`
|
||||
// `readable_cache` + `default_drive_cache` docs).
|
||||
// `drives.name` is sourced from `folders.name` of the root
|
||||
// folder, so a rename affects BOTH caches — every user's
|
||||
// readable-drive list AND the per-user default-drive lookup.
|
||||
// Both are 30 s TTL; without the invalidation, `GET /api/drives`
|
||||
// returns the stale name for up to that window after a root
|
||||
// rename. Surfaced by `tests/api/drives_membership.hurl`
|
||||
// Step 23. Regression from commit `12dc648c` ("perf: round 4 —
|
||||
// drive-selector cache") which added the caches without
|
||||
// wiring the root-rename invalidation.
|
||||
if folder.parent_id().is_none()
|
||||
&& let Some(drive_repo) = &self.drive_repo
|
||||
{
|
||||
drive_repo.invalidate_readable_all();
|
||||
drive_repo.invalidate_default_drive_all();
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(renamed))
|
||||
}
|
||||
|
||||
/// Moves a folder to a new parent. Requires `Update` on the source and
|
||||
@@ -712,6 +833,60 @@ impl FolderUseCase for FolderService {
|
||||
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
|
||||
}
|
||||
|
||||
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
|
||||
// audit share the same src/dst lookup. Gate before the move,
|
||||
// audit after a successful move when the two drives differ.
|
||||
// Skipped for parent_id=None (root namespace, same-drive
|
||||
// semantics) and when drive_repo isn't wired (stubs/tests) —
|
||||
// same shape as `move_file_with_perms`.
|
||||
let mut cross_drive: Option<(Uuid, Uuid)> = None;
|
||||
if let Some(drive_repo) = &self.drive_repo
|
||||
&& let Some(parent_id) = &dto.parent_id
|
||||
{
|
||||
let src_folder_uuid =
|
||||
Uuid::parse_str(id).map_err(|_| DomainError::not_found("Folder", id))?;
|
||||
let dst_folder_uuid = Uuid::parse_str(parent_id)
|
||||
.map_err(|_| DomainError::not_found("Folder", parent_id.as_str()))?;
|
||||
// Independent point reads — overlapped so the pre-move drive
|
||||
// resolution pays one round-trip, not two (ROUND10, same shape
|
||||
// as `move_file_with_perms`).
|
||||
let (src_res, dst_res) = tokio::join!(
|
||||
drive_repo.get_drive_id_and_policies_for_folder(src_folder_uuid),
|
||||
drive_repo.drive_id_for_folder(dst_folder_uuid),
|
||||
);
|
||||
let (src_drive_id, src_policies) = src_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("source drive lookup: {e:?}"))
|
||||
})?;
|
||||
let dst_drive_id = dst_res.map_err(|e| {
|
||||
DomainError::internal_error("Drive", format!("destination drive lookup: {e:?}"))
|
||||
})?;
|
||||
if src_drive_id != dst_drive_id {
|
||||
src_policies.refuse_cross_drive_move(
|
||||
crate::domain::entities::drive::CrossDriveMoveGateContext {
|
||||
caller_id,
|
||||
resource_type: "folder",
|
||||
resource_id: src_folder_uuid,
|
||||
src_drive_id,
|
||||
dst_drive_id,
|
||||
},
|
||||
)?;
|
||||
// Destination drive quota: sum the moved subtree's
|
||||
// non-trashed files and refuse if the destination
|
||||
// couldn't hold them. Same 507 shape as the file
|
||||
// path + upload path — DomainError::QuotaExceeded
|
||||
// maps at the AppError boundary.
|
||||
if let Some(storage_usage) = &self.storage_usage {
|
||||
let subtree_bytes = storage_usage.folder_subtree_bytes(src_folder_uuid).await?;
|
||||
if let Ok(subtree_u64) = u64::try_from(subtree_bytes) {
|
||||
storage_usage
|
||||
.check_drive_quota(dst_drive_id, subtree_u64)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
cross_drive = Some((src_drive_id, dst_drive_id));
|
||||
}
|
||||
}
|
||||
|
||||
let parent_ref = dto.parent_id.as_deref();
|
||||
let folder = self
|
||||
.folder_storage
|
||||
@@ -724,12 +899,46 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Cross-drive move flushes the authz engine's `owner_cache`
|
||||
// — every descendant's cached `Resource → drive_id` mapping
|
||||
// just got stale via the cascade trigger, and we don't (yet)
|
||||
// walk the subtree to invalidate individually. Small perf
|
||||
// cost (single JOIN per resource touched over the next
|
||||
// minute) versus a stale-authz bug where destination-drive
|
||||
// Owner cascades don't apply to moved content.
|
||||
if cross_drive.is_some() {
|
||||
self.authz.invalidate_owner_cache_all().await;
|
||||
}
|
||||
|
||||
// D6 audit: only emit when the move crossed a drive boundary.
|
||||
// The cascade trigger has already propagated drive_id to the
|
||||
// subtree at this point (see migration
|
||||
// `20260807000000_cascade_drive_id_on_folder_move.sql`).
|
||||
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "resource.moved_between_drives",
|
||||
resource_type = "folder",
|
||||
resource_id = %folder.id(),
|
||||
src_drive_id = %src_drive_id,
|
||||
dst_drive_id = %dst_drive_id,
|
||||
by = %caller_id,
|
||||
"📦 folder moved between drives",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
/// Deletes a folder after verifying the caller has `Delete` permission.
|
||||
/// The DB trigger `trg_cleanup_grants_folder` cleans up `access_grants`
|
||||
/// rows targeting the deleted folder automatically.
|
||||
///
|
||||
/// Enumerates the subtree's file ids BEFORE the bulk DELETE so
|
||||
/// `on_file_deleted` fires per file the PG cascade is about to reap —
|
||||
/// without this, file-id-keyed lifecycle data (e.g. `ext-{file_id}.jpg`
|
||||
/// video thumbnails, moka cache entries) leaks past the cascade.
|
||||
/// Same shape `clear_trash_in` uses (`trash_service.rs:804-846`).
|
||||
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
// External mount: delete on the provider (permanent — mounts have no
|
||||
// trash). The mount root is a real folder row and is not deletable here.
|
||||
@@ -758,12 +967,28 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Snapshot the file ids BEFORE the bulk DELETE — the rows are gone
|
||||
// afterward. Failure to enumerate is non-fatal (logged in the repo
|
||||
// method); the delete proceeds and only file-id-keyed cleanup is
|
||||
// skipped (blob-keyed thumbnails still get reaped by GC).
|
||||
let cascaded_file_ids = self
|
||||
.folder_storage
|
||||
.list_file_ids_in_subtree(id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
self.folder_storage.delete_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to delete folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
for file_id in &cascaded_file_ids {
|
||||
self.file_lifecycle.on_file_deleted(file_id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,9 +1327,22 @@ impl PersonalDriveLifecycleHook {
|
||||
// parent_id=NULL, drive_id pinned) + drives.root_folder_id
|
||||
// wire-up + Owner role_grant. Single SQL statement, atomic
|
||||
// against server crash mid-sequence (docs/plan/drive.md §3).
|
||||
//
|
||||
// `quota_bytes = None` (NULL in the DB) is the invariant for
|
||||
// every personal drive per plan §7: the cap for a user's
|
||||
// personal storage lives on `auth.users.storage_quota_bytes`
|
||||
// (the user envelope), not on the drive row. Passing
|
||||
// `Some(user.storage_quota_bytes())` here previously baked
|
||||
// the user quota into `drives.quota_bytes` and — combined
|
||||
// with the "0 = unlimited" convention on the user check but
|
||||
// "0 = literal zero" convention on the drive check — turned
|
||||
// "unlimited user" into "0-byte drive" (see #595). The
|
||||
// migration `20260916000000_null_personal_drive_quota.sql`
|
||||
// heals existing rows and adds a CHECK constraint pinning
|
||||
// this invariant at the schema layer.
|
||||
let drive_with_name = self
|
||||
.drive_repo
|
||||
.create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes()))
|
||||
.create_personal_drive_atomic(user.id(), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -1142,6 +1380,17 @@ impl UserLifecycleHook for PersonalDriveLifecycleHook {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
/// External → internal upgrade. `on_user_created` fired at signup
|
||||
/// with `is_external=true` and short-circuited in
|
||||
/// `provision_if_needed`. The user is now internal — same helper
|
||||
/// runs, but this time the `is_external` guard passes through and
|
||||
/// the atomic CTE creates their default drive + root folder +
|
||||
/// owner grant. Idempotent by construction: a rerun after a partial
|
||||
/// failure hits the `find_default_for_user` short-circuit.
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
self.provision_if_needed(user).await
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
// Drives don't react to logout. Explicit no-op per the
|
||||
// "no defaults" convention.
|
||||
@@ -1415,6 +1664,7 @@ mod mount_authz_integration {
|
||||
let fs = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router,
|
||||
);
|
||||
(fs, p.mount_folder_id.to_string(), p.owner_id)
|
||||
@@ -1610,6 +1860,7 @@ mod mount_authz_integration {
|
||||
let folder_service = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(&pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router.clone(),
|
||||
);
|
||||
let retrieval = FileRetrievalService::new_with_authz_for_test(
|
||||
@@ -1659,7 +1910,7 @@ mod mount_authz_integration {
|
||||
|
||||
// PROPFIND Depth:1 file loop: list files of the mount root.
|
||||
let files = retrieval
|
||||
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, 0, 100)
|
||||
.list_files_batch_with_perms(Some(&p.mount_folder_id.to_string()), p.owner_id, None, 100)
|
||||
.await
|
||||
.expect("list mount files");
|
||||
assert_eq!(
|
||||
@@ -1673,7 +1924,6 @@ mod mount_authz_integration {
|
||||
.get_file_by_path(&format!("{}/sub/b.txt", root.path), p.drive_id)
|
||||
.await
|
||||
.expect("nested file");
|
||||
use futures::TryStreamExt as _;
|
||||
let content: Vec<u8> = Box::into_pin(retrieval.get_file_stream(&nested.id).await.unwrap())
|
||||
.map_ok(|b| b.to_vec())
|
||||
.try_concat()
|
||||
@@ -1745,6 +1995,7 @@ mod mount_authz_integration {
|
||||
let folder_service = FolderService::new(
|
||||
Arc::new(FolderDbRepository::new(pool.clone())),
|
||||
acl(&pool),
|
||||
Arc::new(crate::application::services::file_lifecycle_service::FileLifecycleService::new()),
|
||||
router.clone(),
|
||||
);
|
||||
|
||||
@@ -1834,3 +2085,227 @@ mod mount_authz_integration {
|
||||
assert_eq!(err.kind, crate::domain::errors::ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Integration test — verifies the folder-cascade hook fix lands `on_file_deleted`
|
||||
// for every file the PG cascade reaps when a folder is permanently deleted.
|
||||
//
|
||||
// Background: `delete_folder_with_perms` issues a bulk SQL DELETE that the PG
|
||||
// `ON DELETE CASCADE` fans out to descendant folders + files. Without
|
||||
// service-layer enumeration, file-id-keyed lifecycle data (thumbnails keyed
|
||||
// on `ext-{file_id}.jpg`, moka cache entries, future per-file metadata)
|
||||
// silently leaks. See [[bug-folder-cascade-hooks-missing]] in agent memory.
|
||||
//
|
||||
// How to run:
|
||||
// bash tests/common/spawn-db.sh
|
||||
// RUSTFLAGS='--cfg integration_tests' cargo test \
|
||||
// -p oxicloud --lib folder_service::cascade_hook_integration_tests
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
#[cfg(integration_tests)]
|
||||
#[allow(dead_code)]
|
||||
mod cascade_hook_integration_tests {
|
||||
use super::*;
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::services::dedup_service::DedupService;
|
||||
use crate::infrastructure::services::local_blob_backend::LocalBlobBackend;
|
||||
use crate::integration_test_support::{ensure_clean_test_db, test_db_url};
|
||||
use sqlx::Row;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Records every `on_file_deleted` call so the test can assert the
|
||||
/// exact set of file ids the cascade fired hooks for. Other lifecycle
|
||||
/// methods are no-ops — this fix only touches the deletion path.
|
||||
#[derive(Default)]
|
||||
struct RecordingHook {
|
||||
deleted: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FileLifecycleHook for RecordingHook {
|
||||
fn on_file_created(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_content_type: &str,
|
||||
_is_new_blob: bool,
|
||||
) {
|
||||
}
|
||||
fn on_file_copied(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_content_type: &str,
|
||||
_source_file_id: &str,
|
||||
) {
|
||||
}
|
||||
fn on_file_updated(&self, _file_id: &str, _blob_hash: &str, _content_type: &str) {}
|
||||
fn on_file_deleted(&self, file_id: &str) {
|
||||
self.deleted.lock().unwrap().push(file_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_pool() -> Arc<sqlx::PgPool> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect(&test_db_url())
|
||||
.await
|
||||
.expect("connect to test DB — run tests/common/spawn-db.sh first");
|
||||
ensure_clean_test_db(&pool).await;
|
||||
Arc::new(pool)
|
||||
}
|
||||
|
||||
/// Returns `(user_id, drive_id, drive_root_folder_id)` — same default
|
||||
/// Personal drive every internal user gets post-D0 (provisioned by
|
||||
/// `PersonalDriveLifecycleHook`).
|
||||
async fn seed_user(pool: &sqlx::PgPool) -> (Uuid, Uuid, Uuid) {
|
||||
sqlx::query(
|
||||
"SELECT u.id AS user_id, d.id AS drive_id, d.root_folder_id
|
||||
FROM auth.users u
|
||||
JOIN storage.drives d ON d.default_for_user = u.id
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|r| {
|
||||
(
|
||||
r.get::<Uuid, _>("user_id"),
|
||||
r.get::<Uuid, _>("drive_id"),
|
||||
r.get::<Uuid, _>("root_folder_id"),
|
||||
)
|
||||
})
|
||||
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
|
||||
}
|
||||
|
||||
/// Build a real `PgAclEngine` against the test pool so
|
||||
/// `delete_folder_with_perms` can actually evaluate Owner — the user
|
||||
/// from `seed_user` owns the default drive, so `Permission::Delete`
|
||||
/// on its descendants resolves through the Owner short-circuit.
|
||||
async fn build_authz(
|
||||
pool: Arc<sqlx::PgPool>,
|
||||
dir: &TempDir,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Arc<PgAclEngine> {
|
||||
let backend = Arc::new(LocalBlobBackend::new(&dir.path().join("blobs")));
|
||||
backend.initialize().await.expect("init backend");
|
||||
let dedup = Arc::new(DedupService::new(backend, pool.clone(), pool.clone()));
|
||||
let file_repo = Arc::new(FileBlobReadRepository::new(
|
||||
pool.clone(),
|
||||
dedup,
|
||||
folder_repo.clone(),
|
||||
));
|
||||
let group_repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
/// Seed a file row under `folder_id`. `blob_hash` is just a string —
|
||||
/// `storage.files.blob_hash` is VARCHAR(64) without a FK, so no blob
|
||||
/// row is required. The cascade decrement trigger no-ops when the
|
||||
/// hash is unknown.
|
||||
async fn seed_file_under(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
label: &str,
|
||||
) -> Uuid {
|
||||
let blob_hash = blake3::hash(format!("cascade-{label}-{}", Uuid::new_v4()).as_bytes())
|
||||
.to_hex()
|
||||
.to_string();
|
||||
// Post-D7: `user_id` omitted — the column is nullable and
|
||||
// provenance flows through `created_by` / `updated_by`.
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO storage.files
|
||||
(name, drive_id, folder_id, blob_hash, size, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(format!(
|
||||
"rust-test-cascade-{label}-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
))
|
||||
.bind(drive_id)
|
||||
.bind(folder_id)
|
||||
.bind(&blob_hash)
|
||||
.bind(42i64)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seed file row")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_folder_with_perms_fires_hook_for_cascaded_files() {
|
||||
let pool = test_pool().await;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (user_id, drive_id, drive_root) = seed_user(&pool).await;
|
||||
|
||||
let folder_repo = Arc::new(FolderDbRepository::new(pool.clone()));
|
||||
let authz = build_authz(pool.clone(), &dir, folder_repo.clone()).await;
|
||||
let recorder: Arc<RecordingHook> = Arc::new(RecordingHook::default());
|
||||
let fls = Arc::new(
|
||||
crate::application::services::file_lifecycle_service::FileLifecycleService::new()
|
||||
.with_hook(recorder.clone() as Arc<dyn FileLifecycleHook>),
|
||||
);
|
||||
let service = FolderService::new(
|
||||
folder_repo.clone(),
|
||||
authz,
|
||||
fls,
|
||||
Arc::new(MountRouter::new(Arc::new(
|
||||
crate::application::services::mount_registry::MountRegistry::empty(),
|
||||
))),
|
||||
);
|
||||
|
||||
// Build parent/child via the production create path — it stamps
|
||||
// provenance and computes paths the same way as live uploads.
|
||||
let parent = folder_repo
|
||||
.create_folder(
|
||||
format!(
|
||||
"rust-test-cascade-parent-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
),
|
||||
Some(drive_root.to_string()),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
.expect("create parent");
|
||||
let child = folder_repo
|
||||
.create_folder(
|
||||
format!(
|
||||
"rust-test-cascade-child-{}",
|
||||
&Uuid::new_v4().to_string()[..8]
|
||||
),
|
||||
Some(parent.id().to_string()),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
.expect("create child");
|
||||
let child_uuid = Uuid::parse_str(child.id()).expect("child uuid");
|
||||
|
||||
// Two files: one directly under the parent, one nested under
|
||||
// child. The cascade should reap both; the hook must fire for both.
|
||||
let parent_uuid = Uuid::parse_str(parent.id()).expect("parent uuid");
|
||||
let direct_file = seed_file_under(&pool, user_id, drive_id, parent_uuid, "direct").await;
|
||||
let nested_file = seed_file_under(&pool, user_id, drive_id, child_uuid, "nested").await;
|
||||
|
||||
// Act — the production code path under test.
|
||||
service
|
||||
.delete_folder_with_perms(parent.id(), user_id)
|
||||
.await
|
||||
.expect("delete_folder_with_perms");
|
||||
|
||||
// Assert — every cascaded file id appears in the hook record.
|
||||
let captured = recorder.deleted.lock().unwrap().clone();
|
||||
assert!(
|
||||
captured.contains(&direct_file.to_string()),
|
||||
"expected on_file_deleted for direct-child file {direct_file}, got {captured:?}"
|
||||
);
|
||||
assert!(
|
||||
captured.contains(&nested_file.to_string()),
|
||||
"expected on_file_deleted for nested file {nested_file}, got {captured:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! Tests for IDOR (Insecure Direct Object Reference) protection.
|
||||
//!
|
||||
//! Verifies that ownership checks at the repository and service layers
|
||||
//! correctly reject access when the caller is not the file owner.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Mock repositories
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A simple in-memory mock that maps (file_id → (File, owner_id)).
|
||||
struct MockFileReadPort {
|
||||
/// file_id → (File, owner_id)
|
||||
files: Mutex<HashMap<String, (File, Uuid)>>,
|
||||
}
|
||||
|
||||
impl MockFileReadPort {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a test file owned by `owner_id`.
|
||||
fn insert(&self, id: &str, name: &str, owner_id: Uuid) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), (file, owner_id));
|
||||
}
|
||||
}
|
||||
|
||||
impl FileReadPort for MockFileReadPort {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(id)
|
||||
.map(|(f, _)| f.clone())
|
||||
.ok_or_else(|| DomainError::not_found("File", id.to_string()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
match files.get(id) {
|
||||
Some((file, actual_owner)) if *actual_owner == owner_id => Ok(file.clone()),
|
||||
// Return NotFound regardless — do not leak existence
|
||||
_ => Err(DomainError::not_found("File", id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
_id: &str,
|
||||
_start: u64,
|
||||
_end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, _id: &str) -> Result<StoragePath, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(
|
||||
&self,
|
||||
_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_blob_hash(&self, _file_id: &str) -> Result<String, DomainError> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn search_files_paginated(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn get_folder_id_by_path(
|
||||
&self,
|
||||
_folder_path: &str,
|
||||
_drive_id: Uuid,
|
||||
) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal mock write port — only `move_file` and `rename_file` need real logic.
|
||||
#[allow(dead_code)]
|
||||
struct MockFileWritePort {
|
||||
files: Mutex<HashMap<String, File>>,
|
||||
}
|
||||
|
||||
impl MockFileWritePort {
|
||||
#[allow(dead_code)]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
files: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn insert(&self, id: &str, name: &str) {
|
||||
let file = File::new(
|
||||
id.to_string(),
|
||||
name.to_string(),
|
||||
StoragePath::from_string(&format!("/{}", name)),
|
||||
42,
|
||||
"text/plain".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
self.files.lock().unwrap().insert(id.to_string(), file);
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileWritePort {
|
||||
async fn save_file_with_blob(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_new_name: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
let files = self.files.lock().unwrap();
|
||||
files
|
||||
.get(file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id.to_string()))
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content_with_blob(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_blob_hash: &str,
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
_name: String,
|
||||
_folder_id: Option<String>,
|
||||
_content_type: String,
|
||||
_size: u64,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_target_folder_id: Option<String>,
|
||||
_new_name: Option<&str>,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<File, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
_file_id: &str,
|
||||
_original_path: &str,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, _file_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileReadPort::get_file_for_owner (Repository layer, Solution C)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_file_for_correct_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", alice_id).await;
|
||||
assert!(result.is_ok(), "owner should be able to read own file");
|
||||
assert_eq!(result.unwrap().id(), "file-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_rejects_wrong_owner() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
let result = repo.get_file_for_owner("file-1", bob_id).await;
|
||||
assert!(result.is_err(), "non-owner should be rejected");
|
||||
|
||||
// Must be NotFound, NOT Forbidden — avoids leaking existence
|
||||
let err = result.unwrap_err();
|
||||
let msg = format!("{}", err);
|
||||
assert!(
|
||||
msg.contains("not found") || msg.contains("NotFound"),
|
||||
"error must be NotFound, got: {}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_file_for_owner_returns_not_found_for_missing_file() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
|
||||
let result = repo.get_file_for_owner("nonexistent", alice_id).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_uses_default_impl() {
|
||||
let alice_id = Uuid::new_v4();
|
||||
let bob_id = Uuid::new_v4();
|
||||
let repo = MockFileReadPort::new();
|
||||
repo.insert("file-1", "secret.txt", alice_id);
|
||||
|
||||
// Default impl delegates to get_file_for_owner and maps to ()
|
||||
assert!(repo.verify_file_owner("file-1", alice_id).await.is_ok());
|
||||
assert!(repo.verify_file_owner("file-1", bob_id).await.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — FileManagementService _owned methods (Service layer, Solution B)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Note: FileManagementService::with_trash takes concrete types for the write
|
||||
// repository (Arc<FileBlobWriteRepository>). We cannot construct real PG repos
|
||||
// without a database. Instead, we test the verify_owner logic indirectly by
|
||||
// testing the mock-based trait interactions at the port level, and document
|
||||
// that integration tests hitting the real DB are the ultimate verification.
|
||||
//
|
||||
// The tests below verify the *contract*: _owned methods must call
|
||||
// verify_owner before delegating, and verify_owner must fail-closed when
|
||||
// no read repo is available.
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_file_owner_delegates_to_read_port() {
|
||||
// This test verifies the FileReadPort contract that verify_file_owner
|
||||
// returns Ok for the correct owner and Err for others.
|
||||
let user_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("abc-123", "report.pdf", user_id);
|
||||
|
||||
// Same user → Ok
|
||||
let ok = read.verify_file_owner("abc-123", user_id).await;
|
||||
assert!(ok.is_ok(), "correct owner should pass verify_file_owner");
|
||||
|
||||
// Different user → Err
|
||||
let err = read.verify_file_owner("abc-123", attacker_id).await;
|
||||
assert!(err.is_err(), "wrong owner should fail verify_file_owner");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owned_methods_require_ownership_check_first() {
|
||||
// Simulate what the _owned methods do: verify_owner then delegate.
|
||||
// We test with the mock read port to prove the sequence.
|
||||
let owner_id = Uuid::new_v4();
|
||||
let attacker_id = Uuid::new_v4();
|
||||
let read = MockFileReadPort::new();
|
||||
read.insert("file-1", "data.csv", owner_id);
|
||||
|
||||
// Step 1: verify_owner for correct owner → Ok
|
||||
let step1 = read.verify_file_owner("file-1", owner_id).await;
|
||||
assert!(step1.is_ok());
|
||||
|
||||
// Step 2: verify_owner for attacker → Err, so the move/rename never executes
|
||||
let step2 = read.verify_file_owner("file-1", attacker_id).await;
|
||||
assert!(step2.is_err());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests — Trait-level _owned method stubs (StubFileManagementUseCase)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::common::stubs::StubFileManagementUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_move_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.move_file_with_perms("file-1", user_id, Some("folder-2".to_string()))
|
||||
.await;
|
||||
assert!(result.is_ok(), "stub should return Ok for move_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_rename_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileManagementUseCase;
|
||||
let result = stub
|
||||
.rename_file_with_perms("file-1", user_id, "new-name.txt")
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for rename_file_owned"
|
||||
);
|
||||
}
|
||||
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
use crate::common::stubs::StubFileRetrievalUseCase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub.get_file_with_perms("file-1", user_id).await;
|
||||
assert!(result.is_ok(), "stub should return Ok for get_file_owned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_get_file_optimized_owned_returns_ok() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let stub = StubFileRetrievalUseCase;
|
||||
let result = stub
|
||||
.get_file_optimized_with_perms("file-1", user_id, true, false)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"stub should return Ok for get_file_optimized_owned"
|
||||
);
|
||||
}
|
||||
@@ -307,20 +307,30 @@ impl MagicLinkInviteService {
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
// Drive sharing — and therefore drive magic-link invitations —
|
||||
// land in D2. The grant DTOs accept `Resource::Drive` from the
|
||||
// wire today (see ResourceTypeDto) but no public API path
|
||||
// actually grants on a drive in D0, so this arm is
|
||||
// defensively unreachable. Treating it as an audit-logged
|
||||
// no-op (grant is in place, mail suppressed) matches the
|
||||
// ineligible-recipient branch above.
|
||||
Resource::Drive(_) => {
|
||||
// Drive / Calendar / AddressBook / Playlist sharing is
|
||||
// out-of-band for the magic-link flow. Drive shares land
|
||||
// through `/api/drives/{id}/members`; Calendar /
|
||||
// AddressBook shares through the Round-3
|
||||
// `/api/(calendars|address-books)/{id}/shares` endpoints;
|
||||
// Playlist shares through `/api/playlists/{id}/share`.
|
||||
// The DTOs accept every `Resource` variant on the wire
|
||||
// (see `ResourceTypeDto`) but only file/folder grants
|
||||
// trigger an invitation email. Treating the other arms
|
||||
// as audit-logged suppressed no-ops keeps the grant in
|
||||
// place while matching the ineligible-recipient branch
|
||||
// above.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = "drive_resource_unsupported",
|
||||
reason = "resource_kind_unsupported",
|
||||
user_id = %recipient.id(),
|
||||
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
|
||||
resource_kind = %resource.type_str(),
|
||||
"📭 magic-link invitation suppressed: {} resources aren't invitable via email",
|
||||
resource.type_str(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -347,10 +357,13 @@ impl MagicLinkInviteService {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Unreachable — the early-return above exits before we get
|
||||
// here for a Drive resource. The arm exists only to satisfy
|
||||
// exhaustiveness; if you find this firing, the early-return
|
||||
// was bypassed.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// here for Drive / Calendar / AddressBook / Playlist
|
||||
// resources. The arms exist only to satisfy exhaustiveness;
|
||||
// if you find any firing, the early-return was bypassed.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
@@ -436,7 +449,8 @@ impl MagicLinkInviteService {
|
||||
/// is reserved for `resolve_or_create_recipient` — and if the
|
||||
/// matched user has no other login credential, mint a NULL-resource
|
||||
/// magic-link token and email a sign-in link. The redemption
|
||||
/// endpoint lands a NULL-resource token on `/#/sharedwithme`.
|
||||
/// endpoint lands a NULL-resource token on `/shared-with-me`
|
||||
/// (external users) or `/files` (internal users).
|
||||
///
|
||||
/// Always returns `Ok(())` so the caller can emit a uniform
|
||||
/// response shape (`"If an account exists, a link will be sent."`)
|
||||
@@ -602,6 +616,123 @@ impl MagicLinkInviteService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint + email a magic-link for **email verification**, called
|
||||
/// only after another authentication factor has already proven the
|
||||
/// caller's identity (currently: the login handler after a
|
||||
/// successful password check).
|
||||
///
|
||||
/// Contract: the caller MUST have validated the user's identity via
|
||||
/// an independent factor before invoking this. The method does NOT
|
||||
/// re-verify credentials — it exists specifically to bypass the
|
||||
/// `has_password` eligibility gate, which would otherwise deadlock
|
||||
/// the `OXICLOUD_REQUIRE_VERIFIED_EMAIL` flow (login rejected as
|
||||
/// unverified → user asks for a verification link → refused
|
||||
/// because they have a password).
|
||||
///
|
||||
/// Rejected: OIDC-linked users, deactivated users. Everything else
|
||||
/// gets a token — including the "has password" case that
|
||||
/// `send_login_link` refuses.
|
||||
pub async fn send_verification_link_authenticated(
|
||||
&self,
|
||||
user: &User,
|
||||
request_challenge: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// OIDC boundary is unconditional even here — the IdP owns the
|
||||
// identity contract and we must not mint a session-primitive
|
||||
// for a user it manages.
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "oidc_user",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: OIDC user",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !user.is_active() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "account_deactivated",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: account deactivated",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = MagicLinkToken::new(
|
||||
user.id(),
|
||||
chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64),
|
||||
None,
|
||||
Some(request_challenge.to_string()),
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
"{}/magic/v1/{}",
|
||||
self.public_base_url.trim_end_matches('/'),
|
||||
token.token(),
|
||||
);
|
||||
// Reuses the login email template for now — same call to
|
||||
// action (click the link), same TTL, same challenge binding.
|
||||
// A dedicated "verify your email" template can land later
|
||||
// without wire changes.
|
||||
let locale = self.locale_for(user);
|
||||
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
|
||||
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
|
||||
|
||||
let subject = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.login.subject",
|
||||
&locale,
|
||||
&login_args,
|
||||
)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: user.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "sent_verification",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %user.email(),
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔗 verify-link sent to '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send_failed",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
error = %e.message,
|
||||
"🔗 verify-link SMTP send failed for '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
|
||||
@@ -43,8 +43,6 @@ pub mod wopi_token_service;
|
||||
#[cfg(test)]
|
||||
mod batch_operations_test;
|
||||
#[cfg(test)]
|
||||
mod idor_protection_test;
|
||||
#[cfg(test)]
|
||||
mod trash_service_test;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
|
||||
@@ -58,7 +58,6 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) ->
|
||||
name: node_name(stat.node_id.as_str()).to_owned(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: stat.created_at,
|
||||
modified_at: stat.modified_at,
|
||||
@@ -66,8 +65,8 @@ pub fn mount_folder_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) ->
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +79,6 @@ pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountE
|
||||
name: entry.name.clone(),
|
||||
path: String::new(),
|
||||
parent_id: Some(parent_id.to_owned()),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
drive_id: cfg.drive_id,
|
||||
created_at: entry.created_at,
|
||||
modified_at: entry.modified_at,
|
||||
@@ -88,8 +86,8 @@ pub fn mount_entry_folder_dto(cfg: &MountConfig, parent_id: &str, entry: &MountE
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +110,11 @@ pub fn mount_entry_file_dto(cfg: &MountConfig, parent_id: &str, entry: &MountEnt
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, &mime)),
|
||||
category: Arc::from(category_for(name, &mime)),
|
||||
size_formatted: format_file_size(entry.size),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(entry.size, entry.modified_at),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,11 +136,10 @@ pub fn mount_file_dto(cfg: &MountConfig, parent_id: &str, stat: &MountStat) -> F
|
||||
icon_special_class: Arc::from(icon_special_class_for(name, mime)),
|
||||
category: Arc::from(category_for(name, mime)),
|
||||
size_formatted: format_file_size(stat.size),
|
||||
owner_id: Some(cfg.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash: String::new(),
|
||||
etag: virtual_file_etag(stat.size, stat.modified_at),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: Some(cfg.owner_id),
|
||||
updated_by: Some(cfg.owner_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -5,17 +6,80 @@ use crate::application::dtos::playlist_dto::{
|
||||
AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto,
|
||||
PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Music service — the REST entry point for every playlist or audio
|
||||
/// metadata operation. Every method routes through
|
||||
/// `AuthorizationEngine`; the pre-Round-3 `user_has_access` /
|
||||
/// `user_can_write` bespoke helpers on `MusicStorageAdapter` are no
|
||||
/// longer consulted for access decisions.
|
||||
///
|
||||
/// Ownership + sharing live entirely in `storage.role_grants`
|
||||
/// (`resource_type='playlist'`). `audio.playlists.owner_id` stays for
|
||||
/// provenance and legacy queries; `audio.playlist_shares` is
|
||||
/// backfilled and slated for removal in a follow-up migration.
|
||||
pub struct MusicService {
|
||||
storage: Arc<MusicStorageAdapter>,
|
||||
/// ReBAC engine — every user-facing method calls `authz.require`
|
||||
/// with the appropriate `Permission`. `create_playlist` also uses
|
||||
/// it to seed an Owner grant for the caller, so the common
|
||||
/// "owning my own playlist" case takes a single indexed
|
||||
/// role_grants lookup on subsequent reads.
|
||||
authz: Arc<PgAclEngine>,
|
||||
}
|
||||
|
||||
impl MusicService {
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>) -> Self {
|
||||
Self { storage }
|
||||
pub fn new(storage: Arc<MusicStorageAdapter>, authz: Arc<PgAclEngine>) -> Self {
|
||||
Self { storage, authz }
|
||||
}
|
||||
|
||||
/// Parse `playlist_id` and enforce `permission` on
|
||||
/// `Resource::Playlist(uuid)`. On denial `authz.require` returns
|
||||
/// `NotFound` (anti-enum — same shape as "no such playlist") and
|
||||
/// emits the `authz.denied` audit line. Returns the parsed UUID
|
||||
/// on success so the caller doesn't have to parse it a second
|
||||
/// time.
|
||||
async fn require_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<Uuid, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await?;
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
/// Check `permission` on a playlist without throwing. Used by the
|
||||
/// read paths that also allow a public-playlist bypass — they
|
||||
/// need a bool, not a `Result<(), NotFound>`.
|
||||
async fn has_playlist_perm(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
caller_id: Uuid,
|
||||
permission: Permission,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uuid = Uuid::parse_str(playlist_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid ID"))?;
|
||||
self.authz
|
||||
.check(
|
||||
Subject::User(caller_id),
|
||||
permission,
|
||||
Resource::Playlist(uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +89,26 @@ impl MusicUseCase for MusicService {
|
||||
dto: CreatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
self.storage.create_playlist(dto, user_id).await
|
||||
// No pre-write gate: creating a playlist is a personal act.
|
||||
// Storage stamps `owner_id = user_id`; we then seed an Owner
|
||||
// role_grant so subsequent reads hit the same
|
||||
// `storage.role_grants` fast path used everywhere else.
|
||||
let created = self.storage.create_playlist(dto, user_id).await?;
|
||||
let playlist_uuid = Uuid::parse_str(&created.id).map_err(|_| {
|
||||
DomainError::internal_error("Playlist", "storage returned invalid playlist id")
|
||||
})?;
|
||||
// `set_role` is idempotent on the `(subject, resource)` unique
|
||||
// key. `granted_by = user_id` is the self-seeded creation event.
|
||||
self.authz
|
||||
.set_role(
|
||||
user_id,
|
||||
Subject::User(user_id),
|
||||
Role::Owner,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
async fn update_playlist(
|
||||
@@ -34,45 +117,25 @@ impl MusicUseCase for MusicService {
|
||||
dto: UpdatePlaylistDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to update this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to update this playlist",
|
||||
));
|
||||
}
|
||||
self.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
self.storage.update_playlist(playlist_id, dto).await
|
||||
}
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can delete this playlist",
|
||||
));
|
||||
}
|
||||
self.storage.delete_playlist(playlist_id).await
|
||||
let uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Delete)
|
||||
.await?;
|
||||
self.storage.delete_playlist(playlist_id).await?;
|
||||
// Wipe every grant on this playlist so a re-used UUID
|
||||
// (impossible today but cheap to defend against) doesn't
|
||||
// inherit stale ACLs. The storage DELETE won't cascade to
|
||||
// `storage.role_grants` — it's cross-schema.
|
||||
let _ = self
|
||||
.authz
|
||||
.revoke_all_for_resource(Resource::Playlist(uuid))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_playlist(
|
||||
@@ -80,23 +143,22 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<PlaylistDto, DomainError> {
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
}
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
match playlist {
|
||||
Some(p) => Ok(p),
|
||||
None => Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
)),
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => return Err(DomainError::not_found("Playlist", playlist_id)),
|
||||
};
|
||||
// Public-playlist bypass: anonymous-ish read. `check` returns
|
||||
// bool (no throw); combine with the public flag before
|
||||
// deciding.
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
async fn list_playlists(
|
||||
@@ -109,19 +171,43 @@ impl MusicUseCase for MusicService {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let mut playlists = Vec::new();
|
||||
// Post-Round-3 semantics: playlists the caller has any grant
|
||||
// on come from `list_incoming_grants` — one union of owned +
|
||||
// shared. The pre-Round-3 code fetched them via two separate
|
||||
// queries (`list_playlists_by_owner` + `list_shared_with_user`)
|
||||
// that each read a different table.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_incoming_grants(Subject::User(user_id))
|
||||
.await?;
|
||||
|
||||
let owned = self.storage.list_playlists_by_owner(user_id).await?;
|
||||
playlists.extend(owned);
|
||||
// Deduplicate — a user can hold multiple grants on the same
|
||||
// playlist (direct + group-inherited). We only need one DTO
|
||||
// per resource.
|
||||
let mut playlist_ids: HashSet<Uuid> = grants
|
||||
.into_iter()
|
||||
.filter_map(|g| match g.resource {
|
||||
Resource::Playlist(id) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if include_shared {
|
||||
let shared = self.storage.list_shared_with_user(user_id).await?;
|
||||
for s in shared {
|
||||
if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) {
|
||||
playlists.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `include_shared=false` narrows the listing to owned playlists
|
||||
// only. Owner is a grant like any other in `role_grants`, so we
|
||||
// filter the aggregated set against the owner_id stamped on
|
||||
// each row after hydration — cheaper than a second SQL round-trip.
|
||||
// Hydrate in ONE `= ANY` round-trip (was one point SELECT per
|
||||
// accessible playlist). Missing rows (deleted race) drop out of
|
||||
// the result set silently, as before.
|
||||
let user_str = user_id.to_string();
|
||||
let ids: Vec<Uuid> = playlist_ids.drain().collect();
|
||||
let mut playlists: Vec<PlaylistDto> = self
|
||||
.storage
|
||||
.get_playlists_by_ids(&ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|p| include_shared || p.owner_id == user_str)
|
||||
.collect();
|
||||
|
||||
if include_public {
|
||||
let public = self.storage.list_public_playlists(limit, offset).await?;
|
||||
@@ -141,26 +227,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: AddTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistItemDto>, DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to add tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let file_ids: Result<Vec<Uuid>, _> =
|
||||
dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -177,30 +246,12 @@ impl MusicUseCase for MusicService {
|
||||
file_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
let file_uuid = Uuid::parse_str(file_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to remove tracks",
|
||||
));
|
||||
}
|
||||
|
||||
self.storage.remove_track(&playlist_uuid, &file_uuid).await
|
||||
}
|
||||
|
||||
@@ -210,26 +261,9 @@ impl MusicUseCase for MusicService {
|
||||
dto: ReorderTracksDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to modify this playlist",
|
||||
));
|
||||
}
|
||||
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
||||
if !can_write {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You need write access to reorder tracks",
|
||||
));
|
||||
}
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Update)
|
||||
.await?;
|
||||
|
||||
let item_ids: Result<Vec<Uuid>, _> =
|
||||
dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
||||
@@ -248,16 +282,21 @@ impl MusicUseCase for MusicService {
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
||||
if !has_access {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"You don't have permission to view this playlist",
|
||||
));
|
||||
// Public-playlist bypass mirrors `get_playlist`: readers of a
|
||||
// public playlist can see its tracks. Fetch the playlist row
|
||||
// to inspect `is_public` before deciding.
|
||||
let playlist = self
|
||||
.storage
|
||||
.get_playlist(playlist_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Playlist", playlist_id))?;
|
||||
let allowed = playlist.is_public
|
||||
|| self
|
||||
.has_playlist_perm(playlist_id, user_id, Permission::Read)
|
||||
.await?;
|
||||
if !allowed {
|
||||
return Err(DomainError::not_found("Playlist", playlist_id));
|
||||
}
|
||||
|
||||
self.storage.list_playlist_tracks(&playlist_uuid).await
|
||||
}
|
||||
|
||||
@@ -267,36 +306,33 @@ impl MusicUseCase for MusicService {
|
||||
dto: SharePlaylistDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can share this playlist",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
let can_write = dto.can_write.unwrap_or(false);
|
||||
|
||||
self.storage
|
||||
.share_playlist(&playlist_uuid, target_user_id, can_write)
|
||||
.await
|
||||
// Legacy `can_write` boolean maps into the role bundle system:
|
||||
// - false → Viewer (Read only)
|
||||
// - true → Editor (Read + Update)
|
||||
// The endpoint stays boolean-shaped for API back-compat; new
|
||||
// integrations should switch to the unified `/api/grants` API
|
||||
// which exposes the full role set.
|
||||
let role = if dto.can_write.unwrap_or(false) {
|
||||
Role::Editor
|
||||
} else {
|
||||
Role::Viewer
|
||||
};
|
||||
self.authz
|
||||
.set_role(
|
||||
caller_id,
|
||||
Subject::User(target_user_id),
|
||||
role,
|
||||
Resource::Playlist(playlist_uuid),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_share(
|
||||
@@ -305,33 +341,18 @@ impl MusicUseCase for MusicService {
|
||||
target_user_id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != caller_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can manage sharing",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, caller_id, Permission::Share)
|
||||
.await?;
|
||||
let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
||||
})?;
|
||||
|
||||
self.storage.remove_share(&playlist_uuid, target_uuid).await
|
||||
self.authz
|
||||
.clear_role(
|
||||
Subject::User(target_uuid),
|
||||
Resource::Playlist(playlist_uuid),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_playlist_shares(
|
||||
@@ -339,35 +360,26 @@ impl MusicUseCase for MusicService {
|
||||
playlist_id: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<PlaylistShareInfoDto>, DomainError> {
|
||||
let playlist = self.storage.get_playlist(playlist_id).await?;
|
||||
let playlist = match playlist {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Playlist",
|
||||
"Playlist not found",
|
||||
));
|
||||
}
|
||||
};
|
||||
if playlist.owner_id != user_id.to_string() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Playlist",
|
||||
"Only the owner can view sharing info",
|
||||
));
|
||||
}
|
||||
|
||||
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
||||
})?;
|
||||
|
||||
let shares = self.storage.get_shares(&playlist_uuid).await?;
|
||||
Ok(shares
|
||||
let playlist_uuid = self
|
||||
.require_playlist_perm(playlist_id, user_id, Permission::Share)
|
||||
.await?;
|
||||
// `list_grants_on_resource` returns every role_grant row for
|
||||
// the playlist. Drop the Owner self-grant seeded at creation
|
||||
// (the caller already knows they own it) and collapse the
|
||||
// role bundle back to a boolean `can_write` for the legacy
|
||||
// DTO shape.
|
||||
let grants = self
|
||||
.authz
|
||||
.list_grants_on_resource(Resource::Playlist(playlist_uuid))
|
||||
.await?;
|
||||
Ok(grants
|
||||
.into_iter()
|
||||
.map(|(uid, can_write)| PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write,
|
||||
.filter_map(|g| match g.subject {
|
||||
Subject::User(uid) if g.role != Role::Owner => Some(PlaylistShareInfoDto {
|
||||
user_id: uid.to_string(),
|
||||
can_write: g.role.expand().contains(&Permission::Update),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -375,10 +387,23 @@ impl MusicUseCase for MusicService {
|
||||
async fn get_audio_metadata(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_user_id: Uuid,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Option<AudioMetadataDto>, DomainError> {
|
||||
let file_uuid = Uuid::parse_str(file_id)
|
||||
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?;
|
||||
// AuthZ pre-read: caller must have `Read` on the underlying
|
||||
// audio file. Before this check the endpoint returned
|
||||
// metadata for any known file id (cross-tenant IDOR — the
|
||||
// `_user_id` parameter was deliberately unused). `require`
|
||||
// returns 404 on denial to match the anti-enum shape used
|
||||
// everywhere else.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.await?;
|
||||
self.storage.get_audio_metadata(&file_uuid).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,55 +41,50 @@ impl NextcloudFileIdService {
|
||||
|
||||
/// Resolve — creating when absent — stable numeric file IDs for many
|
||||
/// UUIDs at once. Cache hits cost nothing; the misses are resolved with a
|
||||
/// single backing query. The returned map is keyed by the caller's
|
||||
/// original id strings; unresolvable inputs are simply absent (mirroring
|
||||
/// the `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(
|
||||
&self,
|
||||
file_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
/// single backing query. The returned map is keyed by parsed UUID;
|
||||
/// unparseable/unresolvable inputs are simply absent (mirroring the
|
||||
/// `.ok()` behaviour the callers relied on).
|
||||
pub async fn get_or_create_file_ids(&self, file_ids: &[&str]) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("file", file_ids).await
|
||||
}
|
||||
|
||||
/// Folder counterpart of [`Self::get_or_create_file_ids`].
|
||||
pub async fn get_or_create_folder_ids(
|
||||
&self,
|
||||
folder_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
folder_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
self.get_or_create_many("folder", folder_ids).await
|
||||
}
|
||||
|
||||
async fn get_or_create_many(
|
||||
&self,
|
||||
object_type: &str,
|
||||
raw_ids: &[String],
|
||||
) -> Result<HashMap<String, i64>> {
|
||||
raw_ids: &[&str],
|
||||
) -> Result<HashMap<Uuid, i64>> {
|
||||
let mut result = HashMap::with_capacity(raw_ids.len());
|
||||
// Parsed-UUID → caller's original string; also dedupes the miss list.
|
||||
let mut pending: HashMap<Uuid, String> = HashMap::new();
|
||||
let mut misses: Vec<Uuid> = Vec::new();
|
||||
|
||||
for raw in raw_ids {
|
||||
let Ok(uuid) = Uuid::parse_str(raw) else {
|
||||
continue; // Unparseable ids never had a mapping — skip silently.
|
||||
};
|
||||
if let Some(id) = self.cache.get(&uuid).await {
|
||||
result.insert(raw.clone(), id);
|
||||
result.insert(uuid, id);
|
||||
} else {
|
||||
pending.entry(uuid).or_insert_with(|| raw.clone());
|
||||
misses.push(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
let misses: Vec<Uuid> = pending.keys().copied().collect();
|
||||
if !misses.is_empty() {
|
||||
misses.sort_unstable();
|
||||
misses.dedup();
|
||||
let resolved = self
|
||||
.repo()?
|
||||
.get_or_create_many(object_type, &misses)
|
||||
.await?;
|
||||
for (uuid, id) in resolved {
|
||||
self.cache.insert(uuid, id).await;
|
||||
if let Some(original) = pending.get(&uuid) {
|
||||
result.insert(original.clone(), id);
|
||||
}
|
||||
result.insert(uuid, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +179,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_get_or_create_file_ids_skips_unparseable() {
|
||||
let svc = NextcloudFileIdService::new_stub();
|
||||
let map = svc
|
||||
.get_or_create_file_ids(&["not-a-uuid".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let map = svc.get_or_create_file_ids(&["not-a-uuid"]).await.unwrap();
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ struct PendingFlow {
|
||||
/// if the flow token leaks. `None` for single-drive accounts (legacy
|
||||
/// path goes straight to `completed`).
|
||||
pending_user_id: Option<Uuid>,
|
||||
/// App-password label to persist when this multi-drive flow finally
|
||||
/// completes. Stashed by `resolve_drive_or_complete` (login_v2_handler)
|
||||
/// alongside `pending_user_id` so `handle_drive_pick` can preserve
|
||||
/// provenance (`"Nextcloud"` vs `"Nextcloud (OIDC)"`) across the
|
||||
/// picker round-trip. Consumed by `take_pending_app_password_label`.
|
||||
pending_app_password_label: Option<String>,
|
||||
completed: Option<LoginResult>,
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ impl NextcloudLoginFlowService {
|
||||
created_at: Instant::now(),
|
||||
poll_token: poll_token.clone(),
|
||||
pending_user_id: None,
|
||||
pending_app_password_label: None,
|
||||
completed: None,
|
||||
},
|
||||
);
|
||||
@@ -143,6 +150,35 @@ impl NextcloudLoginFlowService {
|
||||
.and_then(|pending| pending.pending_user_id.take())
|
||||
}
|
||||
|
||||
/// Stash the app-password label to use when the flow eventually
|
||||
/// completes via `handle_drive_pick`. Called alongside
|
||||
/// `mark_awaiting_drive` so the multi-drive round-trip preserves
|
||||
/// the provenance string passed in at the auth step
|
||||
/// (`"Nextcloud"` for password login, `"Nextcloud (OIDC)"` for OIDC).
|
||||
/// Silently no-ops when the flow token is unknown or expired —
|
||||
/// the earlier `mark_awaiting_drive` on the same token is the
|
||||
/// authoritative "exists?" signal so we don't need to log again.
|
||||
pub fn set_pending_app_password_label(&self, flow_token: &str, label: &str) {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
if let Some(pending) = state.flows.get_mut(flow_token) {
|
||||
pending.pending_app_password_label = Some(label.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the stashed app-password label (single-use). Returns
|
||||
/// `None` when the flow was never marked, was password-shortcut
|
||||
/// (single-drive), or the token is unknown / expired — the caller
|
||||
/// falls back to a sensible default in that case.
|
||||
pub fn take_pending_app_password_label(&self, flow_token: &str) -> Option<String> {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
prune_expired(&mut state, self.ttl);
|
||||
state
|
||||
.flows
|
||||
.get_mut(flow_token)
|
||||
.and_then(|pending| pending.pending_app_password_label.take())
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
&self,
|
||||
flow_token: &str,
|
||||
|
||||
@@ -23,17 +23,30 @@ use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::face::Person;
|
||||
use crate::infrastructure::repositories::pg::FacePgRepository;
|
||||
|
||||
/// Cosine similarity of two equal-length vectors. Embeddings are produced
|
||||
/// L2-normalized, so this is ~a dot product; we normalize anyway for safety.
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
/// Squared L2 norm, accumulated in the same order `cosine` used to, so
|
||||
/// the precomputed-norm path is bit-identical to the old per-pair one.
|
||||
fn norm_sq(v: &[f32]) -> f32 {
|
||||
let mut n = 0.0f32;
|
||||
for &x in v {
|
||||
n += x * x;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Cosine similarity of two equal-length vectors given their precomputed
|
||||
/// squared norms. Embeddings are produced L2-normalized, so this is ~a dot
|
||||
/// product; we normalize anyway for safety. The O(N²) recluster pair loop
|
||||
/// used to re-accumulate BOTH norms on every pair — precomputing them once
|
||||
/// per face keeps only the dot product in the hot loop while the final
|
||||
/// `dot / (√na · √nb)` expression (and the zero guards) stay exactly as
|
||||
/// before, so results are bit-identical (benches/ROUND11.md §17).
|
||||
fn cosine_with_norms(a: &[f32], b: &[f32], na: f32, nb: f32) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32);
|
||||
let mut dot = 0.0f32;
|
||||
for (&x, &y) in a.iter().zip(b.iter()) {
|
||||
dot += x * y;
|
||||
na += x * x;
|
||||
nb += y * y;
|
||||
}
|
||||
if na == 0.0 || nb == 0.0 {
|
||||
return 0.0;
|
||||
@@ -102,10 +115,13 @@ impl PeopleService {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let norms: Vec<f32> = faces.iter().map(|f| norm_sq(&f.embedding)).collect();
|
||||
let mut uf = UnionFind::new(n);
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold {
|
||||
if cosine_with_norms(&faces[i].embedding, &faces[j].embedding, norms[i], norms[j])
|
||||
>= self.cluster_threshold
|
||||
{
|
||||
uf.union(i, j);
|
||||
}
|
||||
}
|
||||
@@ -117,13 +133,19 @@ impl PeopleService {
|
||||
groups.entry(root).or_default().push(i);
|
||||
}
|
||||
|
||||
// Accumulate every (face, person) change and apply them in ONE
|
||||
// UNNEST batch at the end — the old per-face `assign_person` loop
|
||||
// issued up to F sequential UPDATE round-trips per recluster
|
||||
// (benches/ROUND11.md §Q5; the ROUND10 `save_faces` pattern). The
|
||||
// final column state is identical.
|
||||
let mut assignments: Vec<(Uuid, Option<Uuid>)> = Vec::new();
|
||||
let mut created = 0usize;
|
||||
for idxs in groups.into_values() {
|
||||
if idxs.len() < self.min_faces {
|
||||
// Too small to be a person — leave/reset these faces unassigned.
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id.is_some() {
|
||||
self.repo.assign_person(faces[i].id, None).await?;
|
||||
assignments.push((faces[i].id, None));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -151,9 +173,7 @@ impl PeopleService {
|
||||
};
|
||||
for &i in &idxs {
|
||||
if faces[i].person_id != Some(person_id) {
|
||||
self.repo
|
||||
.assign_person(faces[i].id, Some(person_id))
|
||||
.await?;
|
||||
assignments.push((faces[i].id, Some(person_id)));
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
@@ -161,23 +181,29 @@ impl PeopleService {
|
||||
.set_person_cover(person_id, faces[idxs[0]].id)
|
||||
.await;
|
||||
}
|
||||
self.repo.assign_person_batch(&assignments).await?;
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
/// People (non-empty clusters), most-photographed first.
|
||||
///
|
||||
/// Counts come from a grouped-COUNT query and cover photos from one
|
||||
/// batched lookup of just the cover face ids — the previous
|
||||
/// `faces_for_user` shipped every face row (2 KiB embedding included)
|
||||
/// only to count them: ~20 MB of BYTEA per request on a 10k-face
|
||||
/// library (benches/PEOPLE-LIST.md).
|
||||
pub async fn list_people(&self, caller_id: Uuid) -> Result<Vec<PersonDto>, DomainError> {
|
||||
let persons = self.repo.persons_for_user(caller_id).await?;
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
|
||||
let mut count: HashMap<Uuid, i64> = HashMap::new();
|
||||
let mut face_file: HashMap<Uuid, Uuid> = HashMap::new();
|
||||
for f in &faces {
|
||||
if let Some(pid) = f.person_id {
|
||||
*count.entry(pid).or_default() += 1;
|
||||
}
|
||||
face_file.insert(f.id, f.file_id);
|
||||
}
|
||||
let count: HashMap<Uuid, i64> = self
|
||||
.repo
|
||||
.person_face_stats(caller_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let cover_ids: Vec<Uuid> = persons.iter().filter_map(|p| p.cover_face_id).collect();
|
||||
let face_file: HashMap<Uuid, Uuid> =
|
||||
self.repo.file_ids_for_faces(caller_id, &cover_ids).await?;
|
||||
|
||||
let mut out: Vec<PersonDto> = persons
|
||||
.into_iter()
|
||||
@@ -219,10 +245,11 @@ impl PeopleService {
|
||||
caller_id: Uuid,
|
||||
file_id: Uuid,
|
||||
) -> Result<Vec<FaceBoxDto>, DomainError> {
|
||||
let faces = self.repo.faces_for_file(file_id).await?;
|
||||
Ok(faces
|
||||
// The narrow projection scopes to the caller in SQL (WHERE user_id),
|
||||
// so no post-filter is needed here. See benches/ROUND14.md §Q1.
|
||||
let boxes = self.repo.face_boxes_for_file(file_id, caller_id).await?;
|
||||
Ok(boxes
|
||||
.into_iter()
|
||||
.filter(|f| f.user_id == caller_id)
|
||||
.map(|f| FaceBoxDto {
|
||||
id: f.id.to_string(),
|
||||
person_id: f.person_id.map(|u| u.to_string()),
|
||||
@@ -245,11 +272,13 @@ impl PeopleService {
|
||||
|
||||
/// Merge `from` into `into` by reassigning all of `from`'s faces. The
|
||||
/// now-empty `from` person is hidden by `list_people`.
|
||||
///
|
||||
/// One set-based UPDATE — the previous shape loaded every face row
|
||||
/// (embeddings included) and issued one UPDATE per matching face.
|
||||
pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> {
|
||||
let faces = self.repo.faces_for_user(caller_id).await?;
|
||||
for f in faces.into_iter().filter(|f| f.person_id == Some(from)) {
|
||||
self.repo.assign_person(f.id, Some(into)).await?;
|
||||
}
|
||||
self.repo
|
||||
.reassign_person_faces(caller_id, from, into)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ use crate::infrastructure::repositories::pg::FileBlobReadRepository;
|
||||
/// "Places" use case: the caller's geotagged photos aggregated into map
|
||||
/// clusters.
|
||||
///
|
||||
/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so,
|
||||
/// like [`RecentService`](super::recent_service::RecentService) and the photos
|
||||
/// timeline, it needs no `AuthorizationEngine` check: the `caller_id`
|
||||
/// parameter *is* the access scope.
|
||||
/// Post-§15 the surface follows the Photos scope: drives where the
|
||||
/// caller has Read AND `policies.include_in_photo_index = true`
|
||||
/// (default personal drives materialise the flag at creation).
|
||||
/// Group-membership expansion is handled inline by
|
||||
/// `storage.caller_group_ids(caller)` inside the repo's SQL, so this
|
||||
/// service is a thin coordinate-math wrapper — no engine dependency.
|
||||
pub struct PlacesService {
|
||||
file_read: Arc<FileBlobReadRepository>,
|
||||
}
|
||||
@@ -30,7 +32,8 @@ impl PlacesService {
|
||||
360.0 / (2_f64.powi(z) * 4.0)
|
||||
}
|
||||
|
||||
/// Clustered geotagged photos for `caller_id` within `bounds`.
|
||||
/// Clustered geotagged photos in the caller's Photos-scope drive set,
|
||||
/// within `bounds`.
|
||||
pub async fn clusters(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::recent_dto::{RecentCursor, RecentItemDto, RecentResourceRow};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::recent_ports::{RecentItemsRepositoryPort, RecentItemsUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
|
||||
use std::sync::Arc;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,16 +18,97 @@ use uuid::Uuid;
|
||||
pub struct RecentService {
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
max_recent_items: i32,
|
||||
/// ReBAC engine — enforces `Permission::Read` on the referenced
|
||||
/// file/folder before enrolling it into a user's Recent list.
|
||||
/// The listing side JOINs back to `storage.files/folders` and
|
||||
/// returns name/mime/size/drive_id for any enrolled UUID, so
|
||||
/// the write path is an information oracle without this gate.
|
||||
/// See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Set after construction via [`Self::set_resource_access_hook`].
|
||||
/// The hook is built FROM this service (it wraps an `Arc<Self>`), so
|
||||
/// we can't take it as a constructor arg without circular ownership;
|
||||
/// the OnceLock holds the back-edge so this service can notify the
|
||||
/// hook when the user clears or removes Recent rows. The notification
|
||||
/// lets the hook drop its in-memory throttle entries — otherwise a
|
||||
/// freshly-cleared Recent refuses to re-record until the TTL expires.
|
||||
resource_access_hook: OnceLock<Arc<dyn ResourceAccessHook>>,
|
||||
}
|
||||
|
||||
impl RecentService {
|
||||
/// Create a new recent items service
|
||||
pub fn new(repo: Arc<RecentItemsPgRepository>, max_recent_items: i32) -> Self {
|
||||
pub fn new(
|
||||
repo: Arc<RecentItemsPgRepository>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
max_recent_items: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
max_recent_items: max_recent_items.clamp(1, 100),
|
||||
authorization,
|
||||
resource_access_hook: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the access hook in after construction. Idempotent: a second
|
||||
/// `set` is a no-op (returns the existing value as `Err`). Called
|
||||
/// from DI once `RecentRecordingHook::new(Arc<Self>)` has produced
|
||||
/// the back-edge that closes the loop.
|
||||
pub fn set_resource_access_hook(&self, hook: Arc<dyn ResourceAccessHook>) {
|
||||
let _ = self.resource_access_hook.set(hook);
|
||||
}
|
||||
|
||||
/// Internal helper: notify the hook (if registered) that `user_id`
|
||||
/// has emptied their Recent list — wholly or by removing a single
|
||||
/// row. The hook drops its in-memory throttle entries so the very
|
||||
/// next access re-records into the freshly-empty table.
|
||||
fn notify_recents_cleared(&self, user_id: Uuid) {
|
||||
if let Some(hook) = self.resource_access_hook.get() {
|
||||
hook.on_recents_cleared(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record access to an item WITHOUT the pre-write `authz.require`
|
||||
/// gate. Callers must have gated the caller's Read upstream — this
|
||||
/// method exists for the `RecentRecordingHook` fast path: writes
|
||||
/// that reach the hook have already passed a `_with_perms` service
|
||||
/// method (uploads, streams, GETs, etc.), so re-checking here
|
||||
/// would be pure duplicate work AND widen the race window between
|
||||
/// the POST response and the `tokio::spawn`ed upsert (
|
||||
/// `tests/api/recent.hurl` step 7 hits this — the extra SQL
|
||||
/// round-trip pushes the upsert past the client's immediate
|
||||
/// `GET /api/recent/resources`).
|
||||
///
|
||||
/// **Do NOT call this from an externally-reachable handler.** The
|
||||
/// REST endpoint goes through the trait method `record_item_access`
|
||||
/// below, which enforces the Read gate per AGENTS.md convention.
|
||||
pub async fn record_item_access_internal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
item_id: &str,
|
||||
item_type: &str,
|
||||
) -> Result<()> {
|
||||
// Type validation only — no authz, no resource parse for the
|
||||
// engine (the hook path is already resource-typed by construction).
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
|
||||
// Prune only when the upsert actually inserted a NEW row — a
|
||||
// re-access refreshes an existing row's timestamp and can never
|
||||
// grow the set past the cap, so the prune (a DELETE over an
|
||||
// OFFSET self-subquery) is a wasted round-trip on that common path
|
||||
// (benches/ROUND13.md §Q3).
|
||||
let inserted = self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
if inserted {
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecentItemsUseCase for RecentService {
|
||||
@@ -59,16 +143,24 @@ impl RecentItemsUseCase for RecentService {
|
||||
item_type, item_id, user_id
|
||||
);
|
||||
|
||||
if item_type != "file" && item_type != "folder" {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"RecentItems",
|
||||
"Item type must be 'file' or 'folder'",
|
||||
));
|
||||
}
|
||||
// AuthZ pre-write: caller must have Read on the referenced
|
||||
// resource. Denial routes through `require` → NotFound
|
||||
// (anti-enum) + `authz.denied` audit line. Without this
|
||||
// gate the write path was an information oracle over the
|
||||
// whole tenant via the listing endpoint's JOIN back to
|
||||
// storage.files/folders.
|
||||
//
|
||||
// Internal hook callers (RecentRecordingHook) bypass the
|
||||
// trait entry point and call `record_item_access_internal`
|
||||
// directly — Read has already been enforced upstream on
|
||||
// whatever `_with_perms` service produced the access event.
|
||||
let resource = Resource::parse(item_type, item_id)?;
|
||||
self.authorization
|
||||
.require(Subject::User(user_id), Permission::Read, resource)
|
||||
.await?;
|
||||
|
||||
self.repo.upsert_access(user_id, item_id, item_type).await?;
|
||||
self.repo.prune(user_id, self.max_recent_items).await?;
|
||||
self.record_item_access_internal(user_id, item_id, item_type)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Successfully recorded access to {} '{}' for user {}",
|
||||
@@ -100,6 +192,12 @@ impl RecentItemsUseCase for RecentService {
|
||||
item_id,
|
||||
user_id
|
||||
);
|
||||
// Drop the throttle entries so the next access re-records. We
|
||||
// notify on every call (even when `removed == false`) so the
|
||||
// semantics are "the user expressed intent to forget this" —
|
||||
// the hook owns the per-(user, item) cache anyway, dropping a
|
||||
// miss is a no-op.
|
||||
self.notify_recents_cleared(user_id);
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
@@ -108,6 +206,7 @@ impl RecentItemsUseCase for RecentService {
|
||||
info!("Clearing all recent items for user {}", user_id);
|
||||
self.repo.clear_all(user_id).await?;
|
||||
info!("Cleared all recent items for user {}", user_id);
|
||||
self.notify_recents_cleared(user_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,18 +472,21 @@ impl RecipientNotificationService {
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
// Drives don't generate share notifications in D0 — drive
|
||||
// sharing lands in D2 and gets its own template key. Fall
|
||||
// back to the folder label so any path that does reach
|
||||
// here produces a readable, if generic, mail body.
|
||||
Resource::Drive(_) => "server.magic_link.email.kind_folder",
|
||||
// Drive / Calendar / AddressBook / Playlist shares don't
|
||||
// produce email notifications through this path. Fall
|
||||
// back to the folder label so any code that does reach
|
||||
// here still produces a readable (if generic) mail body.
|
||||
Resource::Drive(_)
|
||||
| Resource::Calendar(_)
|
||||
| Resource::AddressBook(_)
|
||||
| Resource::Playlist(_) => "server.magic_link.email.kind_folder",
|
||||
};
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
// Short form for the subject, long form (with email) for the
|
||||
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
||||
let inviter_short = granter.display_full(false);
|
||||
let inviter_full = granter.display_full(true);
|
||||
let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),);
|
||||
let login_link = format!("{}/login", self.public_base_url.trim_end_matches('/'),);
|
||||
|
||||
let args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_short.as_str()),
|
||||
|
||||
@@ -2,9 +2,7 @@ use std::cmp::Reverse;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::display_helpers::intern_display;
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::search_dto::{
|
||||
@@ -15,6 +13,7 @@ use crate::application::ports::content_index_ports::{ContentHitDto, ContentIndex
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::Result;
|
||||
use crate::common::text::ascii_ci_contains;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
@@ -67,9 +66,80 @@ pub struct SearchService {
|
||||
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
|
||||
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
|
||||
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
|
||||
///
|
||||
/// **Byte-bounded**, not entry-bounded: entries are weighed by
|
||||
/// [`search_results_entry_weight`] and `max_capacity` is a byte budget.
|
||||
/// Keys span user × query × offset × limit, and each page holds up to 500
|
||||
/// enriched rows (~500–900 B of owned Strings each) — an entry-count bound
|
||||
/// let hundreds of MB of result pages accumulate invisibly.
|
||||
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
|
||||
}
|
||||
|
||||
// ─── Search-results cache (byte-bounded) ─────────────────────────────────
|
||||
|
||||
/// Approximate heap bytes retained by one cached search page.
|
||||
///
|
||||
/// With a `weigher` installed, moka's `max_capacity` is the sum of entry
|
||||
/// *weights*, so this converts the cache bound from "number of entries" to
|
||||
/// real bytes: the length of every owned `String` in each file/folder row,
|
||||
/// plus a fixed per-row and per-entry overhead for struct fields, the 24-B
|
||||
/// `String` headers, `Vec` slots and allocator slop. Same pattern as the
|
||||
/// file-content cache and the dedup manifest cache.
|
||||
///
|
||||
/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained
|
||||
/// bytes with the exact production formula.
|
||||
pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) -> u32 {
|
||||
/// Fixed per-row overhead: struct scalars + one 24-B header per `String`
|
||||
/// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator
|
||||
/// slop. Deliberately a round upper-ish estimate — under-weighing is the
|
||||
/// failure mode that re-opens the memory hole.
|
||||
const ROW_OVERHEAD: usize = 200;
|
||||
/// Fixed per-entry overhead: `Arc` + `SearchResultsDto` scalars + `Vec`
|
||||
/// headers + moka's own bookkeeping per entry.
|
||||
const ENTRY_OVERHEAD: usize = 256;
|
||||
|
||||
fn opt_len(s: &Option<String>) -> usize {
|
||||
s.as_deref().map_or(0, str::len)
|
||||
}
|
||||
|
||||
let mut bytes = ENTRY_OVERHEAD + value.sort_by.len();
|
||||
for f in &value.files {
|
||||
bytes += ROW_OVERHEAD
|
||||
+ f.id.len()
|
||||
+ f.name.len()
|
||||
+ f.path.len()
|
||||
+ f.mime_type.len()
|
||||
+ opt_len(&f.folder_id)
|
||||
+ f.size_formatted.len()
|
||||
+ f.icon_class.len()
|
||||
+ f.icon_special_class.len()
|
||||
+ f.category.len()
|
||||
+ f.blob_hash.len()
|
||||
+ opt_len(&f.snippet)
|
||||
+ opt_len(&f.match_source);
|
||||
}
|
||||
for d in &value.folders {
|
||||
bytes += ROW_OVERHEAD + d.id.len() + d.name.len() + d.path.len() + opt_len(&d.parent_id);
|
||||
}
|
||||
bytes.min(u32::MAX as usize) as u32
|
||||
}
|
||||
|
||||
/// Build the search-results cache exactly as production wires it: a byte
|
||||
/// budget enforced through [`search_results_entry_weight`], plus TTL.
|
||||
///
|
||||
/// Shared with `examples/bench_search_cache_mem.rs` so the benchmark
|
||||
/// measures the identical cache configuration that serves requests.
|
||||
pub fn build_search_results_cache(
|
||||
cache_ttl_secs: u64,
|
||||
max_bytes: u64,
|
||||
) -> moka::future::Cache<u64, Arc<SearchResultsDto>> {
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(max_bytes)
|
||||
.weigher(search_results_entry_weight)
|
||||
.time_to_live(Duration::from_secs(cache_ttl_secs))
|
||||
.build()
|
||||
}
|
||||
|
||||
// ─── Utility functions (pure, no self — computed on the server) ─────────
|
||||
|
||||
/// Compute relevance score (0–100) for a name against a query.
|
||||
@@ -77,19 +147,40 @@ pub struct SearchService {
|
||||
///
|
||||
/// `query_lower` **must** already be lowercased by the caller so that the
|
||||
/// allocation happens once per search, not once per result.
|
||||
///
|
||||
/// The overwhelmingly common all-ASCII filename takes an allocation-free
|
||||
/// ASCII case-fold fast path — `name.to_lowercase()` (full Unicode) is pure
|
||||
/// waste there, and it ran once *per result row* (and per keystroke on the
|
||||
/// suggest path). Non-ASCII names fall back to the exact Unicode-lowercase
|
||||
/// comparison, so behavior is unchanged (for ASCII, lowercasing preserves
|
||||
/// length, so the `contains` length ratio is identical). See benches/ROUND14.md §A2.
|
||||
fn compute_relevance(name: &str, query_lower: &str) -> u32 {
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(query_lower) {
|
||||
// Bonus for shorter names (more specific match)
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
if name.is_ascii() {
|
||||
let (nb, qb) = (name.as_bytes(), query_lower.as_bytes());
|
||||
if nb.eq_ignore_ascii_case(qb) {
|
||||
100
|
||||
} else if nb.len() >= qb.len() && nb[..qb.len()].eq_ignore_ascii_case(qb) {
|
||||
80
|
||||
} else if ascii_ci_contains(nb, qb) {
|
||||
// Bonus for shorter names (more specific match). ASCII lowercase
|
||||
// preserves length, so `name.len()` == the old `name_lower.len()`.
|
||||
let ratio = query_lower.len() as f64 / name.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
let name_lower = name.to_lowercase();
|
||||
if name_lower == query_lower {
|
||||
100
|
||||
} else if name_lower.starts_with(query_lower) {
|
||||
80
|
||||
} else if name_lower.contains(query_lower) {
|
||||
let ratio = query_lower.len() as f64 / name_lower.len() as f64;
|
||||
50 + (ratio * 20.0) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,28 +229,15 @@ fn format_bytes(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Font Awesome icon class for a file based on extension and MIME type.
|
||||
/// Delegates to the centralised `display_helpers` so every API surface is
|
||||
/// consistent.
|
||||
fn get_icon_class(name: &str, mime: &str) -> String {
|
||||
icon_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get CSS special class for icon styling.
|
||||
fn get_icon_special_class(name: &str, mime: &str) -> String {
|
||||
icon_special_class_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
/// Get category label from centralised helpers.
|
||||
fn get_category(name: &str, mime: &str) -> String {
|
||||
category_for(name, mime).to_string()
|
||||
}
|
||||
|
||||
// ─── SearchService implementation ───────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
/**
|
||||
* Creates a new instance of the search service.
|
||||
*
|
||||
* `max_cache_bytes` is the byte budget for the results cache (weigher-
|
||||
* bounded, see [`search_results_entry_weight`]) — it replaced the old
|
||||
* entry-count capacity, which was blind to how big each cached page is.
|
||||
*/
|
||||
pub fn new(
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
@@ -168,12 +246,9 @@ impl SearchService {
|
||||
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
cache_ttl: u64,
|
||||
max_cache_size: usize,
|
||||
max_cache_bytes: u64,
|
||||
) -> Self {
|
||||
let search_cache = moka::future::Cache::builder()
|
||||
.max_capacity(max_cache_size as u64)
|
||||
.time_to_live(Duration::from_secs(cache_ttl))
|
||||
.build();
|
||||
let search_cache = build_search_results_cache(cache_ttl, max_cache_bytes);
|
||||
|
||||
Self {
|
||||
file_repository,
|
||||
@@ -195,8 +270,14 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FileDto → SearchFileResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO: every `String` moves and the interned display
|
||||
/// fields (`mime_type`/`icon_class`/`icon_special_class`/`category`,
|
||||
/// already computed once in `FileDto::from`) transfer as refcount
|
||||
/// bumps — the old borrow-based version cloned all of them AND re-ran
|
||||
/// the three display classifiers per result row.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_file(file: &FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
fn enrich_file(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -204,23 +285,23 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFileResultDto {
|
||||
id: file.id.clone(),
|
||||
name: file.name.clone(),
|
||||
path: file.path.clone(),
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
mime_type: file.mime_type.to_string(),
|
||||
folder_id: file.folder_id.clone(),
|
||||
mime_type: file.mime_type,
|
||||
folder_id: file.folder_id,
|
||||
created_at: file.created_at,
|
||||
modified_at: file.modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: format_bytes(file.size),
|
||||
icon_class: get_icon_class(&file.name, &file.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file.name, &file.mime_type),
|
||||
category: get_category(&file.name, &file.mime_type),
|
||||
icon_class: file.icon_class,
|
||||
icon_special_class: file.icon_special_class,
|
||||
category: file.category,
|
||||
// Carry the content hash through so REPORT/SEARCH
|
||||
// responses on the NC surface can emit the same ETag
|
||||
// (`File::compute_etag`) as PROPFIND/GET would.
|
||||
blob_hash: file.content_hash.clone(),
|
||||
blob_hash: file.content_hash,
|
||||
snippet: None,
|
||||
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
|
||||
}
|
||||
@@ -228,8 +309,10 @@ impl SearchService {
|
||||
|
||||
/// Enrich a FolderDto → SearchFolderResultDto with server-computed metadata.
|
||||
///
|
||||
/// Consumes the DTO so the owned strings move instead of cloning.
|
||||
///
|
||||
/// `query_lower` must already be lowercased (empty string when no query).
|
||||
fn enrich_folder(folder: &FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
fn enrich_folder(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
let relevance = if query_lower.is_empty() {
|
||||
50
|
||||
} else {
|
||||
@@ -237,10 +320,11 @@ impl SearchService {
|
||||
};
|
||||
|
||||
SearchFolderResultDto {
|
||||
id: folder.id.clone(),
|
||||
name: folder.name.clone(),
|
||||
path: folder.path.clone(),
|
||||
parent_id: folder.parent_id.clone(),
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
parent_id: folder.parent_id,
|
||||
drive_id: folder.drive_id,
|
||||
created_at: folder.created_at,
|
||||
modified_at: folder.modified_at,
|
||||
is_root: folder.is_root,
|
||||
@@ -259,7 +343,7 @@ impl SearchService {
|
||||
user_id: Uuid,
|
||||
) -> Vec<ContentHitDto> {
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::domain::services::authorization::Subject;
|
||||
|
||||
let Some(index) = &self.content_index else {
|
||||
return Vec::new();
|
||||
@@ -282,21 +366,11 @@ impl SearchService {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Resolve the caller's accessible drive set via the engine
|
||||
// (handles group-mediated drive grants) + the repo lookup.
|
||||
let caller = Subject::User(user_id);
|
||||
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
// Resolve the caller's accessible drive set. Group-mediated
|
||||
// grants are honoured inline by `storage.caller_group_ids` on
|
||||
// the SQL side, so no Rust-side subject expansion here.
|
||||
let accessible_drives: Vec<Uuid> = match drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
|
||||
return Vec::new();
|
||||
@@ -325,34 +399,45 @@ impl SearchService {
|
||||
// drive the caller doesn't otherwise have. The Tantivy
|
||||
// filter is drive-only; this re-check restores per-file
|
||||
// resolution.
|
||||
// Failures degrade conservatively (drop the hit, log it) —
|
||||
// never leak.
|
||||
let mut verified = Vec::with_capacity(hits.len());
|
||||
// Failures degrade conservatively (drop the hit / the page,
|
||||
// log it) — never leak. Batched: one drive-resolution query for
|
||||
// the whole page instead of up to CONTENT_HITS_LIMIT sequential
|
||||
// point SELECTs (benches/SEARCH-REBAC.md).
|
||||
// Parse each hit id ONCE and carry the pair through the verify loop
|
||||
// — the old shape re-parsed every `file_id` a second time below
|
||||
// (benches/ROUND11.md §12: 1.6x on a 100-hit page).
|
||||
let mut pairs = Vec::with_capacity(hits.len());
|
||||
for hit in hits {
|
||||
let file_uuid = match Uuid::parse_str(&hit.file_id) {
|
||||
Ok(u) => u,
|
||||
match Uuid::parse_str(&hit.file_id) {
|
||||
Ok(u) => pairs.push((hit, u)),
|
||||
Err(_) => {
|
||||
tracing::warn!("Content-index hit had non-UUID file_id: {}", hit.file_id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match authz
|
||||
.check(caller, Permission::Read, Resource::File(file_uuid))
|
||||
.await
|
||||
{
|
||||
Ok(true) => verified.push(hit),
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::search",
|
||||
file_id = %file_uuid,
|
||||
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ReBAC re-check failed for {file_uuid}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let hit_ids: Vec<Uuid> = pairs.iter().map(|(_, u)| *u).collect();
|
||||
let allowed = match authz
|
||||
.check_files_read_batch(Subject::User(user_id), &hit_ids)
|
||||
.await
|
||||
{
|
||||
Ok(set) => set,
|
||||
Err(e) => {
|
||||
tracing::warn!("ReBAC re-check failed for content hits: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let mut verified = Vec::with_capacity(pairs.len());
|
||||
for (hit, file_uuid) in pairs {
|
||||
if allowed.contains(&file_uuid) {
|
||||
verified.push(hit);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
target: "oxicloud::search",
|
||||
file_id = %file_uuid,
|
||||
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
|
||||
);
|
||||
}
|
||||
}
|
||||
verified
|
||||
}
|
||||
|
||||
@@ -408,9 +493,10 @@ impl SearchService {
|
||||
let Some(hit) = by_id.get(dto.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut enriched = Self::enrich_file(&dto, "");
|
||||
enriched.relevance_score = content_relevance(hit.score, max_score);
|
||||
enriched.snippet = hit.snippet.clone();
|
||||
let (score, snippet) = (hit.score, hit.snippet.clone());
|
||||
let mut enriched = Self::enrich_file(dto, "");
|
||||
enriched.relevance_score = content_relevance(score, max_score);
|
||||
enriched.snippet = snippet;
|
||||
enriched.match_source = Some("content".to_string());
|
||||
enriched_files.push(enriched);
|
||||
added += 1;
|
||||
@@ -424,20 +510,28 @@ impl SearchService {
|
||||
/// Quick suggestions search — returns up to `limit` name suggestions
|
||||
/// matching the query. Pushes filtering, relevance sort and LIMIT to SQL
|
||||
/// so only a handful of rows cross the DB→app boundary.
|
||||
pub async fn suggest(
|
||||
///
|
||||
/// `caller_id` scopes the underlying repo queries to drives the caller
|
||||
/// can Read. Without it (the pre-fix shape) any authenticated user —
|
||||
/// including external magic-link recipients — could autocomplete both
|
||||
/// names and full paths across every tenant on the instance (AuthZ
|
||||
/// audit finding #1, 2026-07-12). Named `_with_perms` per the
|
||||
/// AGENTS.md AuthZ convention.
|
||||
pub async fn suggest_with_perms(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Ask SQL for at most `limit` best-matching files and folders
|
||||
let (files, folders) = tokio::join!(
|
||||
self.file_repository
|
||||
.suggest_files_by_name(folder_id, query, limit),
|
||||
.suggest_files_by_name(folder_id, query, limit, caller_id),
|
||||
self.folder_repository
|
||||
.suggest_folders_by_name(folder_id, query, limit),
|
||||
.suggest_folders_by_name(folder_id, query, limit, caller_id),
|
||||
);
|
||||
let files = files?;
|
||||
let folders = folders?;
|
||||
@@ -448,30 +542,36 @@ impl SearchService {
|
||||
// Pre-compute once — avoids N heap allocations inside the loops.
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
for file in &files {
|
||||
let file_dto = FileDto::from(file.clone());
|
||||
// Consume the entities: the old loop deep-cloned every File into
|
||||
// the DTO conversion and then cloned name/id/path AGAIN into the
|
||||
// suggestion — 3 field clones + a full entity clone per row on
|
||||
// an every-keystroke path.
|
||||
for file in files {
|
||||
let file_dto = FileDto::from(file);
|
||||
let score = compute_relevance(&file_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: file_dto.name.clone(),
|
||||
name: file_dto.name,
|
||||
item_type: "file".to_string(),
|
||||
id: file_dto.id.clone(),
|
||||
path: file_dto.path.clone(),
|
||||
icon_class: get_icon_class(&file_dto.name, &file_dto.mime_type),
|
||||
icon_special_class: get_icon_special_class(&file_dto.name, &file_dto.mime_type),
|
||||
id: file_dto.id,
|
||||
path: file_dto.path,
|
||||
// Interned in `FileDto::from` — reuse instead of re-running
|
||||
// the display classifiers per keystroke suggestion.
|
||||
icon_class: file_dto.icon_class,
|
||||
icon_special_class: file_dto.icon_special_class,
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
|
||||
for folder in &folders {
|
||||
let folder_dto = FolderDto::from(folder.clone());
|
||||
for folder in folders {
|
||||
let folder_dto = FolderDto::from(folder);
|
||||
let score = compute_relevance(&folder_dto.name, &query_lower);
|
||||
suggestions.push(SearchSuggestionItem {
|
||||
name: folder_dto.name.clone(),
|
||||
name: folder_dto.name,
|
||||
item_type: "folder".to_string(),
|
||||
id: folder_dto.id.clone(),
|
||||
path: folder_dto.path.clone(),
|
||||
icon_class: "fas fa-folder".to_string(),
|
||||
icon_special_class: "folder-icon".to_string(),
|
||||
id: folder_dto.id,
|
||||
path: folder_dto.path,
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
relevance_score: score,
|
||||
});
|
||||
}
|
||||
@@ -488,6 +588,22 @@ impl SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bench-only public wrappers (feature = "bench") ──────────────────────
|
||||
|
||||
#[cfg(feature = "bench")]
|
||||
impl SearchService {
|
||||
/// Public wrapper over the private `enrich_file` so
|
||||
/// `examples/bench_search_enrich.rs` can measure it.
|
||||
pub fn enrich_file_for_bench(file: FileDto, query_lower: &str) -> SearchFileResultDto {
|
||||
Self::enrich_file(file, query_lower)
|
||||
}
|
||||
|
||||
/// Public wrapper over the private `enrich_folder` for the same bench.
|
||||
pub fn enrich_folder_for_bench(folder: FolderDto, query_lower: &str) -> SearchFolderResultDto {
|
||||
Self::enrich_folder(folder, query_lower)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SearchUseCase trait implementation ──────────────────────────────────
|
||||
|
||||
impl SearchUseCase for SearchService {
|
||||
@@ -512,8 +628,13 @@ impl SearchUseCase for SearchService {
|
||||
criteria: SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Arc<SearchResultsDto>> {
|
||||
let user_id_str = user_id.to_string();
|
||||
let cache_key = Self::create_cache_key(&criteria, &user_id_str);
|
||||
// Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the
|
||||
// hasher sees the identical byte sequence, so the u64 key is unchanged,
|
||||
// but the per-request heap `String` is gone (the fn doc even claims
|
||||
// "zero-allocation hashing"). See benches/ROUND19.md §M5.
|
||||
let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
|
||||
let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf);
|
||||
let cache_key = Self::create_cache_key(&criteria, user_id_str);
|
||||
|
||||
// Single-flight: collapse N identical concurrent searches into ONE
|
||||
// execution. `try_get_with` serves the cached result on a hit and, on a
|
||||
@@ -527,44 +648,42 @@ impl SearchUseCase for SearchService {
|
||||
// Pre-compute once — avoids N heap allocations inside enrich_file/enrich_folder.
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
// Content-index candidates (first page only). Feature-off or an
|
||||
// index failure yields an empty set — the search stays name-only.
|
||||
let content_hits = self.lookup_content_hits(&criteria, user_id).await;
|
||||
|
||||
// For non-recursive searches, use efficient database-level pagination
|
||||
// This avoids loading all files into memory
|
||||
if !criteria.recursive {
|
||||
// Use database-level pagination
|
||||
let (files, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_paginated(criteria.folder_id.as_deref(), &criteria, user_id)
|
||||
.await?;
|
||||
|
||||
// Convert to DTOs and enrich with metadata
|
||||
let file_dtos: Vec<FileDto> = files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
.collect();
|
||||
|
||||
// Get folders for this folder (non-recursive, filtered in SQL)
|
||||
let folders = self
|
||||
.folder_repository
|
||||
.search_folders(
|
||||
// The content-index lookup (drive resolve + Tantivy +
|
||||
// ReBAC batch), the file page and the folder query are
|
||||
// mutually independent — overlap them so the search pays
|
||||
// ~max() instead of the serial sum (`suggest_with_perms`
|
||||
// already used this shape; ROUND10 brought it here).
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_paginated(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
user_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
),
|
||||
);
|
||||
let (files, total_file_count) = files_page?;
|
||||
let folders = folders_res?;
|
||||
|
||||
let filtered_folders: Vec<FolderDto> =
|
||||
folders.into_iter().map(FolderDto::from).collect();
|
||||
// Convert to DTOs and enrich with metadata — one fused
|
||||
// pass, no intermediate Vec<FileDto> materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// For folders, apply sorting and pagination in memory (usually fewer folders)
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = filtered_folders
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// Sort folders (cached_key avoids O(N log N) temporary String allocations)
|
||||
@@ -601,13 +720,24 @@ impl SearchUseCase for SearchService {
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
||||
// Move the page out of the owned vecs instead of
|
||||
// deep-cloning the slice — the source is dropped right
|
||||
// after (benches/ROUND11.md §11: −300 allocs per page).
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.collect();
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -627,35 +757,36 @@ impl SearchUseCase for SearchService {
|
||||
// ── Recursive search via ltree (single SQL query per entity type) ──
|
||||
// Uses PostgreSQL ltree GiST index to find all files and folders
|
||||
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
|
||||
// approach that could saturate the connection pool.
|
||||
let (found_files, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_in_subtree(criteria.folder_id.as_deref(), &criteria, user_id)
|
||||
.await?;
|
||||
|
||||
// Get folders (SQL-filtered, user-scoped, recursive when applicable)
|
||||
let found_folders: Vec<Folder> = self
|
||||
.folder_repository
|
||||
.search_folders(
|
||||
// approach that could saturate the connection pool. The content
|
||||
// lookup, subtree file query and folder query overlap (`join!`),
|
||||
// same as the non-recursive branch.
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_in_subtree(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
user_id,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
),
|
||||
);
|
||||
let (found_files, total_file_count) = files_page?;
|
||||
let found_folders: Vec<Folder> = folders_res?;
|
||||
|
||||
// ── Convert to DTOs and enrich with server-computed metadata ──
|
||||
let file_dtos: Vec<FileDto> = found_files.into_iter().map(FileDto::from).collect();
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = file_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_file(f, &query_lower))
|
||||
// Fused single pass: no intermediate DTO Vec materialization.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
let folder_dtos: Vec<FolderDto> =
|
||||
found_folders.into_iter().map(FolderDto::from).collect();
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folder_dtos
|
||||
.iter()
|
||||
.map(|f| Self::enrich_folder(f, &query_lower))
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.collect();
|
||||
|
||||
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
||||
@@ -691,13 +822,24 @@ impl SearchUseCase for SearchService {
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
let paginated_folders = enriched_folders[folder_start..folder_end].to_vec();
|
||||
// Move the page out instead of deep-cloning the slice — the
|
||||
// recursive branch's vecs can hold the whole subtree match
|
||||
// set, all dropped right after (benches/ROUND11.md §11).
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files = enriched_files[file_start..file_end].to_vec();
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.collect();
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -723,14 +865,20 @@ impl SearchUseCase for SearchService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns quick suggestions for autocomplete.
|
||||
/// Returns quick suggestions for autocomplete. Delegates to the
|
||||
/// inherent `suggest_with_perms` — the trait method is preserved as
|
||||
/// the polymorphic entry point (e.g. for `StubSearchUseCase` in
|
||||
/// tests); production callers can equivalently call the inherent
|
||||
/// method directly.
|
||||
async fn suggest(
|
||||
&self,
|
||||
query: &str,
|
||||
folder_id: Option<&str>,
|
||||
limit: usize,
|
||||
caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
self.suggest(query, folder_id, limit).await
|
||||
self.suggest_with_perms(query, folder_id, limit, caller_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clears the search results cache.
|
||||
@@ -762,6 +910,7 @@ impl SearchService {
|
||||
_query: &str,
|
||||
_folder_id: Option<&str>,
|
||||
_limit: usize,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<SearchSuggestionsDto> {
|
||||
Ok(SearchSuggestionsDto {
|
||||
suggestions: Vec::new(),
|
||||
@@ -799,21 +948,103 @@ mod tests {
|
||||
name: name.to_string(),
|
||||
path: format!("/{name}"),
|
||||
size,
|
||||
mime_type: "text/plain".to_string(),
|
||||
mime_type: "text/plain".into(),
|
||||
folder_id: None,
|
||||
created_at: 0,
|
||||
modified_at,
|
||||
relevance_score: relevance,
|
||||
size_formatted: String::new(),
|
||||
icon_class: String::new(),
|
||||
icon_special_class: String::new(),
|
||||
category: String::new(),
|
||||
icon_class: "".into(),
|
||||
icon_special_class: "".into(),
|
||||
category: "".into(),
|
||||
blob_hash: String::new(),
|
||||
snippet: None,
|
||||
match_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_weight_counts_every_owned_string_plus_overheads() {
|
||||
// Empty page: entry overhead + sort_by ("relevance" = 9 bytes).
|
||||
let empty = Arc::new(SearchResultsDto::empty());
|
||||
let base = search_results_entry_weight(&0, &empty) as usize;
|
||||
assert_eq!(base, 256 + 9);
|
||||
|
||||
// One file row: base + row overhead + its owned string bytes
|
||||
// (id 7 + name 7 + path 8 + mime 10; the rest are empty/None).
|
||||
let one_file = Arc::new(SearchResultsDto::new(
|
||||
vec![dto("abc.txt", 50, 10, 1)],
|
||||
Vec::new(),
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_file) as usize;
|
||||
assert_eq!(w, base + 200 + 7 + 7 + 8 + 10);
|
||||
|
||||
// Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17).
|
||||
let one_folder = Arc::new(SearchResultsDto::new(
|
||||
Vec::new(),
|
||||
vec![SearchFolderResultDto {
|
||||
id: "f1".to_string(),
|
||||
name: "docs".to_string(),
|
||||
path: "/docs".to_string(),
|
||||
parent_id: Some("parent".to_string()),
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
is_root: false,
|
||||
relevance_score: 50,
|
||||
}],
|
||||
100,
|
||||
0,
|
||||
Some(1),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_folder) as usize;
|
||||
assert_eq!(w, base + 200 + 2 + 4 + 5 + 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_evicts_down_to_the_byte_budget() {
|
||||
// Budget fits ~2 of these entries; inserting 20 must never let the
|
||||
// weighted size settle above the budget.
|
||||
let entry = |i: usize| {
|
||||
Arc::new(SearchResultsDto::new(
|
||||
(0..50)
|
||||
.map(|r| dto(&format!("file_{i}_{r}_{}", "x".repeat(100)), 50, 1, 1))
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
50,
|
||||
0,
|
||||
Some(50),
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
))
|
||||
};
|
||||
let per_entry = search_results_entry_weight(&0, &entry(0)) as u64;
|
||||
let budget = per_entry * 2 + per_entry / 2;
|
||||
|
||||
let cache = build_search_results_cache(300, budget);
|
||||
for i in 0..20u64 {
|
||||
cache.insert(i, entry(i as usize)).await;
|
||||
}
|
||||
cache.run_pending_tasks().await;
|
||||
|
||||
let retained: u64 = cache
|
||||
.iter()
|
||||
.map(|(k, v)| search_results_entry_weight(&k, &v) as u64)
|
||||
.sum();
|
||||
assert!(
|
||||
retained <= budget,
|
||||
"retained {retained} B exceeds budget {budget} B"
|
||||
);
|
||||
assert!(cache.entry_count() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_files_resort_by_relevance_and_by_column() {
|
||||
let mut files = vec![
|
||||
|
||||
@@ -4,8 +4,10 @@ use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Resource, Role, Subject};
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
use crate::infrastructure::repositories::pg::DrivePgRepository;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
@@ -77,9 +79,18 @@ const MAX_CONCURRENT_HASHES: usize = 2;
|
||||
|
||||
pub struct ShareService {
|
||||
config: Arc<AppConfig>,
|
||||
/// `AppConfig::base_url()` snapshot, taken once at construction —
|
||||
/// the method re-reads `OXICLOUD_BASE_URL` from the environment (a
|
||||
/// global env-lock + String build) and was being called per DTO row
|
||||
/// in the share listings. Process-invariant, so snapshot it.
|
||||
base_url: String,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
/// Drive repository — D5 enforcement reads the drive's `policies`
|
||||
/// JSONB before any per-resource action that a policy can gate
|
||||
/// (e.g. `forbid_public_links` for token-share creation).
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
/// ReBAC engine — used to create/revoke token grants that mirror public
|
||||
/// share links so that `GET /api/grants/outgoing` reflects them.
|
||||
@@ -90,19 +101,23 @@ pub struct ShareService {
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<AppConfig>,
|
||||
share_repository: Arc<SharePgRepository>,
|
||||
file_repository: Arc<FileBlobReadRepository>,
|
||||
folder_repository: Arc<FolderDbRepository>,
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url(),
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
folder_repository,
|
||||
drive_repository,
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
@@ -195,7 +210,7 @@ impl ShareService {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
pub fn issue_unlock_jwt(&self, share_token: &str) -> Result<String, DomainError> {
|
||||
@@ -234,6 +249,56 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
self.verify_item_exists(&dto.item_id, &item_type).await?;
|
||||
|
||||
// AuthZ: only callers with `Share` on the resource may mint a
|
||||
// public link. Without this gate, an ex-Viewer who kept a
|
||||
// guessed UUID could launder a temporary read into a
|
||||
// permanent anonymous URL that survives their own grant
|
||||
// revocation. `Permission::Share` is bundled with the
|
||||
// `owner` and `editor` role_grants only. `require` returns
|
||||
// `not_found` on denial (anti-enum, matches the shape used
|
||||
// by every other share route). See `docs/plan/authz_audit/`.
|
||||
let item_uuid_for_authz = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let resource_for_authz = match item_type {
|
||||
ShareItemType::File => Resource::File(item_uuid_for_authz),
|
||||
ShareItemType::Folder => Resource::Folder(item_uuid_for_authz),
|
||||
};
|
||||
self.authorization
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Share,
|
||||
resource_for_authz,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// D5: `forbid_public_links` policy gate. The drive owner can
|
||||
// disable anonymous-link creation on every resource in their
|
||||
// drive without per-resource intervention. Lookup is one JOIN
|
||||
// (`get_policies_for_file` / `_for_folder` — single round-trip);
|
||||
// the decision + audit + canonical error live on
|
||||
// `DrivePolicies::refuse_public_links` so every public-link entry
|
||||
// point (future NC OCS share, etc.) refuses with the same shape.
|
||||
let item_uuid = Uuid::parse_str(&dto.item_id)
|
||||
.map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?;
|
||||
let policies = match item_type {
|
||||
ShareItemType::File => self.drive_repository.get_policies_for_file(item_uuid).await,
|
||||
ShareItemType::Folder => {
|
||||
self.drive_repository
|
||||
.get_policies_for_folder(item_uuid)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
let item_type_str: &'static str = match item_type {
|
||||
ShareItemType::File => "file",
|
||||
ShareItemType::Folder => "folder",
|
||||
};
|
||||
policies.refuse_public_links(crate::domain::entities::drive::PublicLinkGateContext {
|
||||
caller_id: user_id,
|
||||
item_type: item_type_str,
|
||||
item_id: item_uuid,
|
||||
})?;
|
||||
|
||||
let password_hash = match dto.password {
|
||||
Some(p) => Some(self.hash_password_async(&p).await?),
|
||||
None => None,
|
||||
@@ -279,7 +344,7 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
// Return DTO with the requested expires_at (grant subquery on the share
|
||||
// row would return NULL at this point since INSERT ran before the grant).
|
||||
let mut response = ShareDto::from_entity(&saved_share, &self.config.base_url());
|
||||
let mut response = ShareDto::from_entity(&saved_share, &self.base_url);
|
||||
response.expires_at = dto.expires_at;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -295,7 +360,7 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
|
||||
// Convert the entity to DTO for the response
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
@@ -321,7 +386,7 @@ impl ShareUseCase for ShareService {
|
||||
// Convert the entities to DTOs for the response
|
||||
let share_dtos = active_shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
|
||||
Ok(share_dtos)
|
||||
@@ -368,7 +433,7 @@ impl ShareUseCase for ShareService {
|
||||
|
||||
// Use the requested expires_at for the response (subquery in update_share
|
||||
// runs before set_expiry_for_subject committed, so entity may lag).
|
||||
let mut response = ShareDto::from_entity(&updated_share, &self.config.base_url());
|
||||
let mut response = ShareDto::from_entity(&updated_share, &self.base_url);
|
||||
if dto.expires_at.is_some() {
|
||||
response.expires_at = dto.expires_at;
|
||||
}
|
||||
@@ -403,7 +468,7 @@ impl ShareUseCase for ShareService {
|
||||
// Convert the entities to DTOs
|
||||
let share_dtos: Vec<ShareDto> = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
|
||||
// Create the paginated result
|
||||
@@ -447,33 +512,22 @@ impl ShareUseCase for ShareService {
|
||||
}
|
||||
|
||||
// Password verified (or not required) — return full share metadata
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
|
||||
// Find the shared link by its token
|
||||
let share = self
|
||||
.share_repository
|
||||
.find_share_by_token(token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
|
||||
})?;
|
||||
|
||||
// Check if it has expired
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
// One atomic UPDATE (see `ShareStoragePort::increment_access_count`).
|
||||
// 0 rows = missing or expired — collapsed into NotFound, same
|
||||
// response shape either way (anti-enumeration; the landing handler
|
||||
// discards this result regardless).
|
||||
let updated = self.share_repository.increment_access_count(token).await?;
|
||||
if updated == 0 {
|
||||
return Err(ShareServiceError::NotFound(format!(
|
||||
"Share with token {} not found or expired",
|
||||
token
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Increment the access counter
|
||||
let updated_share = share.increment_access_count();
|
||||
|
||||
// Save the changes
|
||||
self.share_repository
|
||||
.update_share(&updated_share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -492,7 +546,9 @@ mod tests {
|
||||
|
||||
/// Test-only service that mirrors `ShareService` logic but accepts generic repos.
|
||||
struct ShareServiceForTest<SR, FR, FoR, PH> {
|
||||
#[allow(dead_code)]
|
||||
config: Arc<AppConfig>,
|
||||
base_url: String,
|
||||
share_repository: Arc<SR>,
|
||||
file_repository: Arc<FR>,
|
||||
folder_repository: Arc<FoR>,
|
||||
@@ -515,6 +571,7 @@ mod tests {
|
||||
password_hasher: Arc<PH>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url(),
|
||||
config,
|
||||
share_repository,
|
||||
file_repository,
|
||||
@@ -593,7 +650,7 @@ mod tests {
|
||||
.save_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&saved_share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link(
|
||||
@@ -611,7 +668,7 @@ mod tests {
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
|
||||
@@ -625,7 +682,7 @@ mod tests {
|
||||
if share.is_expired() {
|
||||
return Err(ShareServiceError::Expired.into());
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
|
||||
async fn get_shared_links_for_item(
|
||||
@@ -642,7 +699,7 @@ mod tests {
|
||||
Ok(shares
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| ShareDto::from_entity(&s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(&s, &self.base_url))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -672,7 +729,7 @@ mod tests {
|
||||
.update_share(&share)
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
Ok(ShareDto::from_entity(&updated, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&updated, &self.base_url))
|
||||
}
|
||||
|
||||
async fn delete_shared_link(
|
||||
@@ -701,7 +758,7 @@ mod tests {
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
let dtos = shares
|
||||
.iter()
|
||||
.map(|s| ShareDto::from_entity(s, &self.config.base_url()))
|
||||
.map(|s| ShareDto::from_entity(s, &self.base_url))
|
||||
.collect();
|
||||
Ok(PaginatedResponseDto::new(dtos, page, per_page, total))
|
||||
}
|
||||
@@ -731,9 +788,9 @@ mod tests {
|
||||
"Invalid share password",
|
||||
));
|
||||
}
|
||||
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
|
||||
Ok(ShareDto::from_entity(&share, &self.base_url))
|
||||
}
|
||||
None => Ok(ShareDto::from_entity(&share, &self.config.base_url())),
|
||||
None => Ok(ShareDto::from_entity(&share, &self.base_url)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,15 +924,6 @@ mod tests {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
@@ -891,14 +939,6 @@ mod tests {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> Result<crate::domain::entities::file::File, DomainError> {
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FolderRepository for MockFolderRepository {
|
||||
@@ -946,10 +986,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -965,10 +1004,9 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::application::ports::auth_ports::UserStoragePort;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
@@ -17,9 +16,20 @@ use uuid::Uuid;
|
||||
* Storage usage is calculated directly from the `storage.files` table
|
||||
* by summing file sizes for each user (using the `user_id` column).
|
||||
*/
|
||||
/// Fused quota-gate row: `(user_used, user_quota, drive_used, drive_quota,
|
||||
/// drive_found)` — see [`StorageUsageService::check_upload_quotas`].
|
||||
type QuotaPairRow = (i64, i64, Option<i64>, Option<i64>, bool);
|
||||
|
||||
pub struct StorageUsageService {
|
||||
pool: Arc<PgPool>,
|
||||
user_repository: Arc<UserPgRepository>,
|
||||
/// Optional so DI can wire it lazily and older test constructors
|
||||
/// keep compiling. When `Some`, every write path that mutates
|
||||
/// `drives.used_bytes` or `users.storage_used_bytes` invalidates
|
||||
/// the drive lookup caches so `GET /api/drives` reflects the new
|
||||
/// usage on the next call (see the invalidation calls in the
|
||||
/// delta / sweep methods below).
|
||||
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
|
||||
}
|
||||
|
||||
impl StorageUsageService {
|
||||
@@ -28,6 +38,44 @@ impl StorageUsageService {
|
||||
Self {
|
||||
pool,
|
||||
user_repository,
|
||||
drive_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wires the drive repository used for cache-invalidation-on-write.
|
||||
/// Production DI calls this in `common::di`; tests without a real
|
||||
/// drive repo leave it `None` and the invalidation calls no-op.
|
||||
pub fn with_drive_repo(
|
||||
mut self,
|
||||
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
) -> Self {
|
||||
self.drive_repo = Some(drive_repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Drop the per-caller readable-drive listing cache and the
|
||||
/// per-user default-drive cache so `GET /api/drives` and the
|
||||
/// WebDAV / NextCloud / WOPI drive-lookup paths re-read fresh
|
||||
/// values.
|
||||
///
|
||||
/// **Called only from the reconciliation sweep**, not from the
|
||||
/// hot-path `add_drive_storage_usage_delta*` methods. The design
|
||||
/// (Ed's call, 2026-07-17): keep the cache useful under active
|
||||
/// upload load — per-mutation invalidation would nuke the cache
|
||||
/// on every file upload, defeating the point. `used_bytes` on
|
||||
/// `GET /api/drives` therefore lags by up to the cache TTL (30 s),
|
||||
/// which matches the sibling caches' accepted UX phantom for
|
||||
/// drive-name staleness. Tests / operators that need immediate
|
||||
/// freshness call `POST /api/admin/internal/trigger-sweep`, which
|
||||
/// runs `update_all_drives_storage_usage` → this method.
|
||||
///
|
||||
/// Security posture unaffected: `check_drive_quota` reads
|
||||
/// directly from SQL, bypassing the cache entirely, so quota
|
||||
/// enforcement is honest regardless of listing staleness.
|
||||
fn invalidate_drive_lookup_caches(&self) {
|
||||
if let Some(repo) = &self.drive_repo {
|
||||
repo.invalidate_readable_all();
|
||||
repo.invalidate_default_drive_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +87,22 @@ impl StorageUsageService {
|
||||
/// (was three: user lookup + SUM + UPDATE). NOT called on the request
|
||||
/// path — only by the per-upload background update and the sweep.
|
||||
pub async fn update_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||
// User envelope = SUM of `drives.used_bytes` across personal
|
||||
// drives owned by the user (see `docs/plan/drive.md` §7). Shared
|
||||
// drives don't count. Ownership is canonical via `role_grants`.
|
||||
let total_usage: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE((
|
||||
SELECT SUM(f.size)::bigint
|
||||
FROM storage.files f
|
||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
||||
SELECT SUM(d.used_bytes)::bigint
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = u.id
|
||||
WHERE d.kind = 'personal'), 0)
|
||||
WHERE u.id = $1
|
||||
RETURNING u.storage_used_bytes
|
||||
"#,
|
||||
@@ -77,9 +134,15 @@ impl StorageUsageService {
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE((
|
||||
SELECT SUM(f.size)::bigint
|
||||
FROM storage.files f
|
||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
||||
SELECT SUM(d.used_bytes)::bigint
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
AND g.subject_id = u.id
|
||||
WHERE d.kind = 'personal'), 0)
|
||||
WHERE u.username = $1
|
||||
RETURNING u.storage_used_bytes
|
||||
"#,
|
||||
@@ -128,6 +191,384 @@ impl StorageUsageService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Conditional user-side delta: only fires when the target folder's
|
||||
/// drive is `kind='personal'`. See `docs/plan/drive.md` §7.
|
||||
///
|
||||
/// The new quota model: `auth.users.storage_quota_bytes` is the cap on
|
||||
/// the SUM of `used_bytes` across the user's personal drives. Shared
|
||||
/// drives never count against any user envelope. The upload hot path
|
||||
/// reads `drives.kind` from the same JOIN that already runs for the
|
||||
/// drive cap check; firing this conditional delta instead of the
|
||||
/// unconditional [`Self::add_user_storage_usage_delta`] keeps the
|
||||
/// counter aligned with that envelope semantics. Idempotent + clamped
|
||||
/// at zero, same as the unconditional sibling.
|
||||
///
|
||||
/// Implementation note: the EXISTS subquery is two indexed PK probes
|
||||
/// (folder by id, drive by id) so the personal/shared discrimination
|
||||
/// adds no real cost vs. the unconditional update.
|
||||
pub async fn add_user_storage_usage_delta_if_personal(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE auth.users u
|
||||
SET storage_used_bytes = GREATEST(0, u.storage_used_bytes + $2)
|
||||
WHERE u.id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM storage.folders f
|
||||
JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE f.id = $3
|
||||
AND d.kind = 'personal'
|
||||
)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(delta)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("usage delta if personal: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Incrementally adjust one drive's cached `storage.drives.used_bytes`
|
||||
/// by `delta` bytes — same shape as
|
||||
/// [`Self::add_user_storage_usage_delta`]: single statement, no
|
||||
/// read-then-write window, `GREATEST(0, …)` clamp so a late or
|
||||
/// duplicate adjustment can never drive the counter negative.
|
||||
/// Deletes / trash do not decrement here; the periodic reconciliation
|
||||
/// sweep ([`Self::update_all_drives_storage_usage`]) remains the
|
||||
/// correctness backstop.
|
||||
pub async fn add_drive_storage_usage_delta(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE storage.drives
|
||||
SET used_bytes = GREATEST(0, used_bytes + $2)
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.bind(delta)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("drive delta: {e}")))?;
|
||||
// Deliberate no-invalidate here — see the class doc on
|
||||
// `invalidate_drive_lookup_caches`. Delta writes lag the
|
||||
// cache by up to the TTL; the sweep is the escape hatch.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the size in bytes of a single non-trashed file. `None`
|
||||
/// if the file is trashed or absent. Used by cross-drive MOVE to
|
||||
/// know how many bytes will land on the destination drive so the
|
||||
/// pre-move `check_drive_quota` call can fire.
|
||||
pub async fn file_bytes(&self, file_id: Uuid) -> Result<Option<i64>, DomainError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT size::bigint FROM storage.files WHERE id = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("StorageUsage", format!("file_bytes: {e}")))?;
|
||||
Ok(row.map(|(s,)| s))
|
||||
}
|
||||
|
||||
/// Sum the sizes of every non-trashed file whose parent folder is
|
||||
/// `folder_id` itself or a descendant of it via the `lpath` ltree.
|
||||
/// Used by cross-drive MOVE to know how many bytes would land on
|
||||
/// the destination drive — necessary for the pre-move
|
||||
/// `check_drive_quota` call.
|
||||
///
|
||||
/// Returns 0 for an empty subtree AND for a non-existent
|
||||
/// `folder_id` (the JOIN silently drops); callers that need to
|
||||
/// distinguish those two cases must probe the folder separately.
|
||||
pub async fn folder_subtree_bytes(&self, folder_id: Uuid) -> Result<i64, DomainError> {
|
||||
let (bytes,): (Option<i64>,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(f.size), 0)::bigint
|
||||
FROM storage.files f
|
||||
JOIN storage.folders fo ON fo.id = f.folder_id
|
||||
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1)
|
||||
AND NOT f.is_trashed",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("folder_subtree_bytes: {e}"))
|
||||
})?;
|
||||
Ok(bytes.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Same as [`Self::add_drive_storage_usage_delta`] but resolves
|
||||
/// the drive id from a parent folder id in a single statement.
|
||||
/// Avoids a separate `SELECT drive_id FROM storage.folders` round
|
||||
/// trip at the upload hook site (where the folder id is what's
|
||||
/// naturally on the FileDto). The nested SELECT is point-lookup
|
||||
/// on the folder PK; clamp + idempotency properties are
|
||||
/// unchanged.
|
||||
pub async fn add_drive_storage_usage_delta_by_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
// FROM-form UPDATE keeps the same join shape as
|
||||
// `check_drive_quota_by_folder` so both methods agree on
|
||||
// how a folder maps to its drive. A subquery form would
|
||||
// silently `UPDATE … WHERE id = NULL` (matching zero rows)
|
||||
// if the lookup misses; the FROM-form simply doesn't match
|
||||
// — same outcome, more conventional SQL.
|
||||
sqlx::query(
|
||||
"UPDATE storage.drives d
|
||||
SET used_bytes = GREATEST(0, d.used_bytes + $2)
|
||||
FROM storage.folders f
|
||||
WHERE f.drive_id = d.id
|
||||
AND f.id = $1",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(delta)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive delta by folder: {e}"))
|
||||
})?;
|
||||
// See `add_drive_storage_usage_delta` — deliberate no-invalidate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-upload quota check on a single drive.
|
||||
///
|
||||
/// Read-only `SELECT (used_bytes, quota_bytes) FROM storage.drives`;
|
||||
/// returns `QuotaExceeded` when the projected `used_bytes +
|
||||
/// additional_bytes` would breach `quota_bytes`. A `NULL`
|
||||
/// `quota_bytes` short-circuits to `Ok(())` (unlimited drive —
|
||||
/// admin override / future system drives).
|
||||
///
|
||||
/// Soft cap by design: the check/write window matches the
|
||||
/// user-quota path, bounded by the sweep interval. The clamp on
|
||||
/// `add_drive_storage_usage_delta` and the set-based reconciliation
|
||||
/// keep the counter honest; small over-quota slippage during the
|
||||
/// window is acceptable.
|
||||
pub async fn check_drive_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<(i64, Option<i64>)> =
|
||||
sqlx::query_as("SELECT used_bytes, quota_bytes FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((used, quota)) = row else {
|
||||
// Anti-enum at the upload edge would normally map to 404,
|
||||
// but at this layer we surface the typed not-found and let
|
||||
// the caller decide how to react. In practice the upload
|
||||
// path resolves the drive id from a folder/file lookup
|
||||
// first, so this branch fires only on a deleted-drive race.
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
};
|
||||
Self::eval_drive_cap(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Drive-cap verdict over already-fetched counters. Shared by
|
||||
/// [`Self::check_drive_quota`] and the fused
|
||||
/// [`Self::check_upload_quotas`] pair so both produce byte-identical
|
||||
/// errors.
|
||||
fn eval_drive_cap(
|
||||
used: i64,
|
||||
quota: Option<i64>,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let Some(quota) = quota else {
|
||||
return Ok(()); // unlimited
|
||||
};
|
||||
// Saturate on the i64 + u64 sum so a hostile / corrupt counter
|
||||
// can't silently overflow into a negative comparison.
|
||||
let projected = (used as i128) + (additional_bytes as i128);
|
||||
if projected > quota as i128 {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::QuotaExceeded,
|
||||
"Drive",
|
||||
format!(
|
||||
"Drive quota exceeded: {} + {} > {} bytes",
|
||||
used, additional_bytes, quota
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// User-envelope verdict over already-fetched counters. Shared by
|
||||
/// `check_storage_quota` and the fused [`Self::check_upload_quotas`]
|
||||
/// pair so both produce byte-identical errors.
|
||||
fn eval_user_envelope(used: i64, quota: i64, additional_bytes: u64) -> Result<(), DomainError> {
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fused pre-upload gate: user envelope + drive cap in ONE round-trip.
|
||||
///
|
||||
/// Upload entry points used to run `check_storage_quota` then
|
||||
/// `check_drive_quota` as two serial point reads — and the NC chunked
|
||||
/// PUT pays that pair on EVERY chunk. One `LEFT JOIN` row carries both
|
||||
/// counter pairs; verdict precedence (user envelope first, then drive
|
||||
/// existence, then drive cap) and every error shape are identical to
|
||||
/// the two-call sequence (benches/ROUND12.md §6, 1.81x).
|
||||
///
|
||||
/// Row shape shared with [`Self::check_upload_quotas_by_folder`]:
|
||||
/// `(user_used, user_quota, drive_used, drive_quota, drive_found)`.
|
||||
pub async fn check_upload_quotas(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.drives d ON d.id = $2
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// [`Self::check_upload_quotas`] with the drive resolved from a parent
|
||||
/// folder id — for the REST upload paths, which hold `folder_id`.
|
||||
/// A missing folder (or a folder whose drive vanished mid-race) maps to
|
||||
/// `not_found("Folder")`, exactly like `check_drive_quota_by_folder`.
|
||||
pub async fn check_upload_quotas_by_folder(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
folder_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<QuotaPairRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT u.storage_used_bytes, u.storage_quota_bytes,
|
||||
d.used_bytes, d.quota_bytes, (d.id IS NOT NULL)
|
||||
FROM auth.users u
|
||||
LEFT JOIN storage.folders f ON f.id = $2
|
||||
LEFT JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE u.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("upload quota lookup: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((uused, uquota, dused, dquota, drive_found)) = row else {
|
||||
return Err(DomainError::not_found("User", user_id.to_string()));
|
||||
};
|
||||
Self::eval_user_envelope(uused, uquota, additional_bytes)?;
|
||||
if !drive_found {
|
||||
return Err(DomainError::not_found("Folder", folder_id.to_string()));
|
||||
}
|
||||
Self::eval_drive_cap(dused.unwrap_or(0), dquota, additional_bytes)
|
||||
}
|
||||
|
||||
/// Same as [`Self::check_drive_quota`] but resolves the drive id
|
||||
/// from a parent folder id. Mirrors
|
||||
/// [`Self::add_drive_storage_usage_delta_by_folder`] so the upload
|
||||
/// handler (which holds `folder_id` from the multipart form) can
|
||||
/// gate the write in one round trip. Returns
|
||||
/// `DomainError::not_found("Folder", …)` if the folder id doesn't
|
||||
/// resolve — the upload pipeline would 404 on that anyway.
|
||||
pub async fn check_drive_quota_by_folder(
|
||||
&self,
|
||||
folder_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let row: Option<(i64, Option<i64>)> = sqlx::query_as(
|
||||
"SELECT d.used_bytes, d.quota_bytes
|
||||
FROM storage.drives d
|
||||
JOIN storage.folders f ON f.drive_id = d.id
|
||||
WHERE f.id = $1",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive quota by folder: {e}"))
|
||||
})?;
|
||||
|
||||
let Some((used, quota)) = row else {
|
||||
return Err(DomainError::not_found("Folder", folder_id.to_string()));
|
||||
};
|
||||
let Some(quota) = quota else {
|
||||
return Ok(()); // unlimited
|
||||
};
|
||||
let projected = (used as i128) + (additional_bytes as i128);
|
||||
if projected > quota as i128 {
|
||||
return Err(DomainError::new(
|
||||
crate::common::errors::ErrorKind::QuotaExceeded,
|
||||
"Drive",
|
||||
format!(
|
||||
"Drive quota exceeded: {} + {} > {} bytes",
|
||||
used, additional_bytes, quota
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background task that periodically reconciles every user's cached
|
||||
/// `storage_used_bytes` against the actual sum of their files.
|
||||
///
|
||||
@@ -152,8 +593,16 @@ impl StorageUsageService {
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
debug!("Running scheduled storage-usage reconciliation");
|
||||
// Drive sweep runs FIRST: the user-side sweep below
|
||||
// reads `drives.used_bytes` (the per-drive sum) to
|
||||
// compute its own counter, so the drive counter must
|
||||
// be honest first. Failure of one is logged but
|
||||
// doesn't skip the other or the next tick.
|
||||
if let Err(e) = service.update_all_drives_storage_usage().await {
|
||||
error!("Scheduled drive storage-usage reconciliation failed: {}", e);
|
||||
}
|
||||
if let Err(e) = service.update_all_users_storage_usage().await {
|
||||
error!("Scheduled storage-usage reconciliation failed: {}", e);
|
||||
error!("Scheduled user storage-usage reconciliation failed: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -192,16 +641,33 @@ impl StorageUsagePort for StorageUsageService {
|
||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
||||
debug!("Starting storage-usage reconciliation sweep");
|
||||
|
||||
// User envelope = SUM of `drives.used_bytes` across the user's
|
||||
// personal drives. Shared drives don't count against any user
|
||||
// (`docs/plan/drive.md` §7). The drive-side sweep runs FIRST
|
||||
// (`start_reconciliation_job`) so `drives.used_bytes` is
|
||||
// already honest by the time we read it here.
|
||||
//
|
||||
// Ownership lookup uses `role_grants` (canonical per §1) so
|
||||
// both the user's default personal AND any secondary
|
||||
// personals owned via Owner grants are summed. Secondaries
|
||||
// aren't user-creatable today, but a backfill or admin path
|
||||
// can produce them — covering that surface from day one.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users u
|
||||
SET storage_used_bytes = COALESCE(t.total, 0)
|
||||
FROM auth.users u2
|
||||
LEFT JOIN (
|
||||
SELECT user_id, SUM(size)::bigint AS total
|
||||
FROM storage.files
|
||||
WHERE NOT is_trashed
|
||||
GROUP BY user_id
|
||||
SELECT g.subject_id AS user_id,
|
||||
SUM(d.used_bytes)::bigint AS total
|
||||
FROM storage.drives d
|
||||
JOIN storage.role_grants g
|
||||
ON g.resource_type = 'drive'
|
||||
AND g.resource_id = d.id
|
||||
AND g.role = 'owner'
|
||||
AND g.subject_type = 'user'
|
||||
WHERE d.kind = 'personal'
|
||||
GROUP BY g.subject_id
|
||||
) t ON t.user_id = u2.id
|
||||
WHERE u.id = u2.id
|
||||
AND NOT u2.is_external
|
||||
@@ -227,44 +693,84 @@ impl StorageUsagePort for StorageUsageService {
|
||||
user_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
let quota = user.storage_quota_bytes();
|
||||
let used = user.storage_used_bytes();
|
||||
|
||||
// Quota of 0 means unlimited
|
||||
if quota <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let additional = additional_bytes as i64;
|
||||
|
||||
// Case 1: the single file alone exceeds the entire quota
|
||||
if additional > quota {
|
||||
let quota_fmt = format_bytes(quota);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"File size ({}) exceeds your total storage quota ({})",
|
||||
file_fmt, quota_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
// Case 2: the upload would push usage over the quota
|
||||
if used + additional > quota {
|
||||
let available = (quota - used).max(0);
|
||||
let avail_fmt = format_bytes(available);
|
||||
let file_fmt = format_bytes(additional);
|
||||
return Err(DomainError::quota_exceeded(format!(
|
||||
"Not enough storage space. File size: {}, available: {}",
|
||||
file_fmt, avail_fmt
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
// Narrow 2-column read — the full user row carries the up-to-512 KiB
|
||||
// avatar `image` column, paid on every upload quota check otherwise.
|
||||
let (used, quota) = self.user_repository.get_storage_usage(user_id).await?;
|
||||
Self::eval_user_envelope(used, quota, additional_bytes)
|
||||
}
|
||||
|
||||
async fn get_user_storage_info(&self, user_id: Uuid) -> Result<(i64, i64), DomainError> {
|
||||
let user = self.user_repository.get_user_by_id(user_id).await?;
|
||||
Ok((user.storage_used_bytes(), user.storage_quota_bytes()))
|
||||
// Narrow 2-column read (avatar-free) — runs on every folder PROPFIND
|
||||
// that reports quota. See benches/QUOTA-PATH.md.
|
||||
Ok(self.user_repository.get_storage_usage(user_id).await?)
|
||||
}
|
||||
|
||||
async fn add_drive_storage_usage_delta(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
delta: i64,
|
||||
) -> Result<(), DomainError> {
|
||||
StorageUsageService::add_drive_storage_usage_delta(self, drive_id, delta).await
|
||||
}
|
||||
|
||||
/// Reconcile every drive's cached `used_bytes` in ONE set-based UPDATE.
|
||||
///
|
||||
/// Same shape as the per-user sweep above: `LEFT JOIN` over the
|
||||
/// `storage.files` aggregate keyed on `drive_id`, `IS DISTINCT
|
||||
/// FROM` guard to skip no-op rewrites so idle drives don't churn
|
||||
/// dead tuples. Runs from the same reconciliation ticker as the
|
||||
/// user sweep; failure is logged but doesn't stop the next tick.
|
||||
async fn update_all_drives_storage_usage(&self) -> Result<(), DomainError> {
|
||||
debug!("Starting drive storage-usage reconciliation sweep");
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.drives d
|
||||
SET used_bytes = COALESCE(t.total, 0)
|
||||
FROM storage.drives d2
|
||||
LEFT JOIN (
|
||||
SELECT drive_id, SUM(size)::bigint AS total
|
||||
FROM storage.files
|
||||
WHERE NOT is_trashed
|
||||
GROUP BY drive_id
|
||||
) t ON t.drive_id = d2.id
|
||||
WHERE d.id = d2.id
|
||||
AND d.used_bytes IS DISTINCT FROM COALESCE(t.total, 0)
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Drive storage-usage reconciliation sweep failed: {}", e);
|
||||
DomainError::internal_error("StorageUsage", format!("drive reconciliation sweep: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Drive storage-usage reconciliation corrected {} drive(s)",
|
||||
result.rows_affected()
|
||||
);
|
||||
// Unconditional invalidation — do NOT gate on
|
||||
// `rows_affected() > 0`. When a fire-and-forget delta has
|
||||
// already made SQL correct BEFORE the sweep runs, the sweep
|
||||
// touches zero rows but the cache may still hold the
|
||||
// pre-delta value from an earlier `GET /api/drives`. Gating
|
||||
// means the cache stays stale in exactly the case
|
||||
// `trigger-sweep` is called to fix. The invalidation cost is
|
||||
// small (moka `invalidate_all` on both caches); the
|
||||
// correctness guarantee matters. Regression avoidance:
|
||||
// drive_quota.hurl Step 6 exercises this race — 2nd upload's
|
||||
// delta lands during the 200 ms delay, sweep sees SQL is
|
||||
// already right → zero rows → without unconditional
|
||||
// invalidation, cache stays at the previous step's value.
|
||||
self.invalidate_drive_lookup_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_drive_quota(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
additional_bytes: u64,
|
||||
) -> Result<(), DomainError> {
|
||||
StorageUsageService::check_drive_quota(self, drive_id, additional_bytes).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +780,7 @@ impl Clone for StorageUsageService {
|
||||
Self {
|
||||
pool: Arc::clone(&self.pool),
|
||||
user_repository: Arc::clone(&self.user_repository),
|
||||
drive_repo: self.drive_repo.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct SubjectGroupService {
|
||||
/// 30 s TTL. Without this, fresh group-mediated drive grants
|
||||
/// don't appear in `/api/drives` for up to 30 s after `add_member`.
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
/// Same freshness contract for the drive repository's per-user
|
||||
/// readable-drives cache: a membership change on a group that holds
|
||||
/// drive grants changes every affected user's visible drive list,
|
||||
/// so the cached lists drop alongside `user_groups_cache`.
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
@@ -52,12 +57,14 @@ impl SubjectGroupService {
|
||||
pool: Arc<PgPool>,
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
engine: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
pool,
|
||||
user_storage,
|
||||
engine,
|
||||
drive_repo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +217,69 @@ impl SubjectGroupService {
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse if this group is the **sole Owner** of any drive — the
|
||||
// cascade-delete below would otherwise wipe the only `owner`
|
||||
// grant on that drive and leave it orphaned (no one can ever
|
||||
// manage it again). The check is "for every drive where this
|
||||
// group holds Owner, does another Owner exist?". A single drive
|
||||
// failing the check is enough to refuse.
|
||||
//
|
||||
// Matching D3a's last-owner-protection rule on `set_member_role`
|
||||
// / `remove_member` — they catch the case where the drive's
|
||||
// last Owner is *directly* a user or group being demoted /
|
||||
// removed via the membership API. This guard catches the same
|
||||
// invariant from the group-lifecycle side.
|
||||
let orphaning: Option<(Uuid,)> = sqlx::query_as(
|
||||
r#"
|
||||
WITH group_owned AS (
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE subject_type = 'group'
|
||||
AND subject_id = $1
|
||||
AND resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
)
|
||||
SELECT resource_id
|
||||
FROM storage.role_grants
|
||||
WHERE resource_type = 'drive'
|
||||
AND role = 'owner'
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
AND resource_id IN (SELECT resource_id FROM group_owned)
|
||||
GROUP BY resource_id
|
||||
HAVING COUNT(*) = 1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"SubjectGroup",
|
||||
format!("sole-owner check: {e}"),
|
||||
)
|
||||
})?;
|
||||
if let Some((drive_id,)) = orphaning {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group_delete.rejected",
|
||||
reason = "sole_drive_owner",
|
||||
group_id = %id,
|
||||
drive_id = %drive_id,
|
||||
by = %caller_id,
|
||||
"👮🏻♂️ refused group delete — sole Owner of drive {drive_id}",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::Conflict,
|
||||
"SubjectGroup",
|
||||
"Group is the sole Owner of at least one shared drive — \
|
||||
promote another Owner first or delete the drive."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Atomically delete grants pointing at this group, then the group
|
||||
// itself. If either fails, both roll back.
|
||||
let mut tx = self.pool.begin().await.map_err(|e| {
|
||||
@@ -363,6 +433,7 @@ impl SubjectGroupService {
|
||||
// call for up to 30 s.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -406,6 +477,23 @@ impl SubjectGroupService {
|
||||
// user is still reachable via another path after this remove,
|
||||
// they stay in the set on the post-state, so the check would
|
||||
// pass on the next remove instead.
|
||||
// For a nested child-group removal the child's transitive user set is
|
||||
// needed twice: by the would-empty pre-check below AND, after the
|
||||
// remove, as the cache-invalidation set. The edge delete is ABOVE the
|
||||
// child, so it cannot change the child's descendants — compute the
|
||||
// recursive CTE ONCE here and reuse it, instead of the identical query
|
||||
// running twice (the second was hidden inside `invalidation_targets`).
|
||||
// (benches/ROUND23.md §G1)
|
||||
let child_users: Option<Vec<uuid::Uuid>> = match member {
|
||||
GroupMember::Group(child_id) => Some(
|
||||
self.repo
|
||||
.list_transitive_users(child_id)
|
||||
.await
|
||||
.map_err(map_repo_err)?,
|
||||
),
|
||||
GroupMember::User(_) => None,
|
||||
};
|
||||
|
||||
let users_before = self
|
||||
.repo
|
||||
.list_transitive_users(group_id)
|
||||
@@ -414,18 +502,17 @@ impl SubjectGroupService {
|
||||
if !users_before.is_empty() {
|
||||
let would_be_empty = match member {
|
||||
GroupMember::User(uid) => users_before.len() == 1 && users_before.contains(&uid),
|
||||
GroupMember::Group(child_id) => {
|
||||
// For child-group removal: would this drop the
|
||||
// parent's transitive user set to 0? Look up the
|
||||
// child's transitive users — if every user in the
|
||||
// parent's set comes through the child, removing the
|
||||
// child empties the parent.
|
||||
let child_users = self
|
||||
.repo
|
||||
.list_transitive_users(child_id)
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
!child_users.is_empty() && users_before.iter().all(|u| child_users.contains(u))
|
||||
GroupMember::Group(_) => {
|
||||
// Would removing this child drop the parent's transitive
|
||||
// user set to 0? Reuse the child's transitive users
|
||||
// computed above — if every user in the parent's set comes
|
||||
// through the child, removing the child empties the parent.
|
||||
let child_users = child_users.as_deref().unwrap_or(&[]);
|
||||
// Set probe instead of an O(|before|·|child|) slice scan
|
||||
// (benches/ROUND11.md §13: 5.7x at 500×500).
|
||||
let child_set: std::collections::HashSet<&uuid::Uuid> =
|
||||
child_users.iter().collect();
|
||||
!child_users.is_empty() && users_before.iter().all(|u| child_set.contains(u))
|
||||
}
|
||||
};
|
||||
if would_be_empty {
|
||||
@@ -460,8 +547,17 @@ impl SubjectGroupService {
|
||||
// ancestor. Without this, a removed-from-group user keeps
|
||||
// appearing as a transitive member in `expand_subject_for_listing`
|
||||
// for up to 30 s, surfacing grants they no longer have.
|
||||
for uid in self.invalidation_targets(member).await? {
|
||||
//
|
||||
// Reuse the child's transitive users computed above (unchanged by the
|
||||
// edge delete) as the invalidation set — no second recursive CTE. For a
|
||||
// `User` member it's just that user. (benches/ROUND23.md §G1)
|
||||
let invalidation: Vec<uuid::Uuid> = match member {
|
||||
GroupMember::User(uid) => vec![uid],
|
||||
GroupMember::Group(_) => child_users.unwrap_or_default(),
|
||||
};
|
||||
for uid in invalidation {
|
||||
self.engine.invalidate_user_groups_cache(uid).await;
|
||||
self.drive_repo.invalidate_readable_for_user(uid).await;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -571,7 +667,9 @@ mod integration_tests {
|
||||
// future test starts exercising real authz lookups.
|
||||
let engine =
|
||||
Arc::new(crate::infrastructure::services::pg_acl_engine::PgAclEngine::new_stub());
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine)
|
||||
let drive_repo =
|
||||
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
|
||||
SubjectGroupService::new(repo, pool, user_storage, engine, drive_repo)
|
||||
}
|
||||
|
||||
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
|
||||
|
||||
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
classify_display, format_file_size, intern_display, intern_mime,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
@@ -14,7 +14,7 @@ use crate::application::dtos::trash_dto::{
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_lifecycle::FileLifecycleHook;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use crate::domain::entities::file::File;
|
||||
@@ -24,7 +24,6 @@ use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
use crate::domain::services::authorization::ResourceKind;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
|
||||
@@ -49,9 +48,6 @@ pub struct TrashService {
|
||||
/// Repository for trash-specific operations like listing and retrieving trashed items
|
||||
trash_repository: Arc<TrashDbRepository>,
|
||||
|
||||
/// Port for file read operations (get file metadata)
|
||||
file_read_port: Arc<FileBlobReadRepository>,
|
||||
|
||||
/// Port for file write operations (trash, restore, delete)
|
||||
file_write_port: Arc<FileBlobWriteRepository>,
|
||||
|
||||
@@ -75,19 +71,14 @@ pub struct TrashService {
|
||||
/// so trash listings filter by drive membership instead of the legacy
|
||||
/// per-user scope.
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
|
||||
/// Number of days items should be kept in trash before automatic cleanup
|
||||
retention_days: u32,
|
||||
}
|
||||
|
||||
impl TrashService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
trash_repository: Arc<TrashDbRepository>,
|
||||
file_read_port: Arc<FileBlobReadRepository>,
|
||||
file_write_port: Arc<FileBlobWriteRepository>,
|
||||
folder_storage_port: Arc<FolderDbRepository>,
|
||||
retention_days: u32,
|
||||
dedup_service: Arc<DedupService>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
authz: Arc<PgAclEngine>,
|
||||
@@ -95,7 +86,6 @@ impl TrashService {
|
||||
) -> Self {
|
||||
Self {
|
||||
trash_repository,
|
||||
file_read_port,
|
||||
file_write_port,
|
||||
folder_storage_port,
|
||||
dedup_service,
|
||||
@@ -103,7 +93,6 @@ impl TrashService {
|
||||
content_cache,
|
||||
authz,
|
||||
drive_repo,
|
||||
retention_days,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,25 +115,31 @@ impl TrashService {
|
||||
"folder-icon".to_string(),
|
||||
),
|
||||
TrashedItemType::File => {
|
||||
let name = item.name();
|
||||
// Use empty MIME type to leverage extension fallback
|
||||
let category = category_for(name, "").to_string();
|
||||
let icon_class = icon_class_for(name, "").to_string();
|
||||
let icon_special_class = icon_special_class_for(name, "").to_string();
|
||||
(category, icon_class, icon_special_class)
|
||||
// Use empty MIME type to leverage extension fallback; one
|
||||
// fused pass lowers the extension once instead of three
|
||||
// times (benches/ROUND11.md §21).
|
||||
let classes = classify_display(item.name(), "");
|
||||
(
|
||||
classes.category.to_string(),
|
||||
classes.icon_class.to_string(),
|
||||
classes.icon_special_class.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Move the owned Strings out of the consumed item — the getter
|
||||
// `.to_string()` clones paid 2 extra allocations per trash row.
|
||||
let parts = item.into_parts();
|
||||
TrashedItemDto {
|
||||
id: item.id().to_string(),
|
||||
original_id: item.original_id().to_string(),
|
||||
item_type: match item.item_type() {
|
||||
id: parts.id.to_string(),
|
||||
original_id: parts.original_id.to_string(),
|
||||
item_type: match parts.item_type {
|
||||
TrashedItemType::File => "file".to_string(),
|
||||
TrashedItemType::Folder => "folder".to_string(),
|
||||
},
|
||||
name: item.name().to_string(),
|
||||
original_path: item.original_path().to_string(),
|
||||
trashed_at: item.trashed_at(),
|
||||
name: parts.name,
|
||||
original_path: parts.original_path,
|
||||
trashed_at: parts.trashed_at,
|
||||
days_until_deletion,
|
||||
category,
|
||||
icon_class,
|
||||
@@ -177,23 +172,17 @@ impl TrashUseCase for TrashService {
|
||||
// Note: We now verify file/folder ownership BEFORE moving to trash.
|
||||
// This prevents users from trashing items they do not own (IDOR).
|
||||
|
||||
// Parse UUIDs with detailed error handling
|
||||
// Parse UUIDs with detailed error handling. The parsed value is
|
||||
// re-derived per branch below; this early check preserves the 400
|
||||
// (validation) error shape for malformed ids.
|
||||
debug!("Validating item UUID: {}", item_id);
|
||||
let item_uuid = match Uuid::parse_str(item_id) {
|
||||
Ok(uuid) => {
|
||||
debug!("Valid item UUID: {}", uuid);
|
||||
uuid
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Invalid item UUID: {} - Error: {}", item_id, e);
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid item ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let user_uuid = user_id;
|
||||
if let Err(e) = Uuid::parse_str(item_id) {
|
||||
error!("Invalid item UUID: {} - Error: {}", item_id, e);
|
||||
return Err(DomainError::validation_error(format!(
|
||||
"Invalid item ID: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
match item_type {
|
||||
"file" => {
|
||||
@@ -209,59 +198,13 @@ impl TrashUseCase for TrashService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Authz already passed — use the non-owner-scoped read so that
|
||||
// grantees with Delete permission can trash files they don't own.
|
||||
// The file's user_id in storage.files is unchanged, so the item
|
||||
// will appear in the original owner's trash view.
|
||||
let file = match self.file_read_port.get_file(item_id).await {
|
||||
Ok(file) => {
|
||||
debug!("File found: {} ({})", file.name(), item_id);
|
||||
file
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error getting file: {} - {}", item_id, e);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"File",
|
||||
format!("Error retrieving file {}: {}", item_id, e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let original_path = file.storage_path().to_string();
|
||||
debug!("Original file path: {}", original_path);
|
||||
|
||||
debug!("Creating TrashedItem object for the file");
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::File,
|
||||
file.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
debug!(
|
||||
"TrashedItem created successfully: {} -> {}",
|
||||
file.name(),
|
||||
trashed_item.id()
|
||||
);
|
||||
|
||||
// First add to trash index to register the item
|
||||
info!("Adding file {} to trash index", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => {
|
||||
debug!("File added to trash index successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error adding file to trash index: {}", e);
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add file to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Then physically move the file to trash.
|
||||
// Soft-delete model: the is_trashed flag on the row IS the
|
||||
// trash membership — there is no separate trash index to
|
||||
// register into (`TrashRepository::add_to_trash` is a
|
||||
// documented no-op). The previous shape still fetched the
|
||||
// full file entity and built a `TrashedItem` only to feed
|
||||
// that no-op: one wasted SELECT per trash operation.
|
||||
//
|
||||
// §14: caller_id stamps `updated_by` on the trashed row.
|
||||
info!("Physically moving file to trash: {}", item_id);
|
||||
match self.file_write_port.move_to_trash(item_id, user_id).await {
|
||||
@@ -293,43 +236,10 @@ impl TrashUseCase for TrashService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
.folder_storage_port
|
||||
.get_folder(item_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"Folder",
|
||||
format!("Error retrieving folder {}: {}", item_id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let original_path = folder.storage_path().to_string();
|
||||
|
||||
let trashed_item = TrashedItem::new(
|
||||
item_uuid,
|
||||
user_uuid,
|
||||
TrashedItemType::Folder,
|
||||
folder.name().to_string(),
|
||||
original_path,
|
||||
self.retention_days,
|
||||
);
|
||||
|
||||
// First add to trash index to register the item
|
||||
debug!("Adding folder {} to trash repository", item_id);
|
||||
match self.trash_repository.add_to_trash(&trashed_item).await {
|
||||
Ok(_) => debug!("Successfully added folder to trash repository"),
|
||||
Err(e) => {
|
||||
error!("Failed to add folder to trash repository: {}", e);
|
||||
return Err(DomainError::internal_error(
|
||||
"TrashRepository",
|
||||
format!("Failed to add folder to trash: {}", e),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Then physically move the folder to trash.
|
||||
// Soft-delete model — same as the file branch above: the
|
||||
// cascade UPDATE below is the whole operation; no folder
|
||||
// fetch or trash-index write needed.
|
||||
//
|
||||
// §14: caller_id stamps `updated_by` on every cascade-trashed row.
|
||||
self.folder_storage_port
|
||||
.move_to_trash(item_id, user_id)
|
||||
@@ -632,6 +542,21 @@ impl TrashUseCase for TrashService {
|
||||
// Permanently delete the folder
|
||||
let folder_id = item.original_id().to_string();
|
||||
|
||||
// Snapshot the cascade's file ids BEFORE the bulk
|
||||
// DELETE so `on_file_deleted` fires per cascaded
|
||||
// file (same shape as the bulk `clear_trash_in`
|
||||
// path at line ~804). Skipped when no hook is
|
||||
// registered — the enumeration is a SQL round-trip
|
||||
// we don't want to pay for nothing.
|
||||
let cascaded_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
||||
self.folder_storage_port
|
||||
.list_file_ids_in_subtree(&folder_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
info!("Permanently deleting folder: {}", folder_id);
|
||||
match self
|
||||
.folder_storage_port
|
||||
@@ -666,6 +591,12 @@ impl TrashUseCase for TrashService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hook) = &self.file_deleted_hook {
|
||||
for file_id in &cascaded_file_ids {
|
||||
hook.on_file_deleted(file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,13 +656,52 @@ impl TrashUseCase for TrashService {
|
||||
// the drives where the caller is effectively Owner (direct or via a
|
||||
// group). Single-drive users: this resolves to just their personal
|
||||
// drive, identical to the legacy `WHERE user_id = $1` scope.
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
let drive_ids = self.drives_with_delete_for(user_id).await?;
|
||||
if drive_ids.is_empty() {
|
||||
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
||||
return Ok(());
|
||||
}
|
||||
self.clear_trash_in(&drive_ids, user_id).await
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
||||
// this as a per-row affordance so multi-drive owners can clear
|
||||
// one drive without touching the others.
|
||||
//
|
||||
// Route through `authz.require(Delete, Drive)` so the denial
|
||||
// shape stays consistent with every other write verb: 403 when
|
||||
// the caller has Read on the drive (viewer/editor holding no
|
||||
// Delete), 404 when they don't (anti-enum). Before 2026-07-16
|
||||
// this method rolled its own `drives_with_delete_for` check +
|
||||
// hardcoded `NotFound` — that predated the graduated-denial
|
||||
// engine change and returned 404 unconditionally even for a
|
||||
// Viewer who could see the drive in `/api/drives`. The engine
|
||||
// now emits `authz.denied` with `visibility="visible"|"hidden"`
|
||||
// and the standard mapping renders it as 403 or 404.
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(user_id),
|
||||
Permission::Delete,
|
||||
Resource::Drive(drive_id),
|
||||
)
|
||||
.await?;
|
||||
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
||||
self.clear_trash_in(&[drive_id], user_id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl TrashService {
|
||||
/// Drives where the caller has `Permission::Delete` (via any role
|
||||
/// bundle, direct or group-mediated). Shared by `empty_trash` and
|
||||
/// `empty_trash_for_drive`; lifting the lookup out of both methods
|
||||
/// keeps the two HTTP surfaces semantically consistent and avoids
|
||||
/// duplicating the subject-expansion plumbing.
|
||||
async fn drives_with_delete_for(&self, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||
let drives = self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -739,29 +709,32 @@ impl TrashUseCase for TrashService {
|
||||
format!("Failed to resolve accessible drives: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
let drive_ids: Vec<Uuid> = drives
|
||||
Ok(drives
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.caller_role
|
||||
.is_some_and(|r| r.expand().contains(&Permission::Delete))
|
||||
})
|
||||
.map(|d| d.drive.id)
|
||||
.collect();
|
||||
.collect())
|
||||
}
|
||||
|
||||
if drive_ids.is_empty() {
|
||||
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
|
||||
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
|
||||
// get_trash_items) because the trash_items view excludes files inside a
|
||||
// trashed folder — those files will still be deleted by clear_trash via
|
||||
// the folder CASCADE, but their hooks would otherwise be missed.
|
||||
/// Bulk-clear trash within the given drives, running every side
|
||||
/// effect once: trashed-file id list (for hooks), `clear_trash`
|
||||
/// SQL, dedup GC, content-cache invalidation, file-deleted hook.
|
||||
/// The two `TrashUseCase` entry points compose this with their
|
||||
/// respective drive-id scopes — call-once, no duplication.
|
||||
async fn clear_trash_in(&self, drive_ids: &[Uuid], user_id: Uuid) -> Result<()> {
|
||||
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks
|
||||
// (thumbnail cleanup, etc.) can run afterward. We use
|
||||
// `get_all_trashed_file_ids` (not `get_trash_items`) because the
|
||||
// trash_items view excludes files inside a trashed folder —
|
||||
// those files will still be deleted by `clear_trash` via the
|
||||
// folder CASCADE, but their hooks would otherwise be missed.
|
||||
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
||||
match self
|
||||
.trash_repository
|
||||
.get_all_trashed_file_ids(&drive_ids)
|
||||
.get_all_trashed_file_ids(drive_ids)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
@@ -781,29 +754,33 @@ impl TrashUseCase for TrashService {
|
||||
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
|
||||
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
|
||||
// decrements blob ref_counts for every deleted file row.
|
||||
self.trash_repository.clear_trash(&drive_ids).await?;
|
||||
self.trash_repository.clear_trash(drive_ids).await?;
|
||||
|
||||
// The PG trigger decremented ref_counts but cannot delete disk files or
|
||||
// thumbnails. Run garbage_collect() to remove any blobs whose ref_count
|
||||
// reached 0, along with their blob-keyed thumbnail files.
|
||||
// The PG trigger decremented ref_counts but cannot delete disk
|
||||
// files or thumbnails. `garbage_collect()` removes any blobs
|
||||
// whose ref_count reached 0, along with their blob-keyed
|
||||
// thumbnail files. Failure here is non-fatal — the rows are
|
||||
// gone in any case; the next GC pass mops up.
|
||||
if let Err(e) = self.dedup_service.garbage_collect().await {
|
||||
warn!("empty_trash: garbage_collect failed: {:?}", e);
|
||||
warn!("clear_trash_in: garbage_collect failed: {:?}", e);
|
||||
}
|
||||
|
||||
// Invalidate content cache for all permanently deleted files.
|
||||
if let Some(cc) = &self.content_cache {
|
||||
for file_id in &trashed_file_ids {
|
||||
cc.invalidate(file_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hook) = &self.file_deleted_hook {
|
||||
for file_id in &trashed_file_ids {
|
||||
hook.on_file_deleted(file_id);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Trash emptied for user {}", user_id);
|
||||
info!(
|
||||
"Trash cleared across {} drive(s) for user {}",
|
||||
drive_ids.len(),
|
||||
user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -832,16 +809,8 @@ impl TrashService {
|
||||
// D2b: scope by drives the caller can read (resolved through
|
||||
// role_grants on resource_type='drive', including group-mediated
|
||||
// grants). Empty set → empty page without a SQL round-trip.
|
||||
let (subject_types, subject_ids) = self
|
||||
.authz
|
||||
.expand_subject_for_listing(Subject::User(user_id))
|
||||
.await?;
|
||||
let drive_ids: Vec<Uuid> = match self
|
||||
.drive_repo
|
||||
.list_for_subjects(&subject_types, &subject_ids)
|
||||
.await
|
||||
{
|
||||
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
|
||||
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
|
||||
Ok(drives) => drives.iter().map(|d| d.drive.id).collect(),
|
||||
Err(e) => {
|
||||
return Err(DomainError::internal_error(
|
||||
"Trash",
|
||||
@@ -897,16 +866,18 @@ fn build_trash_cursor(row: &TrashResourceRow, order_by: &str, reverse: bool) ->
|
||||
|
||||
/// Convert a raw repository row into the API DTO.
|
||||
fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
let path = row.path.clone().unwrap_or_default();
|
||||
// `row` is owned and dropped at fn end, so move its String fields into the
|
||||
// DTO instead of cloning (the favorites / recent / folder row mappers
|
||||
// already move these same fields — trash was missed). benches/ROUND19.md §M4.
|
||||
let path = row.path.unwrap_or_default();
|
||||
if row.resource_type == "folder" {
|
||||
let resource_id = row.resource_id.to_string();
|
||||
let dto = FolderDto {
|
||||
etag: resource_id.clone(),
|
||||
id: resource_id,
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
parent_id: row.parent_id.map(|u| u.to_string()),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
// D2b: the trash listing query now SELECTs `drive_id` (the
|
||||
// unified view exposes it). Surfaced so per-drive grouping
|
||||
// in the `/trash` UI doesn't need an extra lookup per row.
|
||||
@@ -914,12 +885,11 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: row.modified_at.timestamp() as u64,
|
||||
is_root: false,
|
||||
icon_class: std::sync::Arc::from("fas fa-folder"),
|
||||
icon_special_class: std::sync::Arc::from("folder-icon"),
|
||||
category: std::sync::Arc::from("Folder"),
|
||||
// §14 provenance not selected by the trash listing query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
icon_class: intern_display("fas fa-folder"),
|
||||
icon_special_class: intern_display("folder-icon"),
|
||||
category: intern_display("Folder"),
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
};
|
||||
TrashResourceItemDto {
|
||||
resource_type: ResourceTypeDto::Folder,
|
||||
@@ -938,32 +908,31 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
|
||||
// match GET/HEAD/PROPFIND ETags — a client restoring a
|
||||
// file may conditional-request it immediately after.
|
||||
let modified_at_u = row.modified_at.timestamp() as u64;
|
||||
let content_hash = row.blob_hash.clone().unwrap_or_default();
|
||||
let content_hash = row.blob_hash.unwrap_or_default();
|
||||
let etag = if content_hash.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
File::compute_etag(&content_hash, modified_at_u)
|
||||
};
|
||||
let classes = classify_display(&row.name, mime);
|
||||
let dto = FileDto {
|
||||
id: row.resource_id.to_string(),
|
||||
name: row.name.clone(),
|
||||
name: row.name,
|
||||
path,
|
||||
size: size_bytes,
|
||||
mime_type: std::sync::Arc::from(mime),
|
||||
mime_type: intern_mime(mime),
|
||||
folder_id: row.parent_id.map(|u| u.to_string()),
|
||||
created_at: row.resource_created_at.timestamp() as u64,
|
||||
modified_at: modified_at_u,
|
||||
icon_class: std::sync::Arc::from(icon_class_for(&row.name, mime)),
|
||||
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
|
||||
category: std::sync::Arc::from(category_for(&row.name, mime)),
|
||||
icon_class: intern_display(classes.icon_class),
|
||||
icon_special_class: intern_display(classes.icon_special_class),
|
||||
category: intern_display(classes.category),
|
||||
size_formatted: format_file_size(size_bytes),
|
||||
owner_id: Some(row.owner_id.to_string()),
|
||||
sort_date: None,
|
||||
content_hash,
|
||||
etag,
|
||||
// §14 provenance not selected by the trash listing query.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
created_by: row.created_by,
|
||||
updated_by: row.updated_by,
|
||||
};
|
||||
TrashResourceItemDto {
|
||||
resource_type: ResourceTypeDto::File,
|
||||
|
||||
@@ -319,6 +319,14 @@ where
|
||||
// via `drive_repo.list_for_subjects` + role-bundle filter.
|
||||
self.trash_repository.clear_trash(&[user_id]).await
|
||||
}
|
||||
|
||||
async fn empty_trash_for_drive(&self, _user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||
// Test mock — uses the passed-in drive id verbatim. Production
|
||||
// checks the caller's Delete-bearing drives first and refuses
|
||||
// with NotFound on a mismatch; the mock skips that and just
|
||||
// clears the given drive directly.
|
||||
self.trash_repository.clear_trash(&[drive_id]).await
|
||||
}
|
||||
}
|
||||
|
||||
// Mock repositories for testing
|
||||
@@ -528,15 +536,6 @@ impl FileReadPort for MockFileRepository {
|
||||
Ok((Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn count_files(
|
||||
&self,
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> std::result::Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
&self,
|
||||
_folder_id: &str,
|
||||
@@ -546,15 +545,6 @@ impl FileReadPort for MockFileRepository {
|
||||
> {
|
||||
Ok(Box::pin(futures::stream::empty()))
|
||||
}
|
||||
|
||||
async fn get_file_for_owner(
|
||||
&self,
|
||||
id: &str,
|
||||
_owner_id: Uuid,
|
||||
) -> std::result::Result<File, DomainError> {
|
||||
// In this mock, ignore ownership — trash tests don't focus on ownership
|
||||
self.get_file(id).await
|
||||
}
|
||||
}
|
||||
|
||||
impl FileWritePort for MockFileRepository {
|
||||
@@ -599,6 +589,7 @@ impl FileWritePort for MockFileRepository {
|
||||
_size: u64,
|
||||
_modified_at: Option<i64>,
|
||||
_caller_id: Uuid,
|
||||
_expected_hash: Option<&str>,
|
||||
) -> std::result::Result<(String, i64), DomainError> {
|
||||
Ok((String::new(), 0))
|
||||
}
|
||||
@@ -737,10 +728,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
async fn list_root_folders_for_caller(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
) -> std::result::Result<Vec<Folder>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
@@ -755,10 +745,9 @@ impl FolderRepository for MockFolderRepository {
|
||||
Ok((vec![], Some(0)))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
async fn list_root_folders_for_caller_paginated(
|
||||
&self,
|
||||
_parent_id: Option<&str>,
|
||||
_owner_id: Uuid,
|
||||
_caller_id: Uuid,
|
||||
_offset: usize,
|
||||
_limit: usize,
|
||||
_include_total: bool,
|
||||
|
||||
@@ -62,6 +62,28 @@ impl UserLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgraded: log-and-continue. Called by
|
||||
/// `AuthApplicationService::upgrade_to_internal` after the
|
||||
/// `is_external = false` UPDATE persists. Same log-and-continue
|
||||
/// semantics as `dispatch_created` — the row is already updated,
|
||||
/// hook failure at (e.g.) home-drive provisioning is recoverable
|
||||
/// on the next login via `PersonalDriveLifecycleHook::on_user_login`
|
||||
/// (its safety-net path already handles the "user is internal but
|
||||
/// no drive yet" case idempotently).
|
||||
pub async fn dispatch_upgraded_to_internal(&self, user: &User) {
|
||||
for h in &self.hooks {
|
||||
if let Err(e) = h.on_upgraded_to_internal(user).await {
|
||||
tracing::error!(
|
||||
target: "user_lifecycle",
|
||||
hook = h.name(),
|
||||
user_id = %user.id(),
|
||||
error = %e,
|
||||
"on_upgraded_to_internal failed; drive provisioning will retry on next login"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Login: log-and-continue. Same reasoning as `dispatch_created`.
|
||||
/// Must fire BEFORE `user.register_login()` so that hooks observing
|
||||
/// `last_login_at().is_none()` correctly detect the first-ever login.
|
||||
@@ -199,6 +221,19 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> {
|
||||
// Post-upgrade state — `is_external` is already `false` here
|
||||
// (the service persisted before dispatching), so we don't log
|
||||
// it as a field; the event name carries the transition.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "user.upgraded_to_internal",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,14 +31,24 @@ pub struct WopiTokenClaims {
|
||||
|
||||
/// Service for generating and validating WOPI access tokens.
|
||||
pub struct WopiTokenService {
|
||||
secret: String,
|
||||
/// Pre-built signing key — `EncodingKey::from_secret` copies the secret into
|
||||
/// a fresh `Vec` on each call, so build it once (mirrors `JwtTokenService`).
|
||||
encoding_key: EncodingKey,
|
||||
/// Pre-built verification key — same copy-per-call cost as `encoding_key`.
|
||||
decoding_key: DecodingKey,
|
||||
/// Pre-built HS256 validation config — `Validation::new` allocates a
|
||||
/// `required_spec_claims` HashSet + an `algorithms` Vec; Office/Collabora
|
||||
/// hosts poll `validate_token` continuously (benches/ROUND19.md §M2).
|
||||
validation: Validation,
|
||||
token_ttl_secs: i64,
|
||||
}
|
||||
|
||||
impl WopiTokenService {
|
||||
pub fn new(secret: String, token_ttl_secs: i64) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
|
||||
validation: Validation::new(Algorithm::HS256),
|
||||
token_ttl_secs,
|
||||
}
|
||||
}
|
||||
@@ -64,12 +74,7 @@ impl WopiTokenService {
|
||||
iat: now,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| {
|
||||
let token = encode(&Header::default(), &claims, &self.encoding_key).map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InternalError,
|
||||
"WopiTokenService",
|
||||
@@ -83,25 +88,19 @@ impl WopiTokenService {
|
||||
|
||||
/// Validate a WOPI access token and extract its claims.
|
||||
pub fn validate_token(&self, token: &str) -> Result<WopiTokenClaims, DomainError> {
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
|
||||
let token_data = decode::<WopiTokenClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
"WOPI token expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
format!("Invalid WOPI token: {}", e),
|
||||
),
|
||||
})?;
|
||||
let token_data = decode::<WopiTokenClaims>(token, &self.decoding_key, &self.validation)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
"WOPI token expired",
|
||||
),
|
||||
_ => DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"WopiTokenService",
|
||||
format!("Invalid WOPI token: {}", e),
|
||||
),
|
||||
})?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user