Merge pull request #610 from EdouardVanbelle/security/grants2
This commit is contained in:
@@ -60,6 +60,25 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
||||
caller_id: Uuid,
|
||||
) -> Result<FileDto, DomainError>;
|
||||
|
||||
/// `_with_perms` variant of `upload_file_streaming` — enforces
|
||||
/// `Create` on the target folder before registering the row.
|
||||
///
|
||||
/// AuthZ audit #17 (2026-07-12): the chunked-upload `complete`
|
||||
/// path called plain `upload_file_streaming` at finalize; a grant
|
||||
/// revoked between session open and finalize stayed effective
|
||||
/// until the caller landed the final chunk (up to 24h JWT TTL,
|
||||
/// forever with app-passwords). Handlers now call this variant
|
||||
/// so the engine re-checks at finalize regardless of how long
|
||||
/// the session was open.
|
||||
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>;
|
||||
|
||||
/// Replace the content of the file at `path` with an already-ingested
|
||||
/// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT).
|
||||
///
|
||||
|
||||
@@ -1924,6 +1924,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?;
|
||||
|
||||
@@ -535,10 +535,16 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 (2026-07-12): previously required
|
||||
// `Permission::Update`, which is NOT in the Contributor bundle
|
||||
// (Read + Create) — Contributor grantees on a shared address
|
||||
// book couldn't add contacts via REST or CardDAV PUT despite
|
||||
// holding the intended Create permission. `Delete` uses Delete
|
||||
// (audit #13, above); creation must use Create. Same fix
|
||||
// applied to `create_contact_from_vcard` + `create_group`.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
// Convert DTOs to domain entities
|
||||
@@ -614,10 +620,13 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 — see the sibling `create_contact` above.
|
||||
// This is the CardDAV `PUT contact.vcf` entry point; the fix
|
||||
// unblocks Contributor grantees creating contacts through the
|
||||
// CardDAV protocol as well as the REST surface.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
// Parse vCard data
|
||||
@@ -756,8 +765,14 @@ impl ContactUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Update)
|
||||
// AuthZ audit #13 (2026-07-12): previously required
|
||||
// `Permission::Update`, which the Editor role bundle satisfies
|
||||
// (Read + Comment + Create + Update). Every Editor grantee on a
|
||||
// shared address book could delete individual contacts — a
|
||||
// silent privilege escalation because the intent for CardDAV
|
||||
// deletion is Delete, not Update. Sibling
|
||||
// `CalendarService::delete_event` was the ground-truth pattern.
|
||||
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Delete)
|
||||
.await?;
|
||||
|
||||
// Delete the contact
|
||||
@@ -902,10 +917,10 @@ impl ContactUseCase for ContactService {
|
||||
let address_book_id = Uuid::parse_str(&dto.address_book_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid address book ID format"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
// AuthZ audit #19 — see the sibling `create_contact` above.
|
||||
let caller_id = Uuid::parse_str(&dto.user_id)
|
||||
.map_err(|_| DomainError::validation_error("Invalid user ID format"))?;
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update)
|
||||
self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create)
|
||||
.await?;
|
||||
|
||||
let group = ContactGroup::new(address_book_id, dto.name);
|
||||
@@ -959,8 +974,11 @@ impl ContactUseCase for ContactService {
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
|
||||
|
||||
// Check if user has write access to the address book
|
||||
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update)
|
||||
// AuthZ audit #13 (2026-07-12): see the sibling `delete_contact`
|
||||
// above — required `Update` (in the Editor bundle) instead of
|
||||
// `Delete`, letting any Editor on a shared address book delete
|
||||
// groups they shouldn't.
|
||||
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Delete)
|
||||
.await?;
|
||||
|
||||
// Delete the group
|
||||
|
||||
@@ -480,6 +480,11 @@ impl DriveManagementService {
|
||||
/// 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,
|
||||
|
||||
@@ -457,6 +457,44 @@ impl FileUploadUseCase for FileUploadService {
|
||||
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).
|
||||
///
|
||||
|
||||
@@ -581,7 +581,7 @@ impl FolderUseCase for FolderService {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let folder = self
|
||||
let renamed = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name, caller_id)
|
||||
.await
|
||||
@@ -592,7 +592,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
|
||||
|
||||
@@ -19,6 +19,13 @@ use uuid::Uuid;
|
||||
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 {
|
||||
@@ -27,6 +34,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +254,9 @@ impl StorageUsageService {
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -285,6 +333,7 @@ impl StorageUsageService {
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("StorageUsage", format!("drive delta by folder: {e}"))
|
||||
})?;
|
||||
// See `add_drive_storage_usage_delta` — deliberate no-invalidate.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -595,6 +644,20 @@ impl StorageUsagePort for StorageUsageService {
|
||||
"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(())
|
||||
}
|
||||
|
||||
@@ -613,6 +676,7 @@ impl Clone for StorageUsageService {
|
||||
Self {
|
||||
pool: Arc::clone(&self.pool),
|
||||
user_repository: Arc::clone(&self.user_repository),
|
||||
drive_repo: self.drive_repo.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,22 +662,25 @@ impl TrashUseCase for TrashService {
|
||||
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. Refuses with
|
||||
// `NotFound` (anti-enum) when the caller lacks Delete on the
|
||||
// named drive — same shape as the user-facing drive listing
|
||||
// would emit for an unknown id.
|
||||
let allowed = self.drives_with_delete_for(user_id).await?;
|
||||
if !allowed.contains(&drive_id) {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "trash.empty_drive_rejected",
|
||||
reason = "no_delete_on_drive",
|
||||
user_id = %user_id,
|
||||
drive_id = %drive_id,
|
||||
"👮🏻♂️ refused per-drive empty — caller lacks Delete on this drive",
|
||||
);
|
||||
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
+14
-1
@@ -1056,14 +1056,26 @@ impl AppServiceFactory {
|
||||
_repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
maintenance_pool: &Arc<PgPool>,
|
||||
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
) -> Arc<StorageUsageService> {
|
||||
let user_repository = Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
// The `drive_repo` passed in is the SAME instance held on
|
||||
// `AppState`, so its `readable_cache` / `default_drive_cache`
|
||||
// are the caches the request path reads from. A separately
|
||||
// constructed `DrivePgRepository` would have its OWN caches
|
||||
// and invalidation would be a no-op observed by nobody —
|
||||
// this is the trap that regressed the used_bytes freshness
|
||||
// after perf commit `12dc648c`.
|
||||
let service = Arc::new(
|
||||
crate::application::services::storage_usage_service::StorageUsageService::new(
|
||||
maintenance_pool.clone(),
|
||||
user_repository,
|
||||
)
|
||||
.with_drive_repo(
|
||||
drive_repo
|
||||
as Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
|
||||
),
|
||||
);
|
||||
// Keep cached storage usage fresh off the request path: GET /api/auth/me
|
||||
@@ -1250,7 +1262,8 @@ impl AppServiceFactory {
|
||||
// 3c. Storage usage / quota service (needed by the instant-upload
|
||||
// path inside the application services, and re-exposed on AppState
|
||||
// for the handler-side quota checks of the byte-upload paths).
|
||||
let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool);
|
||||
let storage_usage =
|
||||
self.create_storage_usage_service(&repos, &pool, &maintenance_pool, drive_repo.clone());
|
||||
|
||||
// 3d. Content index (embedded Tantivy) — opened before application
|
||||
// services so SearchService can hold the query port; the feeding
|
||||
|
||||
@@ -511,6 +511,17 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
||||
) -> Result<FileDto, DomainError> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
|
||||
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> {
|
||||
Ok(FileDto::default())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -188,6 +188,29 @@ pub trait DriveRepository: Send + Sync + 'static {
|
||||
/// content first so a single click can't wipe a populated drive.
|
||||
async fn is_empty(&self, drive_id: Uuid) -> Result<bool, DriveRepositoryError>;
|
||||
|
||||
/// Drop the cached readable-drive list for one user. Called by
|
||||
/// service-layer code paths that mutate state affecting a specific
|
||||
/// caller's drive listing (grant writes, membership changes) but
|
||||
/// don't reach through the drive-repo itself. Default no-op — the
|
||||
/// no-cache stubs need no plumbing.
|
||||
async fn invalidate_readable_for_user(&self, _user_id: Uuid) {}
|
||||
|
||||
/// Drop every cached readable-drive list. Called when the affected
|
||||
/// user set is unknown at this layer — group-subject grants, drive
|
||||
/// deletion, policy edits, root-folder renames (drive.name is
|
||||
/// sourced from the root folder, so a rename affects the listing
|
||||
/// for every user with a grant on the drive). Default no-op.
|
||||
fn invalidate_readable_all(&self) {}
|
||||
|
||||
/// Drop every entry in the "default drive per user" cache. Called
|
||||
/// from paths that mutate a drive's display name or its root
|
||||
/// folder id at the concrete cache level (root-folder rename is
|
||||
/// the only one today). Same class of bug as
|
||||
/// `invalidate_readable_all` — the cache holds a `DriveWithRootName`
|
||||
/// with `root_folder_name` baked in, so a rename would otherwise
|
||||
/// stay stale for the cache TTL. Default no-op.
|
||||
fn invalidate_default_drive_all(&self) {}
|
||||
|
||||
/// Hard-delete a drive: its `role_grants` rows, its root folder,
|
||||
/// and the drive row itself, in one transaction. Caller is
|
||||
/// responsible for ensuring `is_empty` first; this method does
|
||||
|
||||
@@ -25,10 +25,11 @@ use crate::domain::repositories::drive_repository::{
|
||||
/// policy edits — all of which invalidate explicitly below), yet it is
|
||||
/// re-resolved on EVERY NextCloud request (basic-auth chroot), every
|
||||
/// native `/webdav` request (Mode-B scope resolution) and every WOPI
|
||||
/// call. 30 s mirrors `drive_role_cache` in `pg_acl_engine.rs` and bounds
|
||||
/// the one non-invalidated staleness source: a root-folder *rename*,
|
||||
/// which doesn't pass through this repository. Measured in
|
||||
/// `benches/CHROOT-CACHE.md`.
|
||||
/// call. 30 s mirrors `drive_role_cache` in `pg_acl_engine.rs`. Root-
|
||||
/// folder renames — which don't pass through this repository directly
|
||||
/// — invalidate via the `DriveRepository::invalidate_default_drive_all`
|
||||
/// trait hook called from `folder_service::rename_folder_with_perms`
|
||||
/// when `parent_id IS NULL`. Measured in `benches/CHROOT-CACHE.md`.
|
||||
const DEFAULT_DRIVE_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// One entry per active user; entries are small (a `Drive` + a name).
|
||||
@@ -56,11 +57,19 @@ pub struct DrivePgRepository {
|
||||
/// through this repository or `DriveManagementService` invalidates
|
||||
/// explicitly (per-user when the subject is a User, whole cache for
|
||||
/// Group subjects, whose transitive membership is not resolvable
|
||||
/// here). Residual staleness — a root-folder rename or a grant
|
||||
/// written by a path that can't reach this cache — is bounded by
|
||||
/// the same 30 s TTL the sibling caches accept; actual permission
|
||||
/// enforcement is unaffected (the ACL engine re-checks per
|
||||
/// operation with its own invalidation).
|
||||
/// here). Root-folder renames — which update `drive.name` because it
|
||||
/// reads through `folders.name` of the root row — also invalidate,
|
||||
/// via the trait's `invalidate_readable_all` hook called from
|
||||
/// `folder_service::rename_folder_with_perms` when
|
||||
/// `parent_id IS NULL`. That path was missed by the perf commit
|
||||
/// that introduced this cache (`12dc648c`) and surfaced by
|
||||
/// `drives_membership.hurl` Step 23; the trait hook closes it
|
||||
/// without folder_service knowing about the concrete moka cache.
|
||||
///
|
||||
/// Residual staleness — a grant written by a path that can't reach
|
||||
/// this cache — is bounded by the same 30 s TTL the sibling caches
|
||||
/// accept; actual permission enforcement is unaffected (the ACL
|
||||
/// engine re-checks per operation with its own invalidation).
|
||||
readable_cache: Cache<Uuid, Arc<Vec<DriveWithRootName>>>,
|
||||
}
|
||||
|
||||
@@ -93,6 +102,15 @@ impl DrivePgRepository {
|
||||
self.readable_cache.invalidate_all();
|
||||
}
|
||||
|
||||
/// Drop every cached `default_drive_cache` entry. Exposed as a
|
||||
/// `pub` sibling of the whole-cache invalidators above so trait
|
||||
/// callers holding a `dyn DriveRepository` can trigger the same
|
||||
/// cleanup path (e.g. `folder_service` on root-folder rename —
|
||||
/// see `impl DriveRepository` below).
|
||||
pub fn invalidate_default_drive_all(&self) {
|
||||
self.default_drive_cache.invalidate_all();
|
||||
}
|
||||
|
||||
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
|
||||
if let sqlx::Error::Database(ref dberr) = e
|
||||
&& let Some(code) = dberr.code()
|
||||
@@ -212,6 +230,22 @@ impl DrivePgRepository {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl DriveRepository for DrivePgRepository {
|
||||
async fn invalidate_readable_for_user(&self, user_id: Uuid) {
|
||||
// Delegate to the inherent method — the trait forwarding lets
|
||||
// callers holding a `dyn DriveRepository` (e.g. `folder_service`
|
||||
// on a root-folder rename) trigger invalidation without knowing
|
||||
// about the concrete cache.
|
||||
DrivePgRepository::invalidate_readable_for_user(self, user_id).await;
|
||||
}
|
||||
|
||||
fn invalidate_readable_all(&self) {
|
||||
DrivePgRepository::invalidate_readable_all(self);
|
||||
}
|
||||
|
||||
fn invalidate_default_drive_all(&self) {
|
||||
DrivePgRepository::invalidate_default_drive_all(self);
|
||||
}
|
||||
|
||||
async fn create_personal_drive_atomic(
|
||||
&self,
|
||||
owner_id: Uuid,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{DefaultBodyLimit, Json, Multipart, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
IntoResponse,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
@@ -27,8 +27,10 @@ use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::services::authorization::{Resource, Subject};
|
||||
use crate::interfaces::api::handlers::dedup_handler::{get_stats, recalculate_stats};
|
||||
use crate::interfaces::api::handlers::search_handler::clear_search_cache;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::admin::require_admin;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -89,6 +91,22 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route("/plugins/{id}/logs/stream", get(stream_plugin_logs))
|
||||
.route("/plugins/{id}/retention", get(get_plugin_retention))
|
||||
.route("/plugins/{id}/retention", put(set_plugin_retention))
|
||||
// Search — operator flush of the shared moka results cache
|
||||
// (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics
|
||||
// touch every tenant, so this is admin-only. Lived at
|
||||
// `/api/search/cache` pre-2026-07-17; the URL now declares
|
||||
// its admin intent up front.
|
||||
.route("/search/cache", delete(clear_search_cache))
|
||||
// Dedup — global storage stats + integrity recalculation
|
||||
// (AuthZ audit #24 + #25, 2026-07-17). Both are operator-only
|
||||
// observability / maintenance surfaces (blob-count-level data
|
||||
// + verify_integrity sweep). Moved here from `/api/dedup/*`
|
||||
// so the URL declares admin intent and the middleware layer
|
||||
// enforces it — same pattern as `search/cache` above. The
|
||||
// any-authenticated sibling routes (`/check`, `/check-batch`,
|
||||
// `/blob/{hash}`) stay at `/api/dedup/*`.
|
||||
.route("/dedup/stats", get(get_stats))
|
||||
.route("/dedup/recalculate", post(recalculate_stats))
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
@@ -121,14 +139,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
///
|
||||
/// Thin wrapper over the shared `require_admin` middleware helper so this
|
||||
/// handler keeps a stable signature while the implementation lives next to
|
||||
/// the new `subject_group_handler` that also needs it.
|
||||
async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, String), AppError> {
|
||||
require_admin(state, headers).await
|
||||
}
|
||||
// Every route under `/api/admin/*` is gated by the
|
||||
// `require_admin` middleware layer wired at the router nest point
|
||||
// (`routes.rs::admin_router`). Handlers no longer need an inline
|
||||
// guard call — the caller is guaranteed to be admin by construction.
|
||||
// Callers that need the caller's id read it from the `AuthUser`
|
||||
// extractor (`middleware::auth::AuthUser`), populated by the outer
|
||||
// `auth_middleware`.
|
||||
|
||||
/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel
|
||||
#[utoipa::path(
|
||||
@@ -144,10 +161,7 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(Uuid, Str
|
||||
)]
|
||||
pub async fn get_oidc_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state
|
||||
.admin_settings_service
|
||||
.as_ref()
|
||||
@@ -175,10 +189,10 @@ pub async fn get_oidc_settings(
|
||||
)]
|
||||
pub async fn save_oidc_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<SaveOidcSettingsDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (user_id, _) = admin_guard(&state, &headers).await?;
|
||||
let user_id = auth_user.id;
|
||||
|
||||
let svc = state
|
||||
.admin_settings_service
|
||||
@@ -200,11 +214,8 @@ pub async fn save_oidc_settings(
|
||||
/// POST /api/admin/settings/oidc/test — test OIDC discovery
|
||||
async fn test_oidc_connection(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<TestOidcConnectionDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state
|
||||
.admin_settings_service
|
||||
.as_ref()
|
||||
@@ -236,10 +247,7 @@ async fn test_oidc_connection(
|
||||
)]
|
||||
pub async fn get_storage_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state
|
||||
.storage_settings_service
|
||||
.as_ref()
|
||||
@@ -267,10 +275,10 @@ pub async fn get_storage_settings(
|
||||
)]
|
||||
pub async fn save_storage_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<SaveStorageSettingsDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (user_id, _) = admin_guard(&state, &headers).await?;
|
||||
let user_id = auth_user.id;
|
||||
|
||||
let svc = state
|
||||
.storage_settings_service
|
||||
@@ -292,11 +300,8 @@ pub async fn save_storage_settings(
|
||||
/// POST /api/admin/settings/storage/test — test storage backend connection
|
||||
async fn test_storage_connection(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<TestStorageConnectionDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let svc = state
|
||||
.storage_settings_service
|
||||
.as_ref()
|
||||
@@ -328,9 +333,7 @@ async fn test_storage_connection(
|
||||
)]
|
||||
pub async fn get_migration_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let s = state.migration_state.read().await;
|
||||
Ok(Json(migration_state_to_dto(&s)))
|
||||
}
|
||||
@@ -350,13 +353,10 @@ pub async fn get_migration_status(
|
||||
)]
|
||||
pub async fn start_migration(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<StartMigrationDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
|
||||
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
// Check not already running.
|
||||
{
|
||||
let s = state.migration_state.read().await;
|
||||
@@ -428,10 +428,8 @@ pub async fn start_migration(
|
||||
)]
|
||||
pub async fn pause_migration(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let mut s = state.migration_state.write().await;
|
||||
if s.status != MigrationStatus::Running {
|
||||
@@ -459,10 +457,8 @@ pub async fn pause_migration(
|
||||
)]
|
||||
pub async fn resume_migration(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
// Set status back to Running — the background task checks on each blob.
|
||||
let mut s = state.migration_state.write().await;
|
||||
@@ -491,10 +487,8 @@ pub async fn resume_migration(
|
||||
)]
|
||||
pub async fn complete_migration(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use crate::infrastructure::services::migration_blob_backend::MigrationStatus;
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let s = state.migration_state.read().await;
|
||||
if s.status != MigrationStatus::Completed {
|
||||
@@ -531,11 +525,8 @@ pub async fn complete_migration(
|
||||
)]
|
||||
pub async fn verify_migration(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<VerifyMigrationDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let pool = state
|
||||
.db_pool
|
||||
.clone()
|
||||
@@ -607,12 +598,7 @@ fn migration_state_to_dto(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn generate_encryption_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
pub async fn generate_encryption_key() -> Result<impl IntoResponse, AppError> {
|
||||
let key =
|
||||
crate::infrastructure::services::encrypted_blob_backend::EncryptedBlobBackend::generate_key(
|
||||
);
|
||||
@@ -670,10 +656,7 @@ fn build_backend_from_config(
|
||||
)]
|
||||
pub async fn get_dashboard_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
@@ -761,11 +744,8 @@ pub async fn get_dashboard_stats(
|
||||
)]
|
||||
pub async fn list_users(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ListUsersQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
@@ -810,11 +790,8 @@ pub async fn list_users(
|
||||
)]
|
||||
pub async fn get_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
let auth = state
|
||||
@@ -847,10 +824,10 @@ pub async fn get_user(
|
||||
)]
|
||||
pub async fn delete_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
@@ -897,11 +874,11 @@ pub async fn delete_user(
|
||||
)]
|
||||
pub async fn update_user_role(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<UpdateUserRoleDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
@@ -948,11 +925,11 @@ pub async fn update_user_role(
|
||||
)]
|
||||
pub async fn update_user_active(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<UpdateUserActiveDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
@@ -1003,12 +980,9 @@ pub async fn update_user_active(
|
||||
)]
|
||||
pub async fn update_user_quota(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<UpdateUserQuotaDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
let auth = state
|
||||
@@ -1049,11 +1023,8 @@ pub async fn update_user_quota(
|
||||
)]
|
||||
pub async fn create_user(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<AdminCreateUserDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let auth = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
@@ -1090,12 +1061,9 @@ pub async fn create_user(
|
||||
)]
|
||||
pub async fn reset_user_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<AdminResetPasswordDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?;
|
||||
|
||||
let auth = state
|
||||
@@ -1141,10 +1109,10 @@ pub async fn reset_user_password(
|
||||
)]
|
||||
pub async fn set_registration_setting(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
|
||||
let enabled = body
|
||||
.get("registration_enabled")
|
||||
@@ -1177,10 +1145,7 @@ pub async fn set_registration_setting(
|
||||
|
||||
async fn reextract_audio_metadata(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let audio_service = state
|
||||
.applications
|
||||
.audio_metadata_service
|
||||
@@ -1207,10 +1172,7 @@ async fn reextract_audio_metadata(
|
||||
/// Photos timeline by real capture date. Safe to re-run (idempotent upsert).
|
||||
async fn reextract_image_metadata(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let result = state
|
||||
.applications
|
||||
.media_metadata_service
|
||||
@@ -1253,12 +1215,7 @@ async fn reextract_image_metadata(
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn get_smtp_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
async fn get_smtp_info(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> {
|
||||
let smtp = &state.core.config.smtp;
|
||||
let info = SmtpInfoDto {
|
||||
enabled: smtp.is_enabled() && state.email_sender.is_some(),
|
||||
@@ -1287,11 +1244,8 @@ async fn get_smtp_info(
|
||||
/// returns 404 to keep the endpoint inert.
|
||||
async fn get_captured_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<CapturedEmailQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
if !std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
@@ -1347,10 +1301,10 @@ struct CapturedEmailQuery {
|
||||
)]
|
||||
async fn send_smtp_test(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Json(dto): Json<SendSmtpTestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
|
||||
let recipient = dto.to.trim().to_string();
|
||||
if recipient.is_empty() {
|
||||
@@ -1462,9 +1416,7 @@ fn map_mgmt_err(err: &PluginMgmtError) -> AppError {
|
||||
/// GET /api/admin/plugins — list installed plugins.
|
||||
pub async fn list_plugins(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
let plugins: Vec<PluginInfoDto> = mgmt.list().into_iter().map(PluginInfoDto::from).collect();
|
||||
// `enabled` reports that the plugin *subsystem* is active (reaching here
|
||||
@@ -1479,11 +1431,11 @@ pub async fn list_plugins(
|
||||
/// PUT /api/admin/plugins/{id}/enabled — enable or disable a plugin.
|
||||
pub async fn set_plugin_enabled(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<SetEnabledDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
mgmt.set_enabled(&id, dto.enabled)
|
||||
.map_err(|e| map_mgmt_err(&e))?;
|
||||
@@ -1520,10 +1472,10 @@ pub async fn set_plugin_enabled(
|
||||
/// single `bundle` part: a `.zip` containing `plugin.toml` and its `.wasm`.
|
||||
pub async fn install_plugin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
|
||||
let mut bundle: Option<Vec<u8>> = None;
|
||||
@@ -1584,10 +1536,10 @@ pub async fn install_plugin(
|
||||
/// DELETE /api/admin/plugins/{id} — uninstall a plugin and delete its files.
|
||||
pub async fn delete_plugin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
mgmt.remove(&id).map_err(|e| map_mgmt_err(&e))?;
|
||||
|
||||
@@ -1609,11 +1561,9 @@ pub async fn delete_plugin(
|
||||
/// structured log entries (newest first).
|
||||
pub async fn get_plugin_logs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<PluginLogQueryDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
|
||||
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
||||
@@ -1637,10 +1587,10 @@ pub async fn get_plugin_logs(
|
||||
/// DELETE /api/admin/plugins/{id}/logs — wipe a plugin's persisted logs.
|
||||
pub async fn clear_plugin_logs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
mgmt.clear_logs(&id).await.map_err(|e| map_mgmt_err(&e))?;
|
||||
|
||||
@@ -1664,13 +1614,11 @@ pub async fn clear_plugin_logs(
|
||||
/// so `EventSource` works without setting headers.
|
||||
pub async fn stream_plugin_logs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
|
||||
|
||||
admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
if !mgmt.list().iter().any(|p| p.id == id) {
|
||||
return Err(AppError::not_found("Plugin not found"));
|
||||
@@ -1698,10 +1646,8 @@ pub async fn stream_plugin_logs(
|
||||
/// GET /api/admin/plugins/{id}/retention — the plugin's effective retention.
|
||||
pub async fn get_plugin_retention(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
let settings = mgmt
|
||||
.get_retention(&id)
|
||||
@@ -1713,11 +1659,11 @@ pub async fn get_plugin_retention(
|
||||
/// PUT /api/admin/plugins/{id}/retention — set the plugin's retention policy.
|
||||
pub async fn set_plugin_retention(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(dto): Json<PluginRetentionDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let mgmt = plugin_mgmt(&state)?;
|
||||
mgmt.set_retention(&id, dto.into())
|
||||
.await
|
||||
@@ -1761,9 +1707,7 @@ pub async fn set_plugin_retention(
|
||||
)]
|
||||
pub async fn list_all_drives(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let drives = state
|
||||
.drive_repo
|
||||
.list_all()
|
||||
@@ -1799,10 +1743,8 @@ pub async fn list_all_drives(
|
||||
)]
|
||||
pub async fn list_drive_members_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
let grants = state
|
||||
.authorization
|
||||
.list_grants_on_resource(Resource::Drive(drive_id))
|
||||
@@ -1862,11 +1804,11 @@ fn admin_parse_subject(kind: SubjectTypeDto, id: Uuid) -> Subject {
|
||||
)]
|
||||
pub async fn add_drive_member_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
|
||||
Json(dto): Json<AdminAddDriveMemberDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let subject = admin_parse_subject(dto.subject.kind, dto.subject.id);
|
||||
let grant = state
|
||||
.drive_management_service
|
||||
@@ -1907,7 +1849,7 @@ pub async fn add_drive_member_admin(
|
||||
)]
|
||||
pub async fn update_drive_member_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
|
||||
Uuid,
|
||||
SubjectTypeDto,
|
||||
@@ -1915,7 +1857,7 @@ pub async fn update_drive_member_admin(
|
||||
)>,
|
||||
Json(dto): Json<AdminUpdateDriveMemberDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let subject = admin_parse_subject(kind, subject_id);
|
||||
let grant = state
|
||||
.drive_management_service
|
||||
@@ -1954,14 +1896,14 @@ pub async fn update_drive_member_admin(
|
||||
)]
|
||||
pub async fn remove_drive_member_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
axum::extract::Path((drive_id, kind, subject_id)): axum::extract::Path<(
|
||||
Uuid,
|
||||
SubjectTypeDto,
|
||||
Uuid,
|
||||
)>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
let subject = admin_parse_subject(kind, subject_id);
|
||||
state
|
||||
.drive_management_service
|
||||
@@ -1996,10 +1938,10 @@ pub async fn remove_drive_member_admin(
|
||||
)]
|
||||
pub async fn delete_drive_admin(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
auth_user: AuthUser,
|
||||
axum::extract::Path(drive_id): axum::extract::Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
let admin_id = auth_user.id;
|
||||
state
|
||||
.drive_management_service
|
||||
.delete_drive(admin_id, true, drive_id)
|
||||
@@ -2055,15 +1997,11 @@ fn internal_endpoints_disabled() -> axum::response::Response {
|
||||
)]
|
||||
pub async fn internal_trigger_sweep(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if !state.core.config.features.enable_admin_internal_endpoints {
|
||||
return internal_endpoints_disabled();
|
||||
}
|
||||
if let Err(e) = admin_guard(&state, &headers).await {
|
||||
return e.into_response();
|
||||
}
|
||||
let svc = match state.storage_usage_service.as_ref() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
@@ -2135,16 +2073,12 @@ pub struct InternalTriggerGcQuery {
|
||||
)]
|
||||
pub async fn internal_trigger_gc(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<InternalTriggerGcQuery>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if !state.core.config.features.enable_admin_internal_endpoints {
|
||||
return internal_endpoints_disabled();
|
||||
}
|
||||
if let Err(e) = admin_guard(&state, &headers).await {
|
||||
return e.into_response();
|
||||
}
|
||||
let result = if query.force {
|
||||
state.core.dedup_service.garbage_collect_force().await
|
||||
} else {
|
||||
@@ -2211,16 +2145,12 @@ pub struct InternalTriggerGrantCleanupQuery {
|
||||
)]
|
||||
pub async fn internal_trigger_grant_cleanup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<InternalTriggerGrantCleanupQuery>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if !state.core.config.features.enable_admin_internal_endpoints {
|
||||
return internal_endpoints_disabled();
|
||||
}
|
||||
if let Err(e) = admin_guard(&state, &headers).await {
|
||||
return e.into_response();
|
||||
}
|
||||
// Daemon may be disabled by config even when the internal-endpoint
|
||||
// gate is on. Return 503 (rather than 404 or 500) so integration
|
||||
// tests can distinguish "surface not exposed" from "surface
|
||||
|
||||
@@ -188,9 +188,14 @@ impl ChunkedUploadHandler {
|
||||
|
||||
// ── Permission pre-check: caller must have Create on the target
|
||||
// folder BEFORE we allocate a session and accept chunks. The
|
||||
// upload service re-checks at finalize time, but failing here
|
||||
// avoids wasting client+server resources on chunks that will be
|
||||
// rejected. None = caller's root namespace, no check needed.
|
||||
// upload service re-checks at finalize via
|
||||
// `upload_file_streaming_with_perms` (AuthZ audit #17 fix,
|
||||
// 2026-07-16) so a grant revoked mid-session is caught. This
|
||||
// pre-check is the fail-fast: it avoids wasting client+server
|
||||
// resources on chunks that will be rejected anyway. `None`
|
||||
// means the write lands at drive-root — that path is currently
|
||||
// unchecked (session doesn't carry `drive_id`; tracked with the
|
||||
// folder-id-walking follow-up).
|
||||
if let Some(ref fid) = request.folder_id
|
||||
&& let Err(err) = state
|
||||
.applications
|
||||
@@ -441,9 +446,17 @@ impl ChunkedUploadHandler {
|
||||
}
|
||||
|
||||
// Register the file row against the ingested blob.
|
||||
//
|
||||
// AuthZ audit #17 (2026-07-12): swapped `upload_file_streaming` →
|
||||
// `upload_file_streaming_with_perms` so `Create` on the target
|
||||
// folder is re-verified at finalize. Session creation already
|
||||
// pre-checked (line ~198), but that was potentially hours or
|
||||
// days ago; app-passwords keep sessions valid indefinitely.
|
||||
// Without the finalize re-check, a grant revoked mid-session
|
||||
// stayed effective until the last chunk landed.
|
||||
let size = ingested.size;
|
||||
match upload_service
|
||||
.upload_file_streaming(
|
||||
.upload_file_streaming_with_perms(
|
||||
parts.filename.clone(),
|
||||
parts.folder_id.clone(),
|
||||
ingested.content_type.clone(),
|
||||
@@ -478,7 +491,12 @@ impl ChunkedUploadHandler {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create file from chunked upload: {:?}", e);
|
||||
AppError::internal_error(format!("Failed to create file: {}", e)).into_response()
|
||||
// AuthZ audit #2 (2026-07-12) — route DomainError through
|
||||
// `AppError::from` so graduated denial from
|
||||
// `upload_file_streaming_with_perms` keeps the 403/404
|
||||
// shape instead of collapsing into a 500. Sibling
|
||||
// `cancel_upload_impl` at :514 already uses this pattern.
|
||||
AppError::from(e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,9 +537,15 @@ impl ChunkedUploadHandler {
|
||||
// routes.rs calls these free functions directly.
|
||||
// TODO: collapse back into the impl block after a utoipa upgrade resolves the issue.
|
||||
|
||||
/// **Deprecated.** Prefer `/api/files/delta/*` — hash-first negotiation,
|
||||
/// resumable, chunked. The `/api/uploads/*` family stays for backward
|
||||
/// compatibility with existing clients but receives no new features.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/uploads",
|
||||
description = "**Deprecated.** Prefer the delta-upload surface at `/api/files/delta/*` \
|
||||
(hash-first negotiation, resumable, chunked). The `/api/uploads/*` family is kept for \
|
||||
backward compatibility with existing clients but is no longer receiving new features.",
|
||||
request_body(content = CreateUploadRequest, content_type = "application/json", description = "Upload session parameters"),
|
||||
responses(
|
||||
(status = 201, description = "Upload session created", body = crate::application::ports::chunked_upload_ports::CreateUploadResponseDto),
|
||||
@@ -531,6 +555,7 @@ impl ChunkedUploadHandler {
|
||||
tag = "uploads",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
#[deprecated(note = "prefer /api/files/delta/*")]
|
||||
pub async fn create_upload(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -539,9 +564,11 @@ pub async fn create_upload(
|
||||
ChunkedUploadHandler::create_upload_impl(state, auth_user, request).await
|
||||
}
|
||||
|
||||
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/uploads/{upload_id}",
|
||||
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
|
||||
params(
|
||||
("upload_id" = String, Path, description = "Upload session ID"),
|
||||
("chunk_index" = usize, Query, description = "Zero-based chunk index"),
|
||||
@@ -570,6 +597,7 @@ pub async fn create_upload(
|
||||
tag = "uploads",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
#[deprecated(note = "prefer /api/files/delta/*")]
|
||||
pub async fn upload_chunk(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -683,9 +711,11 @@ pub async fn upload_chunk(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
|
||||
#[utoipa::path(
|
||||
head,
|
||||
path = "/api/uploads/{upload_id}",
|
||||
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
|
||||
params(
|
||||
("upload_id" = String, Path, description = "Upload session ID"),
|
||||
),
|
||||
@@ -696,6 +726,7 @@ pub async fn upload_chunk(
|
||||
tag = "uploads",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
#[deprecated(note = "prefer /api/files/delta/*")]
|
||||
pub async fn get_upload_status(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -704,9 +735,11 @@ pub async fn get_upload_status(
|
||||
ChunkedUploadHandler::get_upload_status_impl(state, auth_user, path).await
|
||||
}
|
||||
|
||||
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/uploads/{upload_id}/complete",
|
||||
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
|
||||
params(
|
||||
("upload_id" = String, Path, description = "Upload session ID"),
|
||||
),
|
||||
@@ -731,6 +764,7 @@ pub async fn get_upload_status(
|
||||
tag = "uploads",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
#[deprecated(note = "prefer /api/files/delta/*")]
|
||||
pub async fn complete_upload(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
@@ -744,9 +778,11 @@ pub async fn complete_upload(
|
||||
ChunkedUploadHandler::complete_upload_impl(state, auth_user, path, req).await
|
||||
}
|
||||
|
||||
/// **Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/uploads/{upload_id}",
|
||||
description = "**Deprecated.** See `POST /api/uploads` for the migration note.",
|
||||
params(
|
||||
("upload_id" = String, Path, description = "Upload session ID"),
|
||||
),
|
||||
@@ -757,6 +793,7 @@ pub async fn complete_upload(
|
||||
tag = "uploads",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
#[deprecated(note = "prefer /api/files/delta/*")]
|
||||
pub async fn cancel_upload(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
|
||||
@@ -218,18 +218,16 @@ impl DedupHandler {
|
||||
/// - Deduplication ratio
|
||||
pub(super) async fn get_stats_impl(
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
_auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
// Admin-only — global dedup statistics are sensitive infrastructure data
|
||||
if auth_user.role != "admin" {
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Admin role required"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// AuthZ audit #24 (2026-07-17): admin check moved to the
|
||||
// `/api/admin/*` middleware layer. Reaching this handler means
|
||||
// the caller is admin by construction — the bespoke role
|
||||
// string comparison here (`auth_user.role != "admin"` → 403
|
||||
// with a hand-rolled JSON body, no audit line) is gone. The
|
||||
// route is registered at `admin_handler::admin_routes()`;
|
||||
// moving the URL to `/api/admin/dedup/stats` also declares
|
||||
// the admin intent up front.
|
||||
let dedup = &state.core.dedup_service;
|
||||
let stats = dedup.get_stats().await;
|
||||
|
||||
@@ -343,16 +341,10 @@ impl DedupHandler {
|
||||
State(state): State<GlobalState>,
|
||||
auth_user: AuthUser,
|
||||
) -> impl IntoResponse {
|
||||
// Admin-only — integrity verification is a privileged operation
|
||||
if auth_user.role != "admin" {
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"error": "Admin role required"}"#))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// AuthZ audit #25 (2026-07-17): admin check moved to the
|
||||
// `/api/admin/*` middleware layer — see the sibling
|
||||
// `get_stats_impl` comment. `auth_user` is kept so the
|
||||
// success-side audit line carries the caller id.
|
||||
let dedup = &state.core.dedup_service;
|
||||
|
||||
// Verify integrity first
|
||||
@@ -392,6 +384,21 @@ impl DedupHandler {
|
||||
savings_percentage: savings_pct,
|
||||
};
|
||||
|
||||
// AuthZ audit #25 (2026-07-17): integrity recalculation is a
|
||||
// low-frequency privileged operation — landing an audit event
|
||||
// so security reviews can see who ran verify + integrity
|
||||
// sweeps and when. The pre-fix path emitted no audit line at
|
||||
// all (the accepted 200 was silent from the security POV).
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "dedup.integrity_recalculated",
|
||||
caller_id = %auth_user.id,
|
||||
unique_blobs = response.unique_blobs,
|
||||
total_references = response.total_references,
|
||||
bytes_saved = response.bytes_saved,
|
||||
"🧮 dedup integrity verified and stats recomputed by admin",
|
||||
);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
@@ -453,12 +460,13 @@ pub async fn check_hashes_batch(
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/dedup/stats",
|
||||
path = "/api/admin/dedup/stats",
|
||||
responses(
|
||||
(status = 200, description = "Deduplication statistics", body = StatsResponse),
|
||||
(status = 403, description = "Admin role required"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 403, description = "Caller is not an admin"),
|
||||
),
|
||||
tag = "dedup",
|
||||
tag = "admin",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn get_stats(state: State<GlobalState>, auth_user: AuthUser) -> impl IntoResponse {
|
||||
@@ -489,13 +497,14 @@ pub async fn get_blob(
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/dedup/recalculate",
|
||||
path = "/api/admin/dedup/recalculate",
|
||||
responses(
|
||||
(status = 200, description = "Statistics after integrity verification", body = StatsResponse),
|
||||
(status = 403, description = "Admin role required"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 403, description = "Caller is not an admin"),
|
||||
(status = 500, description = "Integrity verification failed"),
|
||||
),
|
||||
tag = "dedup",
|
||||
tag = "admin",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn recalculate_stats(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
extract::{Json, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
@@ -11,6 +11,7 @@ use crate::application::dtos::search_dto::{
|
||||
};
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -187,40 +188,57 @@ impl SearchHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /search/cache — clears the search results cache.
|
||||
/// `DELETE /admin/search/cache` — flush the shared moka search
|
||||
/// results cache. Admin-only.
|
||||
///
|
||||
/// AuthZ audit #14 (2026-07-12): pre-fix this endpoint lived at
|
||||
/// `/api/search/cache` and required only a valid JWT — any
|
||||
/// authenticated user (external / magic-link included) could
|
||||
/// DELETE it in a loop and keep the results cache cold indefinitely
|
||||
/// (sustained DoS on every subsequent `/api/search` query). Now
|
||||
/// mounted at `/api/admin/search/cache`, gated by the
|
||||
/// `require_admin` middleware layer on the `/api/admin` nest point.
|
||||
/// The handler no longer needs an inline authz call — reaching
|
||||
/// this code implies `AuthUser` is admin by construction. Audit
|
||||
/// line on success so operator-driven flushes are traceable in
|
||||
/// security reviews.
|
||||
pub(super) async fn clear_search_cache_impl(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Response, AppError> {
|
||||
let caller_id = auth_user.id;
|
||||
info!("API: Clearing search cache");
|
||||
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let Some(search_service) = &state.applications.search_service else {
|
||||
error!("Search service not available");
|
||||
return Ok((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
match search_service.clear_search_cache().await {
|
||||
Ok(_) => {
|
||||
info!("Search cache cleared successfully");
|
||||
(
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "search.cache_cleared",
|
||||
caller_id = %caller_id,
|
||||
"🧹 search results cache flushed by admin",
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({ "message": "Search cache cleared successfully" })),
|
||||
)
|
||||
.into_response()
|
||||
.into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Error clearing search cache: {}", err);
|
||||
(
|
||||
Ok((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "Error clearing search cache" })),
|
||||
)
|
||||
.into_response()
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,14 +386,19 @@ pub async fn suggest_files(
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/search/cache",
|
||||
path = "/api/admin/search/cache",
|
||||
responses(
|
||||
(status = 200, description = "Cache cleared"),
|
||||
(status = 401, description = "Missing or invalid token"),
|
||||
(status = 403, description = "Caller is not an admin"),
|
||||
(status = 503, description = "Search service unavailable"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "search"
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn clear_search_cache(state: State<Arc<AppState>>) -> impl IntoResponse {
|
||||
SearchHandler::clear_search_cache_impl(state).await
|
||||
pub async fn clear_search_cache(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Response, AppError> {
|
||||
SearchHandler::clear_search_cache_impl(state, auth_user).await
|
||||
}
|
||||
|
||||
@@ -332,23 +332,57 @@ async fn put_file(
|
||||
};
|
||||
|
||||
// ── Atomic store: swap the file row onto the ingested blob ──
|
||||
// `drive_id` scopes the path-based lookups in `update_file_streaming`
|
||||
// post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve
|
||||
// that to the caller's default drive (WOPI today is a single-drive
|
||||
// editing surface — no drive marker travels in the token).
|
||||
// `drive_id` scopes the path-based lookups in
|
||||
// `update_file_streaming_with_perms` post-D0.
|
||||
//
|
||||
// AuthZ audit #18 (2026-07-12): the pre-fix path resolved
|
||||
// `drive_id` via `find_default_for_user(claims_sub_uuid)` —
|
||||
// ALWAYS the caller's own default personal drive, regardless of
|
||||
// where the file actually lived. Shared-drive edits either
|
||||
// misrouted the write into the caller's personal drive (if the
|
||||
// filename happened to collide with a personal-drive path) or
|
||||
// 500'd on the parent-folder lookup. Resolve from the file's
|
||||
// own parent folder instead — one PK probe, returns the drive
|
||||
// the file genuinely belongs to. Also unlocks shared-drive WOPI
|
||||
// editing.
|
||||
let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||
};
|
||||
let Some(folder_id_str) = file.folder_id.as_deref() else {
|
||||
// Files always live under a folder (drive-root files use the
|
||||
// drive-root folder id). A `None` here means the file entity
|
||||
// is malformed — safest is a 500.
|
||||
tracing::error!(
|
||||
"WOPI PutFile: file {} has no parent folder id — cannot resolve drive",
|
||||
file_id
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
let folder_uuid = match uuid::Uuid::parse_str(folder_id_str) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"WOPI PutFile: file {} parent folder id '{}' is not a UUID",
|
||||
file_id,
|
||||
folder_id_str
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let drive_id = match state
|
||||
.app_state
|
||||
.drive_repo
|
||||
.find_default_for_user(claims_sub_uuid)
|
||||
.drive_id_for_folder(folder_uuid)
|
||||
.await
|
||||
{
|
||||
Ok(d) => d.drive.id,
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e);
|
||||
tracing::error!(
|
||||
"WOPI PutFile: drive-id lookup for folder {} failed: {:?}",
|
||||
folder_uuid,
|
||||
e
|
||||
);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,6 +52,11 @@ async fn get_openapi_spec() -> AxumJson<utoipa::openapi::OpenApi> {
|
||||
|
||||
use crate::interfaces::api::handlers::admin_handler;
|
||||
use crate::interfaces::api::handlers::batch_handler::{self, BatchHandlerState};
|
||||
// `chunked_upload_handler::*` are marked `#[deprecated]` (prefer
|
||||
// `/api/files/delta/*`); the router still needs to reference them
|
||||
// until clients migrate. See the `chunked_upload_router` block
|
||||
// below for the local `#[allow(deprecated)]`.
|
||||
#[allow(deprecated)]
|
||||
use crate::interfaces::api::handlers::chunked_upload_handler::{
|
||||
cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk,
|
||||
};
|
||||
@@ -70,7 +75,7 @@ use crate::interfaces::api::handlers::i18n_handler::{
|
||||
get_locales, get_translations_by_locale, translate,
|
||||
};
|
||||
use crate::interfaces::api::handlers::search_handler::{
|
||||
clear_search_cache, search_files_get, search_files_post, suggest_files,
|
||||
search_files_get, search_files_post, suggest_files,
|
||||
};
|
||||
use crate::interfaces::api::handlers::trash_handler;
|
||||
|
||||
@@ -275,8 +280,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.route("/suggest", get(suggest_files))
|
||||
// Advanced search with full criteria object
|
||||
.route("/advanced", post(search_files_post))
|
||||
// Clear search cache
|
||||
.route("/cache", delete(clear_search_cache))
|
||||
// `DELETE /api/search/cache` used to live here as a per-user-
|
||||
// reachable endpoint. It's an operator-only debug lever
|
||||
// (moka `invalidate_all()` — nukes every tenant), so it
|
||||
// moved to `/api/admin/search/cache` where the URL declares
|
||||
// intent. AuthZ audit #14 (2026-07-16).
|
||||
.with_state(app_state.clone())
|
||||
} else {
|
||||
Router::new()
|
||||
@@ -365,6 +373,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Create routes for chunked uploads (large files >10MB).
|
||||
// All five handlers are free functions — see chunked_upload_handler.rs for why
|
||||
// #[utoipa::path] cannot be applied to ChunkedUploadHandler impl methods directly.
|
||||
//
|
||||
// Each handler carries `#[deprecated]` so utoipa marks the OpenAPI paths
|
||||
// deprecated (Swagger UI shows the strikethrough + banner) and existing
|
||||
// callers get a compile-time nudge to migrate to `/api/files/delta/*`.
|
||||
// The route registration itself has to keep referencing them until the
|
||||
// clients migrate off, so we suppress the local `deprecated` lint here.
|
||||
#[allow(deprecated)]
|
||||
let chunked_upload_router = Router::new()
|
||||
.route("/", post(create_upload))
|
||||
.route("/{upload_id}", axum::routing::patch(upload_chunk))
|
||||
@@ -376,18 +391,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// Create routes for deduplication endpoints.
|
||||
// All handlers are free functions — see dedup_handler.rs for why
|
||||
// #[utoipa::path] cannot be applied to DedupHandler impl methods directly.
|
||||
use super::handlers::dedup_handler::{
|
||||
check_hash, check_hashes_batch, get_blob, get_stats, recalculate_stats,
|
||||
};
|
||||
use super::handlers::dedup_handler::{check_hash, check_hashes_batch, get_blob};
|
||||
let dedup_router = Router::new()
|
||||
.route("/check/{hash}", get(check_hash))
|
||||
.route("/check-batch", post(check_hashes_batch))
|
||||
.route("/stats", get(get_stats))
|
||||
.route("/blob/{hash}", get(get_blob))
|
||||
// NOTE: remove_reference is intentionally NOT exposed as a public
|
||||
// endpoint — ref_count management is an internal concern handled
|
||||
// automatically when files are deleted via the file API.
|
||||
.route("/recalculate", post(recalculate_stats))
|
||||
// NOTE: `remove_reference` is intentionally NOT exposed as a
|
||||
// public endpoint — ref_count management is an internal concern
|
||||
// handled automatically when files are deleted via the file API.
|
||||
//
|
||||
// `/stats` and `/recalculate` moved to `/api/admin/dedup/*`
|
||||
// (AuthZ audit #24/#25, 2026-07-17) so the middleware admin
|
||||
// gate covers them by construction. See
|
||||
// `admin_handler::admin_routes()`.
|
||||
.with_state(app_state.clone());
|
||||
|
||||
let mut router = Router::new()
|
||||
@@ -598,8 +614,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav)
|
||||
// in main.rs for protocol compliance, NOT under /api.
|
||||
|
||||
// Admin settings routes (protected by admin_guard inside the handler)
|
||||
let admin_router = admin_handler::admin_routes().with_state(app_state.clone());
|
||||
// Admin settings routes — the whole subtree is admin-only by
|
||||
// construction. The `require_admin` layer runs AFTER the outer
|
||||
// `auth_middleware` (main.rs::protected_api), so it can rely on
|
||||
// `CurrentUser` already being in the request extensions. Any new
|
||||
// route added to `admin_handler::admin_routes()` inherits the
|
||||
// gate automatically — implementors no longer have to remember
|
||||
// to call `require_admin(&state, &headers).await?` inline, and a
|
||||
// forgotten call can't silently expose a non-admin surface.
|
||||
let admin_router = admin_handler::admin_routes()
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::interfaces::middleware::auth::require_admin,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/admin", admin_router);
|
||||
|
||||
// ReBAC subject-group management. All mutating routes are admin-gated;
|
||||
|
||||
@@ -392,6 +392,13 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response {
|
||||
/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see
|
||||
/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured
|
||||
/// here within the flags-cache TTL.
|
||||
///
|
||||
/// Denial shapes distinguish authn from authz:
|
||||
/// - `CurrentUser` present, role != "admin" → 403 Forbidden.
|
||||
/// - `CurrentUser` absent → 401 Unauthorized. Should not happen in
|
||||
/// practice (auth_middleware guards against it), but the
|
||||
/// defensive fallback returns the honest shape: "we don't know
|
||||
/// who you are" is 401, not "we know you and refuse" (403).
|
||||
pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||
// Get the CurrentUser inserted by auth_middleware
|
||||
if let Some(current_user) = request.extensions().get::<Arc<CurrentUser>>() {
|
||||
@@ -407,18 +414,16 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||
role = %current_user.role,
|
||||
"👮🏻♂️ admin-only route denied for non-admin caller"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.admin_denied",
|
||||
reason = "unauthenticated",
|
||||
"👮🏻♂️ admin-only route reached with no authenticated user"
|
||||
);
|
||||
return AuthError::AccessDenied("Admin role required".to_string()).into_response();
|
||||
}
|
||||
|
||||
// Access denied
|
||||
let error = AuthError::AccessDenied("Admin role required".to_string());
|
||||
error.into_response()
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.admin_denied",
|
||||
reason = "unauthenticated",
|
||||
"👮🏻♂️ admin-only route reached with no authenticated user"
|
||||
);
|
||||
AuthError::TokenNotProvided.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -135,19 +135,40 @@ async fn user_provisioning_response(
|
||||
) -> Response {
|
||||
let statuscode = if ocs_version == 1 { 100 } else { 200 };
|
||||
|
||||
// Only allow users to view their own profile, unless they are admin.
|
||||
if user.username != userid && user.role != "admin" {
|
||||
return Json(ocs_err(403, "Insufficient privileges")).into_response();
|
||||
}
|
||||
|
||||
// AuthZ audit #11 (2026-07-12): the pre-fix path here rolled its
|
||||
// own gate ("caller is `userid`, else must be admin") and then
|
||||
// called bare `get_user_by_username` — bypassing every visibility
|
||||
// rule the id-keyed `/api/users/{id}` endpoint enforces. Cross-user
|
||||
// probes returned 403 (leaking existence via the differential vs a
|
||||
// genuine 404 for missing users); admins bypassed
|
||||
// `expose_system_users`; no audit line ever fired.
|
||||
//
|
||||
// Now routing through `get_user_profile_by_username_with_perms`,
|
||||
// which delegates to the same visibility engine as the REST
|
||||
// endpoint (self / shared-grant / expose_system_users / admin
|
||||
// paths, all audit-logged on denial). The OCS wire shape stays
|
||||
// `ocs_err(404, ...)` for every denied case — the NC client can't
|
||||
// tell "no such user" from "you can't see this user" from "you're
|
||||
// not admin" apart, which is the anti-enum invariant.
|
||||
let auth_service = match state.auth_service.as_ref() {
|
||||
Some(svc) => &svc.auth_application_service,
|
||||
None => {
|
||||
return Json(ocs_err(997, "Authentication not configured")).into_response();
|
||||
}
|
||||
};
|
||||
let Some(pool) = state.db_pool.as_ref() else {
|
||||
return Json(ocs_err(997, "Database pool not available")).into_response();
|
||||
};
|
||||
|
||||
let user_dto = match auth_service.get_user_by_username(&userid).await {
|
||||
let user_dto = match auth_service
|
||||
.get_user_profile_by_username_with_perms(
|
||||
user.id,
|
||||
&userid,
|
||||
state.core.config.features.expose_system_users,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return Json(ocs_err(404, "User not found")).into_response();
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
|
||||
use crate::application::ports::file_ports::FileUploadUseCase;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
@@ -402,8 +402,6 @@ async fn handle_assemble(
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?;
|
||||
|
||||
let upload_service = &state.applications.file_upload_service;
|
||||
let file_service = &state.applications.file_retrieval_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Path-based lookups below scope by `drive_id`. The NC session's
|
||||
// chroot is always populated for path-scoped handlers (see
|
||||
@@ -433,64 +431,41 @@ async fn handle_assemble(
|
||||
.await?;
|
||||
let content_type = ingested.content_type.clone();
|
||||
|
||||
// Check if file exists (update vs create).
|
||||
let existing = file_service
|
||||
.get_file_by_path(&internal_path, drive_id)
|
||||
.await;
|
||||
|
||||
let etag: Option<String> = if existing.is_ok() {
|
||||
let dto = upload_service
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
|
||||
|
||||
Some(dto.etag)
|
||||
} else {
|
||||
// New-file branch: resolve the parent folder by path and register
|
||||
// the file row against the already-ingested blob.
|
||||
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
|
||||
Some((p, n)) => (p, n),
|
||||
None => ("", dest_subpath.as_str()),
|
||||
};
|
||||
let parent_internal =
|
||||
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, parent_sub)?;
|
||||
let parent_internal = parent_internal.trim_end_matches('/');
|
||||
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
let parent_folder = match folder_service
|
||||
.get_folder_by_path(parent_internal, drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(folder) => folder,
|
||||
Err(e) => {
|
||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||
return Err(AppError::internal_error(format!(
|
||||
"Parent folder lookup failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let dto = upload_service
|
||||
.upload_file_streaming(
|
||||
filename.to_string(),
|
||||
Some(parent_folder.id),
|
||||
content_type.to_string(),
|
||||
ingested.stored(),
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
|
||||
|
||||
Some(dto.etag)
|
||||
// AuthZ audit #12 (2026-07-12): the previous shape branched on
|
||||
// file existence — `update_file_streaming_with_perms` on the
|
||||
// overwrite path (correct), plain `upload_file_streaming` on
|
||||
// the create path (NO `authz.require`). Viewer/Commenter on a
|
||||
// shared drive could MKCOL → PUT chunks → MOVE and land a
|
||||
// brand-new file, skipping the `Create`-on-parent-folder gate.
|
||||
//
|
||||
// `update_file_streaming_with_perms` handles both branches
|
||||
// atomically: `Update` on the existing file OR `Create` on the
|
||||
// parent folder / drive root (per the service's own internal
|
||||
// fork). Funneling everything through the one method also
|
||||
// deletes the duplicated parent-folder lookup that used to
|
||||
// live here.
|
||||
//
|
||||
// AuthZ audit #2 (2026-07-12): route DomainError through
|
||||
// `AppError::from` so authz denials keep the graduated 403/404
|
||||
// shape instead of collapsing into 500.
|
||||
let dto = match upload_service
|
||||
.update_file_streaming_with_perms(
|
||||
&internal_path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
oc_mtime,
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dto) => dto,
|
||||
Err(e) => {
|
||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||
return Err(AppError::from(e));
|
||||
}
|
||||
};
|
||||
let etag: Option<String> = Some(dto.etag);
|
||||
|
||||
// Cleanup session.
|
||||
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
|
||||
|
||||
Reference in New Issue
Block a user