Merge origin/main into webdav-litmus-compliance

This commit is contained in:
M.Schmidt
2026-07-12 22:25:12 +02:00
189 changed files with 11105 additions and 5655 deletions
+6 -2
View File
@@ -96,7 +96,9 @@ impl CalDavAdapter {
for attr in e.attributes().flatten() {
let attr_name =
std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
let attr_value = attr.unescape_value().unwrap_or_default();
let attr_value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default();
if attr_name == "start" {
// Parse ISO date format with Z for UTC
@@ -161,7 +163,9 @@ impl CalDavAdapter {
// Parse time-range attributes
for attr in e.attributes().flatten() {
let attr_name = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
let attr_value = attr.unescape_value().unwrap_or_default();
let attr_value = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default();
if attr_name == "start" {
// Parse ISO date format with Z for UTC
@@ -61,7 +61,10 @@ impl PluginLifecycleHook {
dispatch.dispatch(PluginEvent {
name: EVENT_FILE_UPLOADED,
user_id: dto.owner_id,
// Post-D7 the wire DTO no longer carries `owner_id`;
// §14 `created_by` provenance is the equivalent signal
// (who put the file in the system).
user_id: dto.created_by.map(|u| u.to_string()),
invocation_id: Uuid::new_v4().to_string(),
payload: serde_json::json!({
"path": dto.path,
+47 -3
View File
@@ -223,10 +223,42 @@ impl NextcloudPropContext {
}
}
/// Defense-in-depth cap on attributes per XML element in WebDAV request
/// bodies. Legitimate PROPFIND / PROPPATCH elements carry a handful of
/// `xmlns:*` declarations and, occasionally, per-property namespace
/// bindings — a dozen is already a lot. 100 is generous headroom and
/// three orders of magnitude below what an attacker would need to
/// exploit a quadratic parser bug (see quick-xml #969, fixed in 0.41;
/// this cap fences the same threat model for any future analogous bug
/// in whatever parser we swap to).
///
/// A rejected element yields 400 Bad Request via the ParseError path.
pub const MAX_ATTRIBUTES_PER_ELEMENT: usize = 100;
/// WebDAV adapter for converting between XML and domain objects
pub struct WebDavAdapter;
impl WebDavAdapter {
/// Refuse elements carrying an unreasonable attribute count.
/// See [`MAX_ATTRIBUTES_PER_ELEMENT`] for the reasoning.
///
/// `Attributes::count()` is O(N) in the number of attributes (each
/// attribute is parsed once), so this check itself is safe even
/// against very large elements. The parser may still have paid a
/// quadratic cost by the time we get here on a vulnerable version
/// of the underlying library — the bump to quick-xml 0.41 closes
/// that specific bug; this cap is defense-in-depth against future
/// analogous bugs and against adversarially large XML that would
/// otherwise reach our downstream code.
fn check_attribute_cap(e: &BytesStart) -> Result<()> {
if e.attributes().count() > MAX_ATTRIBUTES_PER_ELEMENT {
return Err(WebDavError::ParseError(format!(
"Element carries more than {MAX_ATTRIBUTES_PER_ELEMENT} attributes"
)));
}
Ok(())
}
/// Collect namespace prefix → URI mappings from element attributes.
/// E.g. `xmlns:D="DAV:"` maps prefix `"D"` to `"DAV:"`.
pub fn collect_ns_decls(
@@ -236,11 +268,17 @@ impl WebDavAdapter {
for attr in e.attributes().flatten() {
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
if let Some(prefix) = key.strip_prefix("xmlns:") {
let uri = attr.unescape_value().unwrap_or_default().to_string();
let uri = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
ns_map.insert(prefix.to_string(), uri);
} else if key == "xmlns" {
// Default namespace declaration: xmlns="uri"
let uri = attr.unescape_value().unwrap_or_default().to_string();
let uri = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default()
.to_string();
ns_map.insert(String::new(), uri);
}
}
@@ -252,7 +290,9 @@ impl WebDavAdapter {
for attr in e.attributes().flatten() {
let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
if key.starts_with("xmlns:") {
let uri = attr.unescape_value().unwrap_or_default();
let uri = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)
.unwrap_or_default();
if uri.is_empty() {
return Err(WebDavError::ParseError(
"Invalid namespace declaration: prefix bound to empty URI".to_string(),
@@ -305,6 +345,7 @@ impl WebDavAdapter {
loop {
match xml_reader.read_event_into(&mut buffer) {
Ok(Event::Start(ref e)) => {
Self::check_attribute_cap(e)?;
Self::collect_ns_decls(e, &mut ns_map);
Self::check_ns_decls_valid(e)?;
let name = e.name();
@@ -343,6 +384,7 @@ impl WebDavAdapter {
}
}
Ok(Event::Empty(ref e)) => {
Self::check_attribute_cap(e)?;
Self::collect_ns_decls(e, &mut ns_map);
Self::check_ns_decls_valid(e)?;
let name = e.name();
@@ -981,6 +1023,7 @@ impl WebDavAdapter {
loop {
match xml_reader.read_event_into(&mut buffer) {
Ok(Event::Start(ref e)) => {
Self::check_attribute_cap(e)?;
Self::collect_ns_decls(e, &mut ns_map);
let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
@@ -1063,6 +1106,7 @@ impl WebDavAdapter {
}
}
Ok(Event::Empty(ref e)) => {
Self::check_attribute_cap(e)?;
Self::collect_ns_decls(e, &mut ns_map);
let name = e.name();
let name_str = std::str::from_utf8(name.as_ref()).unwrap_or("");
-13
View File
@@ -61,16 +61,3 @@ pub struct UpdateAddressBookDto {
pub is_public: Option<bool>,
pub user_id: String, // Current user making the update
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShareAddressBookDto {
pub address_book_id: String,
pub user_id: String,
pub can_write: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnshareAddressBookDto {
pub address_book_id: String,
pub user_id: String,
}
-6
View File
@@ -55,11 +55,6 @@ pub struct FavoriteItemDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub item_path: Option<String>,
/// UUID of the file/folder's actual owner (may differ from `user_id` when
/// the item was shared and then favourited by another user).
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
// ── Pre-computed display fields ──
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
pub icon_class: String,
@@ -124,7 +119,6 @@ pub struct FavoriteResourceRow {
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive that owns this row. Surfaced on the favorites listing
/// so a UI can tell when a favorited item lives in a different
/// drive than the user's home (post-D6 cross-drive moves +
+3 -10
View File
@@ -54,10 +54,6 @@ pub struct FileDto {
/// Human-readable formatted size (e.g. "3.27 MB")
pub size_formatted: String,
/// Owner user ID (omitted from JSON when None)
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
/// Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).
/// Only populated by the /api/photos endpoint.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -102,7 +98,7 @@ impl From<File> for FileDto {
let content_hash = file.content_hash().to_string();
// Consume the entity by moving all fields — zero heap allocations
// for id, name, path, folder_id, owner_id (previously 5× .to_string()).
// for id, name, path, folder_id (previously 4× .to_string()).
let parts = file.into_parts();
let icon_class = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
@@ -124,7 +120,6 @@ impl From<File> for FileDto {
icon_special_class,
category,
size_formatted,
owner_id: parts.owner_id.map(|u| u.to_string()),
sort_date: None,
content_hash,
etag,
@@ -157,9 +152,8 @@ impl FileDto {
///
/// Used when a file is returned to a share recipient: `path` reveals the
/// full folder hierarchy above the file which the recipient may not have
/// access to. `folder_id` and `owner_id` are intentionally kept — the
/// former is needed for sub-folder navigation (covered by the cascade
/// grant), and the latter is harmless metadata.
/// access to. `folder_id` is intentionally kept — it's needed for
/// sub-folder navigation (covered by the cascade grant).
#[must_use]
pub fn without_hierarchy_info(self) -> Self {
Self {
@@ -183,7 +177,6 @@ impl FileDto {
icon_special_class: Arc::from(""),
category: Arc::from("Document"),
size_formatted: "0 Bytes".to_string(),
owner_id: None,
content_hash: String::new(),
etag: String::new(),
sort_date: None,
+2 -10
View File
@@ -48,10 +48,6 @@ pub struct FolderDto {
/// Parent folder ID
pub parent_id: Option<String>,
/// Owner user ID (scopes visibility per user)
#[serde(skip_serializing_if = "Option::is_none")]
pub owner_id: Option<String>,
/// Drive that owns this folder. The scope axis for path-based
/// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.
/// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub /
@@ -111,7 +107,6 @@ impl From<Folder> for FolderDto {
name: folder.name().to_string(),
path: folder.path_string().to_string(),
parent_id: folder.parent_id().map(String::from),
owner_id: folder.owner_id().map(|u| u.to_string()),
drive_id: folder.drive_id(),
created_at: folder.created_at(),
modified_at: folder.modified_at(),
@@ -147,9 +142,8 @@ impl FolderDto {
///
/// Used when a folder is returned to a share recipient: `path` reveals the
/// full folder hierarchy above the shared folder which the recipient may
/// not have access to. `parent_id` and `owner_id` are intentionally kept
/// — the former is needed for sub-folder navigation (covered by the
/// cascade grant), and the latter is harmless metadata.
/// not have access to. `parent_id` is intentionally kept — it's needed
/// for sub-folder navigation (covered by the cascade grant).
#[must_use]
pub fn without_hierarchy_info(self) -> Self {
Self {
@@ -165,7 +159,6 @@ impl FolderDto {
name: "stub-folder".to_string(),
path: "/stub/path".to_string(),
parent_id: None,
owner_id: None,
drive_id: Uuid::nil(),
created_at: 0,
modified_at: 0,
@@ -205,7 +198,6 @@ pub struct FolderResourceRow {
pub size: i64,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive that owns this row. Same column as
/// `storage.folders.drive_id` / `storage.files.drive_id`. Surfaced
/// on the listing so a UI can tell when a child lives in a
+10 -1
View File
@@ -55,11 +55,14 @@ impl From<Subject> for SubjectDto {
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
#[serde(rename_all = "snake_case")]
pub enum ResourceTypeDto {
Folder,
File,
Drive,
Calendar,
AddressBook,
Playlist,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -75,6 +78,9 @@ impl From<ResourceDto> for Resource {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
ResourceTypeDto::Drive => Resource::Drive(dto.id),
ResourceTypeDto::Calendar => Resource::Calendar(dto.id),
ResourceTypeDto::AddressBook => Resource::AddressBook(dto.id),
ResourceTypeDto::Playlist => Resource::Playlist(dto.id),
}
}
}
@@ -85,6 +91,9 @@ impl From<Resource> for ResourceDto {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
Resource::Calendar(id) => (ResourceTypeDto::Calendar, id),
Resource::AddressBook(id) => (ResourceTypeDto::AddressBook, id),
Resource::Playlist(id) => (ResourceTypeDto::Playlist, id),
};
ResourceDto { kind, id }
}
-1
View File
@@ -104,7 +104,6 @@ pub struct RecentResourceRow {
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive that owns this row. Surfaced on the recent listing
/// so a UI can tell when a recently-accessed item lives in a
/// different drive than the user's home (post-D6 cross-drive
-1
View File
@@ -60,7 +60,6 @@ pub struct TrashResourceRow {
pub size: i64,
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive the trashed item belongs to. Surfaced verbatim on the wire
/// (`TrashResourceItemDto.drive_id`) so the `/trash` UI can group by
/// drive without an extra lookup per row. D2b: filtering by drive is
@@ -62,6 +62,9 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
-48
View File
@@ -25,38 +25,11 @@ pub trait CalendarStoragePort: Send + Sync + 'static {
&self,
owner_id: Uuid,
) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_public_calendars(
&self,
limit: i64,
offset: i64,
) -> Result<Vec<CalendarDto>, DomainError>;
async fn check_calendar_access(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<bool, DomainError>;
// Calendar sharing
async fn share_calendar(
&self,
calendar_id: &str,
user_id: Uuid,
access_level: &str,
) -> Result<(), DomainError>;
async fn remove_calendar_sharing(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<(), DomainError>;
async fn get_calendar_shares(
&self,
calendar_id: &str,
) -> Result<Vec<(String, String)>, DomainError>;
// Calendar properties
async fn set_calendar_property(
&self,
@@ -146,33 +119,12 @@ pub trait CalendarUseCase: Send + Sync + 'static {
user_id: Uuid,
) -> Result<CalendarDto, DomainError>;
async fn list_my_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_shared_calendars(&self, user_id: Uuid) -> Result<Vec<CalendarDto>, DomainError>;
async fn list_public_calendars(
&self,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<Vec<CalendarDto>, DomainError>;
// Calendar sharing
async fn share_calendar(
&self,
calendar_id: &str,
target_user_id: Uuid,
access_level: &str,
caller_user_id: Uuid,
) -> Result<(), DomainError>;
async fn remove_calendar_sharing(
&self,
calendar_id: &str,
target_user_id: Uuid,
caller_user_id: Uuid,
) -> Result<(), DomainError>;
async fn get_calendar_shares(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<Vec<(String, String)>, DomainError>;
// Event operations
async fn create_event(
&self,
+91 -19
View File
@@ -1,16 +1,105 @@
use crate::application::dtos::address_book_dto::{
AddressBookDto, CreateAddressBookDto, ShareAddressBookDto, UnshareAddressBookDto,
UpdateAddressBookDto,
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
};
use crate::application::dtos::contact_dto::{
ContactDto, ContactGroupDto, CreateContactDto, CreateContactGroupDto, CreateContactVCardDto,
GroupMembershipDto, UpdateContactDto, UpdateContactGroupDto,
};
use crate::common::errors::DomainError;
use crate::domain::entities::contact::{AddressBook, Contact, ContactGroup};
use uuid::Uuid;
pub type CardDavRepositoryError = DomainError;
/// Low-level storage port for CardDAV resources. Post-Round-3 the
/// port covers ONLY raw storage operations — everything that used
/// to be routed through it for sharing (`share_address_book`,
/// `unshare_address_book`, `get_address_book_shares`) or
/// scope-listing (`get_address_books_by_owner`,
/// `get_shared_address_books`) is gone. Access decisions live in
/// `AuthorizationEngine`; sharing state lives in
/// `storage.role_grants`. The service layer (`ContactService`) gates
/// each call, then reaches through this port for storage.
///
/// Symmetric with `CalendarStoragePort`. Implemented by
/// `ContactStorageAdapter` against Postgres today; a future backend
/// (external CardDAV, LDAP directory, in-memory test mock) would
/// implement the same trait and swap in via DI.
pub trait ContactStoragePort: Send + Sync + 'static {
// ── Address books ────────────────────────────────────────────
async fn create_address_book(
&self,
address_book: AddressBook,
) -> Result<AddressBook, DomainError>;
async fn update_address_book(
&self,
address_book: AddressBook,
) -> Result<AddressBook, DomainError>;
async fn delete_address_book(&self, id: &Uuid) -> Result<(), DomainError>;
async fn get_address_book_by_id(&self, id: &Uuid) -> Result<Option<AddressBook>, DomainError>;
async fn get_public_address_books(&self) -> Result<Vec<AddressBook>, DomainError>;
// ── Contacts ─────────────────────────────────────────────────
async fn create_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
async fn update_contact(&self, contact: Contact) -> Result<Contact, DomainError>;
async fn delete_contact(&self, id: &Uuid) -> Result<(), DomainError>;
async fn get_contact_by_id(&self, id: &Uuid) -> Result<Option<Contact>, DomainError>;
/// Indexed single-row lookup by vCard UID within a specific book.
async fn get_contact_by_uid(
&self,
address_book_id: &Uuid,
uid: &str,
) -> Result<Option<Contact>, DomainError>;
/// Indexed batch lookup by vCard UID within a specific book.
async fn get_contacts_by_uids(
&self,
address_book_id: &Uuid,
uids: &[String],
) -> Result<Vec<Contact>, DomainError>;
async fn get_contacts_by_address_book(
&self,
address_book_id: &Uuid,
) -> Result<Vec<Contact>, DomainError>;
async fn get_contacts_by_address_book_paginated(
&self,
address_book_id: &Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<Contact>, DomainError>;
async fn search_contacts(
&self,
address_book_id: &Uuid,
query: &str,
) -> Result<Vec<Contact>, DomainError>;
// ── Contact groups ───────────────────────────────────────────
async fn create_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
async fn update_group(&self, group: ContactGroup) -> Result<ContactGroup, DomainError>;
async fn delete_group(&self, id: &Uuid) -> Result<(), DomainError>;
async fn get_group_by_id(&self, id: &Uuid) -> Result<Option<ContactGroup>, DomainError>;
async fn get_groups_by_address_book(
&self,
address_book_id: &Uuid,
) -> Result<Vec<ContactGroup>, DomainError>;
// ── Group membership ─────────────────────────────────────────
async fn add_contact_to_group(
&self,
group_id: &Uuid,
contact_id: &Uuid,
) -> Result<(), DomainError>;
async fn remove_contact_from_group(
&self,
group_id: &Uuid,
contact_id: &Uuid,
) -> Result<(), DomainError>;
async fn get_contacts_in_group(&self, group_id: &Uuid) -> Result<Vec<Contact>, DomainError>;
async fn get_groups_for_contact(
&self,
contact_id: &Uuid,
) -> Result<Vec<ContactGroup>, DomainError>;
}
pub trait AddressBookUseCase: Send + Sync + 'static {
// Address Book operations
async fn create_address_book(
@@ -37,23 +126,6 @@ pub trait AddressBookUseCase: Send + Sync + 'static {
user_id: Uuid,
) -> Result<Vec<AddressBookDto>, DomainError>;
async fn list_public_address_books(&self) -> Result<Vec<AddressBookDto>, DomainError>;
// Address Book sharing
async fn share_address_book(
&self,
dto: ShareAddressBookDto,
user_id: Uuid,
) -> Result<(), DomainError>;
async fn unshare_address_book(
&self,
dto: UnshareAddressBookDto,
user_id: Uuid,
) -> Result<(), DomainError>;
async fn get_address_book_shares(
&self,
address_book_id: &str,
user_id: Uuid,
) -> Result<Vec<(String, bool)>, DomainError>;
}
pub trait ContactUseCase: Send + Sync + 'static {
+14 -11
View File
@@ -75,7 +75,12 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
/// `updated_by` column reflects the principal that performed the
/// PUT — not the file's existing owner (D2 shared drives let
/// non-owners overwrite content).
async fn update_file_streaming(
/// `_with_perms` suffix (AGENTS.md AuthZ convention): the
/// implementation calls `authz.require(caller, Update, File(id))`
/// on the overwrite branch and `authz.require(caller, Create,
/// Folder|Drive(id))` on the new-file branch. Handlers just plumb
/// `caller_id` through — no protocol-layer authz.
async fn update_file_streaming_with_perms(
&self,
path: &str,
drive_id: Uuid,
@@ -241,23 +246,21 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
.collect())
}
/// Like [`list_files_batch`], but scoped to a specific owner.
/// Like [`list_files_batch`], but scoped to a specific caller.
///
/// Used by streaming WebDAV PROPFIND so that each user only sees their
/// own files, even in shared folder_id namespaces.
/// Used by streaming WebDAV PROPFIND. Post-D7 the concrete
/// implementation in `FileRetrievalService` uses drive-membership
/// grants; this default falls back to the unscoped listing (the
/// caller passes through `owner_id` for interface parity but the
/// stub can't apply a real filter without a repo lookup).
async fn list_files_batch_with_perms(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
_owner_id: Uuid,
offset: i64,
limit: i64,
) -> Result<Vec<FileDto>, DomainError> {
let all = self.list_files_batch(folder_id, offset, limit).await?;
let owner_str = owner_id.to_string();
Ok(all
.into_iter()
.filter(|f| f.owner_id.as_deref().is_some_and(|o| o == owner_str))
.collect())
self.list_files_batch(folder_id, offset, limit).await
}
}
+14 -65
View File
@@ -1,6 +1,5 @@
use bytes::Bytes;
use futures::Stream;
use serde_json::Value;
use std::path::PathBuf;
use std::pin::Pin;
use uuid::Uuid;
@@ -31,40 +30,9 @@ pub trait FileReadPort: Send + Sync + 'static {
async fn get_file_or_trashed(&self, id: &str) -> Result<File, DomainError>;
/// Gets a file by its ID, scoped to a specific owner.
///
/// Returns `NotFound` if the file does not exist **or** belongs to a
/// different user. This is the primary IDOR-safe accessor — handlers
/// serving end-user requests should always prefer this over `get_file`.
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError>;
/// Verifies that the file identified by `id` belongs to `owner_id`.
///
/// Returns `Ok(())` on success or `NotFound` when the file does not
/// exist or belongs to another user.
async fn verify_file_owner(&self, id: &str, owner_id: Uuid) -> Result<(), DomainError> {
self.get_file_for_owner(id, owner_id).await.map(|_| ())
}
/// Lists files in a folder.
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
/// Lists files in a folder scoped to a specific owner (SQL-level).
///
/// Default falls back to `list_files` + in-memory filter.
/// Repositories should override with a direct `AND user_id = $N` query.
async fn list_files_for_owner(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
) -> Result<Vec<File>, DomainError> {
let all = self.list_files(folder_id).await?;
Ok(all
.into_iter()
.filter(|f| f.owner_id() == Some(owner_id))
.collect())
}
/// Gets content as a stream (ideal for large files).
async fn get_file_stream(
&self,
@@ -155,25 +123,6 @@ pub trait FileReadPort: Send + Sync + 'static {
Ok(all.into_iter().skip(start).take(end - start).collect())
}
/// Like [`list_files_batch`], but only returns files owned by `owner_id`.
///
/// Used by streaming WebDAV PROPFIND to list files scoped to the
/// authenticated user, preventing cross-user data leakage.
async fn list_files_batch_for_owner(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
offset: i64,
limit: i64,
) -> Result<Vec<File>, DomainError> {
// Default: filter in-memory (repos should override with SQL)
let all = self.list_files_batch(folder_id, offset, limit).await?;
Ok(all
.into_iter()
.filter(|f| f.owner_id() == Some(owner_id))
.collect())
}
/// Streams every file in the subtree rooted at `folder_id`.
///
/// Uses an ltree `<@` join against `storage.folders` so the entire
@@ -195,7 +144,10 @@ pub trait FileReadPort: Send + Sync + 'static {
/// # Arguments
/// * `folder_id` - Optional folder ID to scope the search (for recursive search, pass None)
/// * `criteria` - Search criteria including name_contains, file_types, date ranges, size ranges
/// * `user_id` - User ID for ownership filtering
/// * `caller_id` - Caller user id — scoped by drive-membership grants
/// (`role_grants` on `resource_type='drive'`) rather than the legacy
/// `files.user_id` column. Group memberships (direct + transitive)
/// are expanded inline via `storage.caller_group_ids($caller)`.
///
/// # Returns
/// A tuple of (files, total_count) where files are paginated and filtered
@@ -203,36 +155,39 @@ pub trait FileReadPort: Send + Sync + 'static {
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError>;
/// Search files recursively in a folder subtree using ltree.
///
/// When `root_folder_id` is Some, uses ltree descendant queries to find
/// all files within the subtree rooted at that folder. When None, searches
/// all files for the user. This replaces the O(N) recursive spawn-per-folder
/// approach with O(1) SQL queries.
/// all files within the subtree rooted at that folder. When None,
/// delegates to `search_files_paginated`.
///
/// Post-PR-B: scoped by drive-membership grants (same semantics as
/// `search_files_paginated`), not by `files.user_id`.
///
/// Returns a tuple of (matching files, total count for pagination).
async fn search_files_in_subtree(
&self,
root_folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
// Default: delegate to paginated search (non-recursive fallback)
self.search_files_paginated(root_folder_id, criteria, user_id)
self.search_files_paginated(root_folder_id, criteria, caller_id)
.await
}
/// Count files matching the search criteria (without loading them).
///
/// Used for pagination metadata without fetching the actual files.
/// Same drive-membership scoping as `search_files_paginated`.
async fn count_files(
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<usize, DomainError>;
/// Return up to `limit` files whose name contains `query` (case-insensitive).
@@ -490,9 +445,3 @@ pub trait StorageUsagePort: Send + Sync + 'static {
additional_bytes: u64,
) -> Result<(), DomainError>;
}
/// Generic storage service interface for calendar and contact services
pub trait StorageUseCase: Send + Sync + 'static {
/// Handle a request with the specified action and parameters
async fn handle_request(&self, action: &str, params: Value) -> Result<Value, DomainError>;
}
+180 -199
View File
@@ -1,4 +1,5 @@
use chrono::{DateTime, Utc};
use std::collections::HashSet;
use std::sync::Arc;
use uuid::Uuid;
@@ -6,17 +7,80 @@ use crate::application::dtos::calendar_dto::{
CalendarDto, CalendarEventDto, CreateCalendarDto, CreateEventDto, CreateEventICalDto,
UpdateCalendarDto, UpdateEventDto,
};
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::calendar_ports::{CalendarStoragePort, CalendarUseCase};
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 +90,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 +122,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 +152,52 @@ 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. `get_calendar` misses on trashed / deleted
// calendars — those are dropped from the listing rather than
// erroring, so a lifecycle-race doesn't turn a PROPFIND into
// a 5xx.
let mut out = Vec::with_capacity(calendar_ids.len());
for id in calendar_ids {
if let Ok(dto) = self.calendar_storage.get_calendar(&id.to_string()).await {
out.push(dto);
}
}
Ok(out)
}
async fn list_public_calendars(
@@ -103,6 +205,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 +214,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,17 +229,8 @@ 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
}
@@ -223,33 +241,15 @@ 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)
self.require_calendar_perm(&event.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
.calendar_storage
.check_calendar_access(&event.calendar_id, user_id)
self.require_calendar_perm(&event.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 +259,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 +280,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 +299,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 +322,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);
@@ -365,17 +350,13 @@ 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)
File diff suppressed because it is too large Load Diff
@@ -249,6 +249,20 @@ impl DriveManagementService {
.set_role(caller_id, subject, role, resource, expires_at)
.await?;
// 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;
// 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
@@ -312,6 +326,15 @@ impl DriveManagementService {
self.authz.clear_role(subject, resource).await?;
// 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;
// D6 §11: canonical `drive.member_removed` audit event covers
// every successful removal (owner-driven or admin bypass).
// `via_admin` replaces the separate
@@ -448,7 +471,7 @@ impl DriveManagementService {
&self,
caller_id: Uuid,
drive_id: Uuid,
partial: crate::domain::entities::drive::DrivePolicies,
partial: serde_json::Value,
) -> Result<crate::domain::entities::drive::DrivePolicies, DomainError> {
let merged = self
.drive_repo
@@ -474,6 +497,8 @@ impl DriveManagementService {
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,
"📜 drive policies updated",
);
Ok(merged)
+35 -22
View File
@@ -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,17 @@ 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`.
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();
@@ -43,6 +43,13 @@ pub struct FileManagementService {
/// 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 {
@@ -67,6 +74,7 @@ impl FileManagementService {
file_lifecycle_hook: None,
resource_access_hook: None,
drive_repo: None,
storage_usage: None,
}
}
@@ -100,6 +108,18 @@ impl FileManagementService {
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(
@@ -338,12 +358,42 @@ impl FileManagementUseCase for FileManagementService {
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
@@ -375,6 +425,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
}
@@ -453,6 +528,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
}
@@ -343,19 +343,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(
@@ -470,20 +468,21 @@ impl FileRetrievalUseCase for FileRetrievalService {
offset: i64,
limit: i64,
) -> 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?;
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, offset, limit)
.await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
+98 -12
View File
@@ -42,6 +42,16 @@ pub struct FileUploadService {
/// `(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.
@@ -66,6 +76,7 @@ impl FileUploadService {
content_cache: None,
file_lifecycle_hook: None,
resource_access_hook: None,
authorization: None,
instant_upload: None,
}
}
@@ -82,18 +93,34 @@ impl FileUploadService {
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,
@@ -296,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}")))?;
@@ -317,19 +343,22 @@ 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) {
let Some(storage_service) = &self.storage_usage_service else {
return;
};
let delta = file.size as i64;
let owner = file
.owner_id
.as_deref()
.and_then(|s| Uuid::parse_str(s).ok());
let owner = Some(caller_id);
let folder = file
.folder_id
.as_deref()
@@ -410,7 +439,7 @@ 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);
}
@@ -422,7 +451,16 @@ impl FileUploadUseCase for FileUploadService {
/// 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.
async fn update_file_streaming_with_perms(
&self,
path: &str,
drive_id: Uuid,
@@ -431,10 +469,33 @@ impl FileUploadUseCase for FileUploadService {
modified_at: Option<i64>,
caller_id: Uuid,
) -> 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 file_id = file.id().to_string();
let (new_hash, updated_at) = self
.file_write
@@ -464,7 +525,6 @@ impl FileUploadUseCase for FileUploadService {
parts.folder_id,
parts.created_at,
updated_at as u64,
parts.owner_id,
new_hash,
)
.map_err(|e| {
@@ -504,6 +564,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
+69 -26
View File
@@ -31,6 +31,11 @@ pub struct FolderService {
/// 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 {
@@ -45,6 +50,7 @@ impl FolderService {
authz,
file_lifecycle,
drive_repo: None,
storage_usage: None,
}
}
@@ -60,6 +66,19 @@ impl FolderService {
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,
@@ -358,18 +377,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())
@@ -431,24 +450,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());
@@ -594,6 +612,19 @@ impl FolderUseCase for FolderService {
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));
}
}
@@ -610,6 +641,17 @@ 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
@@ -1082,17 +1124,18 @@ mod cascade_hook_integration_tests {
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, user_id, drive_id, folder_id, blob_hash, size, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
(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(user_id)
.bind(drive_id)
.bind(folder_id)
.bind(&blob_hash)
@@ -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
-2
View File
@@ -39,8 +39,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
+234 -212
View File
@@ -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,17 +171,38 @@ 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.
let mut playlists: Vec<PlaylistDto> = Vec::with_capacity(playlist_ids.len());
let user_str = user_id.to_string();
for id in playlist_ids.drain() {
if let Ok(Some(p)) = self.storage.get_playlist(&id.to_string()).await
&& (include_shared || p.owner_id == user_str)
{
playlists.push(p);
}
}
@@ -141,26 +224,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 +243,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 +258,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 +279,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 +303,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 +338,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 +357,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 +384,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
}
}
+8 -5
View File
@@ -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,
+69 -12
View File
@@ -1,10 +1,12 @@
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::application::ports::resource_access_hook::ResourceAccessHook;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::services::authorization::ResourceKind;
use crate::common::errors::{DomainError, Result};
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
use crate::infrastructure::repositories::pg::RecentItemsPgRepository;
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use std::sync::{Arc, OnceLock};
use tracing::info;
use uuid::Uuid;
@@ -16,6 +18,13 @@ 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;
@@ -28,10 +37,15 @@ pub struct RecentService {
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(),
}
}
@@ -53,6 +67,41 @@ impl RecentService {
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'",
));
}
self.repo.upsert_access(user_id, item_id, item_type).await?;
self.repo.prune(user_id, self.max_recent_items).await?;
Ok(())
}
}
impl RecentItemsUseCase for RecentService {
@@ -87,16 +136,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 {}",
@@ -472,11 +472,14 @@ 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
+9 -15
View File
@@ -283,20 +283,10 @@ 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
{
// 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.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
@@ -338,7 +328,11 @@ impl SearchService {
}
};
match authz
.check(caller, Permission::Read, Resource::File(file_uuid))
.check(
Subject::User(user_id),
Permission::Read,
Resource::File(file_uuid),
)
.await
{
Ok(true) => verified.push(hit),
+27 -15
View File
@@ -6,7 +6,7 @@ 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;
@@ -243,6 +243,28 @@ 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
@@ -928,14 +950,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 {
@@ -983,10 +997,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!()
}
@@ -1002,10 +1015,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,
@@ -213,6 +213,47 @@ impl StorageUsageService {
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
+2 -16
View File
@@ -786,13 +786,9 @@ impl TrashService {
/// 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 (subject_types, subject_ids) = self
.authz
.expand_subject_for_listing(Subject::User(user_id))
.await?;
let drives = self
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.list_readable_by(user_id)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -900,15 +896,7 @@ 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
{
let drive_ids: Vec<Uuid> = match self.drive_repo.list_readable_by(user_id).await {
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
return Err(DomainError::internal_error(
@@ -974,7 +962,6 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
name: row.name.clone(),
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.
@@ -1025,7 +1012,6 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
icon_special_class: std::sync::Arc::from(icon_special_class_for(&row.name, mime)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
+4 -15
View File
@@ -554,15 +554,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 {
@@ -745,10 +736,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![])
}
@@ -763,10 +753,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,
+16 -10
View File
@@ -526,10 +526,12 @@ async fn build_subtree(
// Level 0 — the subtree's "root" sits inside `mount_under`, not at
// parent_id=NULL. drive_id is inherited from the mount point.
// Post-D7: `user_id` omitted; `created_by` / `updated_by` bind to
// the seed caller.
let root: (Uuid,) = sqlx::query_as(
"INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
SELECT $1, parent.id, $2, parent.drive_id, $2, $2
(name, parent_id, drive_id, created_by, updated_by)
SELECT $1, parent.id, parent.drive_id, $2, $2
FROM storage.folders parent
WHERE parent.id = $3::uuid
RETURNING id",
@@ -568,13 +570,13 @@ async fn build_subtree(
);
// drive_id derives from the parent folder — same pattern as
// file_blob_write_repository's resolve_owner_and_drive helper.
// Every parent in `current_level` already has a drive_id set,
// so the JOIN is guaranteed to find one.
// file_blob_write_repository's resolve_parent_drive helper.
// Post-D7: `user_id` omitted; provenance via `created_by` /
// `updated_by`.
let rows: Vec<(Uuid,)> = sqlx::query_as(
"INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
SELECT f.name, f.parent_id, $1, parent.drive_id, $1, $1
(name, parent_id, drive_id, created_by, updated_by)
SELECT f.name, f.parent_id, parent.drive_id, $1, $1
FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name)
JOIN storage.folders parent ON parent.id = f.parent_id
RETURNING id",
@@ -653,13 +655,17 @@ async fn insert_files(
// Post-D0: storage.files.drive_id is NOT NULL — derive it from the
// parent folder (same pattern as file_blob_write_repository's
// INSERTs and the resolve_owner_and_drive helper). The folder's
// INSERTs and the resolve_parent_drive helper). The folder's
// drive_id was set during the M2 backfill or by the lifecycle hook
// for users provisioned after D0.
//
// Post-D7: `user_id` omitted; `created_by` / `updated_by` bind to
// the seed caller so provenance is preserved.
sqlx::query(
"INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size, mime_type)
SELECT f.name, f.folder_id, $1, fo.drive_id, $2, 0, 'text/plain'
(name, folder_id, drive_id, blob_hash, size, mime_type,
created_by, updated_by)
SELECT f.name, f.folder_id, fo.drive_id, $2, 0, 'text/plain', $1, $1
FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)
JOIN storage.folders fo ON fo.id = f.folder_id",
)
+29
View File
@@ -915,6 +915,22 @@ pub struct FeaturesConfig {
/// deployments don't want them reachable. Env:
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`.
pub enable_admin_internal_endpoints: bool,
/// Native WebDAV path segment that lists the caller's drives.
///
/// * Default `"@drive"` — bare `/webdav/` addresses the caller's
/// default personal drive (back-compat). Drive listing lives at
/// `/webdav/@drive/`; explicit drive at
/// `/webdav/@drive/<uuid|name>/…`.
/// * `""` (empty) — no default-drive shortcut. Bare `/webdav/`
/// returns the drive listing; explicit drive at
/// `/webdav/<uuid|name>/…`. Operators who don't want a "default
/// drive" concept exposed via WebDAV pick this.
/// * Any other string (e.g. `"drives"`) — same shape as the default,
/// just with that path segment. Loaded via `trim_matches('/')`
/// so operators can safely pass `"/drives/"`.
///
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
pub webdav_drive_listing_prefix: String,
}
impl Default for FeaturesConfig {
@@ -934,6 +950,10 @@ impl Default for FeaturesConfig {
// deployments do NOT need this; the periodic ticker handles
// reconciliation transparently.
enable_admin_internal_endpoints: false,
// Back-compat with pre-multi-drive clients — bare `/webdav/`
// maps to the caller's default drive; drive listing is
// reachable at `/webdav/@drive/`.
webdav_drive_listing_prefix: "@drive".to_string(),
}
}
}
@@ -1505,6 +1525,15 @@ impl AppConfig {
config.features.enable_admin_internal_endpoints = val;
}
// Native WebDAV drive-picker path segment. Sanitised by
// stripping leading/trailing slashes so operators can pass
// `/drives/` or `drives` interchangeably; empty string means
// "no default-drive shortcut, `/webdav/` IS the drive listing".
// See `FeaturesConfig::webdav_drive_listing_prefix`.
if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") {
config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string();
}
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
&& let Ok(val) = enable_faces
{
+80 -44
View File
@@ -50,13 +50,13 @@ use crate::application::ports::video_frame_ports::VideoFramePort;
use crate::application::services::app_password_service::AppPasswordService;
use crate::application::services::blob_lifecycle_service::BlobLifecycleService;
use crate::application::services::calendar_service::CalendarService;
use crate::application::services::contact_service::ContactService;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::application::services::file_lifecycle_service::FileLifecycleService;
use crate::application::services::music_service::MusicService;
use crate::application::services::storage_usage_service::StorageUsageService;
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::infrastructure::repositories::AppPasswordPgRepository;
use crate::infrastructure::repositories::DeviceCodePgRepository;
use crate::infrastructure::repositories::pg::{
@@ -536,7 +536,12 @@ impl AppServiceFactory {
// drive repo every other policy uses. Wired here so
// `move_folder_with_perms` can enforce
// `forbid_cross_drive_move` without a separate construction path.
.with_drive_repo(drive_repo.clone()),
.with_drive_repo(drive_repo.clone())
// Destination-drive quota pre-check on cross-drive folder
// MOVE. Reuses the `check_drive_quota` the upload path
// already runs. Without this, a Move that would push the
// destination past its cap succeeds silently.
.with_storage_usage(storage_usage.clone()),
);
// Built before the upload/management services so the plugin lifecycle
@@ -618,7 +623,10 @@ impl AppServiceFactory {
// drive repo every other policy uses. Wired here so
// `move_file_with_perms` can enforce `forbid_cross_drive_move`
// without a separate construction path.
.with_drive_repo(drive_repo.clone());
.with_drive_repo(drive_repo.clone())
// Destination-drive quota pre-check on cross-drive file
// MOVE. Same rationale as the folder side above.
.with_storage_usage(storage_usage.clone());
if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook);
}
@@ -889,30 +897,49 @@ impl AppServiceFactory {
Some(service)
}
/// Creates the favorites service (requires database)
pub fn create_favorites_service(&self, db_pool: &Arc<PgPool>) -> Arc<FavoritesService> {
/// Creates the favorites service (requires database + authz engine
/// for the Read gate on `add_to_favorites` — see the post-Drive
/// AuthZ audit).
pub fn create_favorites_service(
&self,
db_pool: &Arc<PgPool>,
authorization: &Arc<PgAclEngine>,
) -> Arc<FavoritesService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
);
let service = Arc::new(FavoritesService::new(repo));
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
tracing::info!("Favorites service initialized");
service
}
/// Creates the recent items service (requires database)
pub fn create_recent_service(&self, db_pool: &Arc<PgPool>) -> Arc<RecentService> {
/// Creates the recent items service (requires database + authz
/// engine for the Read gate on `record_item_access` — see the
/// post-Drive AuthZ audit).
pub fn create_recent_service(
&self,
db_pool: &Arc<PgPool>,
authorization: &Arc<PgAclEngine>,
) -> Arc<RecentService> {
let repo = Arc::new(
crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()),
);
let service = Arc::new(RecentService::new(
repo, 50, // Maximum recent items per user
repo,
authorization.clone(),
50, // Maximum recent items per user
));
tracing::info!("Recent items service initialized");
service
}
/// Creates the Places (photo map) service. Reuses the existing file-read
/// repository — the data is the caller's own geotagged photos.
/// repository — the data is the caller's Photos-scope geotagged photos
/// (§15: default personal drive + drives with
/// `include_in_photo_index = true` AND caller has Read).
/// Group-membership expansion is inline in the SQL via
/// `storage.caller_group_ids`, so the service needs no AuthZ engine
/// handle.
pub fn create_places_service(
&self,
file_read: &Arc<FileBlobReadRepository>,
@@ -1156,31 +1183,6 @@ impl AppServiceFactory {
let pool = Arc::new(pools.primary);
let maintenance_pool = Arc::new(pools.maintenance);
// Recent service + recording hook are built up-front so the
// hook can be threaded into `create_application_services` below.
// The file services hold the hook directly so every authorised
// `_with_perms` read/write fires into `auth.user_recent_files`
// without per-handler wiring. Reordering vs the legacy in-block
// creation (further down) is safe: `create_recent_service` only
// needs `pool`, which is already in scope.
//
// The back-edge `recent_service_eager.set_resource_access_hook`
// closes the loop so the clear/remove handlers can drop the
// hook's in-memory throttle entries — without it a freshly
// cleared Recent list refuses to re-record the same file for a
// full TTL window, surfacing as "I cleared, opened the file,
// and Recent is still empty" (caught by tests/api/recent.hurl
// step 8).
let recent_service_eager = self.create_recent_service(&pool);
let resource_access_hook: Arc<
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
> = Arc::new(
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
recent_service_eager.clone(),
),
);
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
// 1. Core services (PgPool needed for DedupService index)
let core = self.create_core_services(&pool, &maintenance_pool).await?;
@@ -1191,6 +1193,10 @@ impl AppServiceFactory {
// because services hold an Arc<PgAclEngine> for ReBAC checks.
// SubjectGroupPgRepository is constructed here too so the engine can
// expand a user's transitive group set on cache misses.
//
// Moved above the eager recent-service build so `create_recent_service`
// can receive an `Arc<PgAclEngine>` — the Read gate on
// `record_item_access` (post-Drive AuthZ audit fix) needs it.
let subject_group_repo = Arc::new(
crate::infrastructure::repositories::pg::SubjectGroupPgRepository::new(pool.clone()),
);
@@ -1201,6 +1207,29 @@ impl AppServiceFactory {
subject_group_repo.clone(),
);
// Recent service + recording hook are built up-front so the
// hook can be threaded into `create_application_services` below.
// The file services hold the hook directly so every authorised
// `_with_perms` read/write fires into `auth.user_recent_files`
// without per-handler wiring.
//
// The back-edge `recent_service_eager.set_resource_access_hook`
// closes the loop so the clear/remove handlers can drop the
// hook's in-memory throttle entries — without it a freshly
// cleared Recent list refuses to re-record the same file for a
// full TTL window, surfacing as "I cleared, opened the file,
// and Recent is still empty" (caught by tests/api/recent.hurl
// step 8).
let recent_service_eager = self.create_recent_service(&pool, &authorization);
let resource_access_hook: Arc<
dyn crate::application::ports::resource_access_hook::ResourceAccessHook,
> = Arc::new(
crate::infrastructure::services::recent_recording_hook::RecentRecordingHook::new(
recent_service_eager.clone(),
),
);
recent_service_eager.set_resource_access_hook(resource_access_hook.clone());
// Drive repository — needed both by the lifecycle hook (when auth
// is enabled) and by `GET /api/drives` on the final `AppState`,
// so declared at the outer scope.
@@ -1274,7 +1303,7 @@ impl AppServiceFactory {
> = None;
{
let favs = self.create_favorites_service(&pool);
let favs = self.create_favorites_service(&pool, &authorization);
favorites_service = Some(favs.clone());
apps.favorites_service = Some(favs);
@@ -1529,7 +1558,6 @@ impl AppServiceFactory {
people_service,
storage_usage_service,
calendar_service: None,
contact_service: None,
calendar_use_case: None,
addressbook_use_case: None,
contact_use_case: None,
@@ -1800,6 +1828,7 @@ impl AppServiceFactory {
let calendar_service = Arc::new(
crate::application::services::calendar_service::CalendarService::new(
calendar_storage,
authorization.clone(),
),
);
app_state.calendar_use_case = Some(calendar_service as Arc<CalendarService>);
@@ -1816,15 +1845,23 @@ impl AppServiceFactory {
pool.clone(),
),
);
// Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter.
// * ContactStorageAdapter → pure ContactStoragePort impl
// (raw PG storage, no ACL, no sharing).
// * ContactService → gates every call through the
// AuthorizationEngine, then delegates through the port.
// Owns both AddressBookUseCase + ContactUseCase impls.
let contact_storage = Arc::new(
crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new(
address_book_repo,
contact_repo,
group_repo,
)
),
);
app_state.addressbook_use_case = Some(contact_storage.clone());
app_state.contact_use_case = Some(contact_storage);
let contact_service =
Arc::new(ContactService::new(contact_storage, authorization.clone()));
app_state.addressbook_use_case = Some(contact_service.clone());
app_state.contact_use_case = Some(contact_service);
tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories");
}
@@ -1844,7 +1881,7 @@ impl AppServiceFactory {
audio_metadata_repo,
),
);
let music_svc = Arc::new(MusicService::new(music_storage));
let music_svc = Arc::new(MusicService::new(music_storage, authorization.clone()));
app_state.music_service = Some(music_svc);
tracing::info!("Music service initialized");
}
@@ -1993,10 +2030,9 @@ pub struct AppState {
pub people_service: Option<Arc<PeopleService>>,
pub storage_usage_service: Option<Arc<StorageUsageService>>,
pub calendar_service: Option<Arc<CalendarService>>,
pub contact_service: Option<Arc<ContactStorageAdapter>>,
pub calendar_use_case: Option<Arc<CalendarService>>,
pub addressbook_use_case: Option<Arc<ContactStorageAdapter>>,
pub contact_use_case: Option<Arc<ContactStorageAdapter>>,
pub addressbook_use_case: Option<Arc<ContactService>>,
pub contact_use_case: Option<Arc<ContactService>>,
pub music_service: Option<Arc<MusicService>>,
pub wopi_token_service:
Option<Arc<crate::application::services::wopi_token_service::WopiTokenService>>,
+5 -11
View File
@@ -145,10 +145,6 @@ impl FileReadPort for StubFileReadPort {
) -> Result<Pin<Box<dyn Stream<Item = Result<File, DomainError>> + Send>>, DomainError> {
Ok(Box::pin(futures::stream::empty()))
}
async fn get_file_for_owner(&self, _id: &str, _owner_id: Uuid) -> Result<File, DomainError> {
Ok(File::default())
}
}
// ---------------------------------------------------------------------------
@@ -274,10 +270,9 @@ impl FolderRepository for StubFolderStoragePort {
Ok(Vec::new())
}
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<Folder>, DomainError> {
Ok(Vec::new())
}
@@ -292,10 +287,9 @@ impl FolderRepository for StubFolderStoragePort {
Ok((Vec::new(), 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,
@@ -506,7 +500,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
Ok(FileDto::default())
}
async fn update_file_streaming(
async fn update_file_streaming_with_perms(
&self,
_path: &str,
_drive_id: Uuid,
+20
View File
@@ -181,6 +181,26 @@ pub struct DrivePolicies {
/// writes) and `::remove_member` (refuses Owner removals) when the
/// caller is non-admin.
pub forbid_owner_role_change: bool,
/// Opts this drive into the `/api/photos` timeline (§15). Non-default
/// drives are omitted by default so a random shared folder full of
/// screenshots doesn't bleed into the personal timeline; owners flip
/// this on when the drive genuinely is a photo library (e.g. "Family
/// Photos"). Default personal drives get `true` on creation via the
/// `PersonalDriveLifecycleHook` + a one-shot backfill for existing
/// rows, so the SQL predicate is a single positive rule with no
/// per-kind carve-out. Read at `file_blob_read_repository::
/// list_media_files` + `list_geo_clusters`. See §15 for the query
/// shape and rationale.
pub include_in_photo_index: bool,
/// Same shape as `include_in_photo_index`, applied to the Music
/// library surface (playlists today; a `/api/music/tracks` library
/// view later). Symmetric opt-in — Music was originally cross-drive
/// via a `forbid_music_index` opt-out, but that mixed-form naming
/// created "one include-in, one forbid" confusion and the
/// "shared audio is always intentional" claim didn't hold under
/// scrutiny (voicemail MP3s in a work drive shouldn't bleed into
/// the personal library). See §15.
pub include_in_music_index: bool,
}
impl DrivePolicies {
-22
View File
@@ -22,7 +22,6 @@ pub struct FileParts {
pub folder_id: Option<String>,
pub created_at: u64,
pub modified_at: u64,
pub owner_id: Option<Uuid>,
/// BLAKE3 content hash. See [`File::content_hash`] for semantics.
pub blob_hash: String,
/// §14 provenance: original creator. See [`File::created_by`].
@@ -70,9 +69,6 @@ pub struct File {
/// Last modification timestamp (seconds since UNIX epoch)
modified_at: u64,
/// Owner user ID (from storage.files.user_id)
owner_id: Option<Uuid>,
/// BLAKE3 content hash. Stable across renames/moves, changes only
/// when the file's content bytes change. Source of truth for both
/// content-addressable storage and the HTTP ETag (via
@@ -109,7 +105,6 @@ impl Default for File {
folder_id: None,
created_at: 0,
modified_at: 0,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -150,7 +145,6 @@ impl File {
folder_id,
created_at: now,
modified_at: now,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -184,7 +178,6 @@ impl File {
folder_id: parent_id,
created_at,
modified_at,
owner_id: None,
blob_hash: String::new(),
created_by: None,
updated_by: None,
@@ -201,7 +194,6 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
) -> FileResult<Self> {
Self::with_timestamps_and_blob_hash(
id,
@@ -212,7 +204,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id,
String::new(),
)
}
@@ -227,7 +218,6 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
) -> FileResult<Self> {
Self::with_timestamps_blob_hash_and_provenance(
@@ -239,7 +229,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id,
blob_hash,
None,
None,
@@ -259,7 +248,6 @@ impl File {
folder_id: Option<String>,
created_at: u64,
modified_at: u64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
@@ -282,7 +270,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id,
blob_hash,
created_by,
updated_by,
@@ -304,7 +291,6 @@ impl File {
folder_id: self.folder_id,
created_at: self.created_at,
modified_at: self.modified_at,
owner_id: self.owner_id,
blob_hash: self.blob_hash,
created_by: self.created_by,
updated_by: self.updated_by,
@@ -407,10 +393,6 @@ impl File {
self.modified_at
}
pub fn owner_id(&self) -> Option<Uuid> {
self.owner_id
}
/// User that originally created this file (§14 provenance).
/// `None` when the referenced user has been deleted
/// (FK is `ON DELETE SET NULL`) or for stub/DTO entities.
@@ -455,7 +437,6 @@ impl File {
folder_id,
created_at,
modified_at,
owner_id: None,
blob_hash: String::new(),
// DTO round-trips don't carry provenance; callers needing
// it must reload from the repository.
@@ -607,7 +588,6 @@ mod tests {
None,
1_000,
2_000,
None,
"abcdef0123456789ZZZZZZZZ".to_string(),
)
.unwrap();
@@ -632,7 +612,6 @@ mod tests {
None,
1_000,
2_000,
None,
"shorthash".to_string(),
)
.unwrap();
@@ -655,7 +634,6 @@ mod tests {
None,
1_000,
2_000,
None,
"stable-content-hash".to_string(),
)
.unwrap();
+7 -70
View File
@@ -25,10 +25,6 @@ pub struct Folder {
/// Parent folder ID (None if it's a root folder)
parent_id: Option<String>,
/// Owner user ID — scopes folder visibility per user.
/// `None` only for legacy/stub folders; real folders always have an owner.
owner_id: Option<Uuid>,
/// Drive that owns this folder. Post-D0 every `storage.folders` row
/// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope
/// by this axis (not by `user_id`, which is dropped in D7).
@@ -76,7 +72,6 @@ impl Default for Folder {
storage_path: StoragePath::from_string("/"),
path_string: "/".to_string(),
parent_id: None,
owner_id: None,
drive_id: Uuid::nil(),
created_at: 0,
modified_at: 0,
@@ -88,26 +83,20 @@ impl Default for Folder {
}
impl Folder {
/// Creates a new folder with validation
/// Creates a new folder with validation.
///
/// In-memory constructor: callers that don't supply a `drive_id`
/// are by definition stub/legacy paths (tests, pre-D0 fixtures,
/// DTO round-trips). Real DB-backed folders flow through
/// [`Folder::with_timestamps_and_tree`] which propagates the
/// drive scope and §14 provenance from the row.
pub fn new(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
) -> FolderResult<Self> {
Self::new_with_owner(id, name, storage_path, parent_id, None)
}
/// Creates a new folder with validation and an explicit owner.
pub fn new_with_owner(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
) -> FolderResult<Self> {
let name = normalize_storage_name(&name);
// Validate folder name
if let Err(reason) = validate_storage_name(&name) {
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
}
@@ -117,7 +106,6 @@ impl Folder {
.unwrap_or_default()
.as_secs();
// Store the path string for serialization compatibility
let path_string = storage_path.to_string();
Ok(Self {
@@ -126,17 +114,10 @@ impl Folder {
storage_path,
path_string,
parent_id,
owner_id,
// In-memory constructor: callers that don't supply a
// drive_id are by definition stub/legacy paths (tests,
// pre-D0 fixtures, DTO round-trips). Real DB-backed
// folders flow through `with_timestamps_and_tree`.
drive_id: Uuid::nil(),
created_at: now,
modified_at: now,
tree_modified_at: now,
// Provenance is unknown for in-memory construction; the DB
// reconstruction path supplies real values.
created_by: None,
updated_by: None,
})
@@ -160,34 +141,6 @@ impl Folder {
name,
storage_path,
parent_id,
None,
Uuid::nil(),
created_at,
modified_at,
modified_at,
)
}
/// Creates a folder with specific timestamps and owner (legacy
/// constructor — `tree_modified_at` defaults to `modified_at`).
/// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction
/// so the rollup ETag reflects descendant activity, not just this
/// row's own metadata.
pub fn with_timestamps_and_owner(
id: String,
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
created_at: u64,
modified_at: u64,
) -> FolderResult<Self> {
Self::with_timestamps_and_tree(
id,
name,
storage_path,
parent_id,
owner_id,
Uuid::nil(),
created_at,
modified_at,
@@ -209,7 +162,6 @@ impl Folder {
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
@@ -220,7 +172,6 @@ impl Folder {
name,
storage_path,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
@@ -239,7 +190,6 @@ impl Folder {
name: String,
storage_path: StoragePath,
parent_id: Option<String>,
owner_id: Option<Uuid>,
drive_id: Uuid,
created_at: u64,
modified_at: u64,
@@ -260,7 +210,6 @@ impl Folder {
storage_path,
path_string,
parent_id,
owner_id,
drive_id,
created_at,
modified_at,
@@ -299,10 +248,6 @@ impl Folder {
self.modified_at
}
pub fn owner_id(&self) -> Option<Uuid> {
self.owner_id
}
/// Drive that owns this folder. Path-based lookups scope by
/// this axis (post-D0 invariant: `storage.folders.drive_id`
/// is `NOT NULL`).
@@ -412,7 +357,6 @@ impl Folder {
storage_path,
path_string: path,
parent_id,
owner_id: None,
// DTO round-trips lose drive_id (FolderDto carries it,
// but the legacy `from_dto` signature predates this
// change). Callers that need real scoping must reload
@@ -460,7 +404,6 @@ impl Folder {
storage_path: new_storage_path,
path_string: new_path_string,
parent_id: self.parent_id.clone(),
owner_id: self.owner_id,
drive_id: self.drive_id,
created_at: self.created_at,
modified_at: now,
@@ -501,7 +444,6 @@ impl Folder {
storage_path: new_storage_path,
path_string: new_path_string,
parent_id,
owner_id: self.owner_id,
drive_id: self.drive_id,
created_at: self.created_at,
modified_at: now,
@@ -593,7 +535,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
@@ -615,7 +556,6 @@ mod tests {
"a".to_string(),
StoragePath::from_string("/a"),
None,
None,
Uuid::nil(),
0,
0,
@@ -627,7 +567,6 @@ mod tests {
"b".to_string(),
StoragePath::from_string("/b"),
None,
None,
Uuid::nil(),
0,
0,
@@ -650,7 +589,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
@@ -662,7 +600,6 @@ mod tests {
"folder".to_string(),
StoragePath::from_string("/folder"),
None,
None,
Uuid::nil(),
1_000,
2_000,
@@ -6,6 +6,14 @@ use crate::domain::entities::contact::AddressBook;
pub type AddressBookRepositoryResult<T> = Result<T, DomainError>;
/// Repository interface for AddressBook entity operations.
///
/// Post-Round-3, access-control state lives in `storage.role_grants`.
/// The pre-Round-3 methods that read/wrote `carddav.address_book_shares`
/// (`get_shared_address_books`, `share_address_book`,
/// `unshare_address_book`, `get_address_book_shares`) have been removed
/// from this trait, and the backing table was dropped in
/// `20260906000002_drop_legacy_share_tables.sql`.
pub trait AddressBookRepository: Send + Sync + 'static {
async fn create_address_book(
&self,
@@ -20,28 +28,13 @@ pub trait AddressBookRepository: Send + Sync + 'static {
&self,
id: &Uuid,
) -> AddressBookRepositoryResult<Option<AddressBook>>;
/// Direct owner enumeration — same semantics as the calendar
/// counterpart. The service layer prefers
/// `authz.list_incoming_grants`, but internal maintenance paths
/// keep the owner-only lookup available.
async fn get_address_books_by_owner(
&self,
owner_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn get_shared_address_books(
&self,
user_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>>;
async fn share_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
can_write: bool,
) -> AddressBookRepositoryResult<()>;
async fn unshare_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
) -> AddressBookRepositoryResult<()>;
async fn get_address_book_shares(
&self,
address_book_id: &Uuid,
) -> AddressBookRepositoryResult<Vec<(String, bool)>>;
}
+13 -36
View File
@@ -4,7 +4,14 @@ use uuid::Uuid;
pub type CalendarRepositoryResult<T> = Result<T, DomainError>;
/// Repository interface for Calendar entity operations
/// Repository interface for Calendar entity operations.
///
/// Post-Round-3, access-control state lives in `storage.role_grants` —
/// the pre-Round-3 methods that read/wrote `caldav.calendar_shares`
/// (`list_calendars_shared_with_user`, `user_has_calendar_access`,
/// `share_calendar`, `remove_calendar_sharing`, `get_calendar_shares`)
/// have been removed from this trait, and the backing table was dropped
/// in `20260906000002_drop_legacy_share_tables.sql`.
pub trait CalendarRepository: Send + Sync + 'static {
/// Creates a new calendar
async fn create_calendar(&self, calendar: Calendar) -> CalendarRepositoryResult<Calendar>;
@@ -18,7 +25,11 @@ pub trait CalendarRepository: Send + Sync + 'static {
/// Finds a calendar by its ID
async fn find_calendar_by_id(&self, id: &Uuid) -> CalendarRepositoryResult<Calendar>;
/// Lists all calendars for a specific user
/// Lists all calendars owned by a specific user. Post-Round-3 the
/// service layer prefers `authz.list_incoming_grants` (surfaces
/// owned + shared in one union), but this direct lookup remains
/// available for internal maintenance / migration paths that need
/// owner-only enumeration without going through the engine.
async fn list_calendars_by_owner(
&self,
owner_id: Uuid,
@@ -31,12 +42,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
owner_id: Uuid,
) -> CalendarRepositoryResult<Calendar>;
/// Lists calendars shared with a specific user
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> CalendarRepositoryResult<Vec<Calendar>>;
/// List public calendars
async fn list_public_calendars(
&self,
@@ -44,13 +49,6 @@ pub trait CalendarRepository: Send + Sync + 'static {
offset: i64,
) -> CalendarRepositoryResult<Vec<Calendar>>;
/// Checks if a user has access to a calendar
async fn user_has_calendar_access(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<bool>;
/// Gets a custom property for a calendar
async fn get_calendar_property(
&self,
@@ -78,25 +76,4 @@ pub trait CalendarRepository: Send + Sync + 'static {
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<std::collections::HashMap<String, String>>;
/// Share calendar with another user
async fn share_calendar(
&self,
calendar_id: &Uuid,
user_id: Uuid,
access_level: &str,
) -> CalendarRepositoryResult<()>;
/// Remove calendar sharing for a user
async fn remove_calendar_sharing(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<()>;
/// Get calendar sharing information (who has access to this calendar)
async fn get_calendar_shares(
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<Vec<(String, String)>>;
}
+17 -9
View File
@@ -52,7 +52,7 @@ pub struct DriveWithRootName {
/// of the root folder via JOIN at read time.
pub root_folder_name: String,
/// Highest role the calling user holds on this drive (direct OR
/// group-mediated). Populated by `list_for_subjects` (which already
/// group-mediated). Populated by `list_readable_by` (which already
/// JOINs `role_grants` for accessibility, so the role is in scope at
/// query time). `None` for repo methods called without a caller
/// context (`get_by_id`, `get_by_ids`, `find_default_for_user`,
@@ -164,17 +164,17 @@ pub trait DriveRepository: Send + Sync + 'static {
}
/// List drives the caller can read, resolved via `role_grants` for
/// `resource_type='drive'`. The caller's group memberships are
/// expanded by the engine's `subject_match_set`; that expanded set
/// is what this method's `subject_ids` argument carries.
/// `resource_type='drive'`. Group memberships (direct + transitive)
/// are expanded inline by the `storage.caller_group_ids(caller)`
/// SQL function — callers pass only the caller's uuid, no
/// expansion ceremony.
///
/// Returns rows in a stable order: default drive first (if any),
/// then by display name. The `/api/drives` handler relies on that
/// order for the picker UI without a follow-up sort.
async fn list_for_subjects(
async fn list_readable_by(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
caller_id: Uuid,
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError>;
/// `true` when the drive holds no live (non-trashed) folders other
@@ -193,7 +193,7 @@ pub trait DriveRepository: Send + Sync + 'static {
/// List every drive on the system, regardless of caller membership.
///
/// Used by the admin panel's `GET /api/admin/drives`. Distinct from
/// `list_for_subjects` (which filters by `role_grants`) because an
/// `list_readable_by` (which filters by `role_grants`) because an
/// admin who creates a shared drive for someone else has no grant
/// on it — but still needs to see, audit, and manage it. The HTTP
/// gate (admin-only middleware) is what makes the unrestricted
@@ -256,10 +256,18 @@ pub trait DriveRepository: Send + Sync + 'static {
///
/// Caller is responsible for the `Manage` permission check; this
/// method does not re-verify.
///
/// `partial` is a raw JSON object carrying **only** the keys the
/// caller wants to change — the repo passes it verbatim to the
/// `policies || $partial` JSONB merge. Using the typed
/// `DrivePolicies` here would serialise every field (including
/// unset ones as `false`) and clobber other flags on the row;
/// keeping the merge on the raw `Value` preserves the
/// partial-update semantic the handler documents.
async fn update_policies(
&self,
drive_id: Uuid,
partial: &crate::domain::entities::drive::DrivePolicies,
partial: &serde_json::Value,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError>;
}
+44 -23
View File
@@ -13,6 +13,17 @@ use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use uuid::Uuid;
// NOTE on `caller_role` for the two listing methods below:
// We deliberately do NOT compute or return the caller's role per row.
// The frontend already fetches `/api/drives` (which surfaces
// `caller_role` per drive) and cross-references by `folder.drive_id` —
// see `MoveDialog.svelte` and the config/drive page. Adding
// `caller_role` to `FolderDto` would either (a) mean redundant
// server-side work for a client-side concern the client already
// handles, or (b) drag folder-level grant cascades into the query
// which is real cost for a rare edge case. Punted; see
// `project_caller_role_on_file_folder_dto` memory.
/// Domain port for folder persistence.
///
/// Defines the CRUD and management operations required for
@@ -51,13 +62,20 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
/// Lists root-level folders owned by a specific user.
/// For non-root queries (parent_id is Some), ownership is implicit
/// because the parent already belongs to the user.
async fn list_folders_by_owner(
/// Lists root-level folders the caller can read — scoped through
/// drive-membership grants (`role_grants` on `resource_type='drive'`)
/// rather than the legacy `folders.user_id` column. Group memberships
/// are expanded inline by `storage.caller_group_ids($caller)` in the
/// SQL. Closes [[bug-root-folder-listing-legacy-user-id]] — root
/// folders admin created for other users but has no role on no
/// longer surface in the admin's `GET /api/folders`.
///
/// Non-root queries (parent_id != None) go through `list_folders`
/// with the parent already permission-checked at the service layer,
/// so this method carries no `parent_id` parameter.
async fn list_root_folders_for_caller(
&self,
parent_id: Option<&str>,
owner_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError>;
/// Lists folders with pagination
@@ -69,13 +87,12 @@ pub trait FolderRepository: Send + Sync + 'static {
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Lists folders with pagination, scoped to a specific owner.
/// Combines the owner filtering of `list_folders_by_owner` with
/// the pagination of `list_folders_paginated`.
async fn list_folders_by_owner_paginated(
/// Paginated companion to `list_root_folders_for_caller` — same
/// drive-scoped predicate, adds LIMIT/OFFSET + optional
/// window-function COUNT.
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,
@@ -162,31 +179,35 @@ pub trait FolderRepository: Send + Sync + 'static {
Ok(Vec::new())
}
/// Lists all descendant folders in a subtree (ltree-based).
/// Lists all descendant folders in a subtree (ltree-based), scoped
/// to drives the caller can read.
///
/// Returns all folders whose lpath is a descendant of the given folder's
/// lpath. Used for recursive search — O(1) SQL via GiST index instead
/// of O(N) recursive traversal.
/// Returns all folders whose lpath is a descendant of the given
/// folder's lpath. Used for recursive search — O(1) SQL via GiST
/// index instead of O(N) recursive traversal. Drive-membership
/// filtering (including group cascade via `caller_group_ids`) is
/// applied inline in the SQL.
///
/// The default implementation returns an empty vec (stubs / mocks).
async fn list_descendant_folders(
&self,
folder_id: &str,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> {
let _ = (folder_id, name_contains, user_id);
let _ = (folder_id, name_contains, caller_id);
Ok(Vec::new())
}
/// Search folders with SQL-level filtering by name, user, and scope.
/// Search folders with SQL-level filtering by name and scope,
/// restricted to drives the caller can read.
///
/// - **Non-recursive** (`recursive = false`): searches direct children of
/// `parent_id` (or root folders when `None`).
/// - **Recursive with `parent_id`**: delegates to `list_descendant_folders`
/// (ltree GiST-indexed scan).
/// - **Recursive without `parent_id`**: searches ALL folders owned by
/// `user_id` with optional name filter in SQL.
/// - **Recursive without `parent_id`**: searches ALL folders in drives
/// the caller can read, with optional name filter in SQL.
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
@@ -194,13 +215,13 @@ pub trait FolderRepository: Send + Sync + 'static {
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder_id → use optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, user_id)
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
// Fallback: load + filter in memory (stubs / mocks)
+1 -1
View File
@@ -27,7 +27,7 @@ pub trait TrashRepository: Send + Sync {
///
/// **Caller contract**: pass only drive UUIDs the caller has
/// `Permission::Delete` on (resolved by the service via
/// `DriveRepository::list_for_subjects` + role-bundle filter). This
/// `DriveRepository::list_readable_by` + role-bundle filter). This
/// repository performs no authorization — see
/// `TrashService::empty_trash` for the canonical call site.
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()>;
+65 -18
View File
@@ -78,12 +78,24 @@ pub enum Resource {
/// membership and policy bag. Added in D0; membership lives in
/// `storage.role_grants` (no separate `drive_members` table).
Drive(Uuid),
// Reserved for future use:
// Calendar(Uuid),
// Reserved for future use:
// AddressBook(Uuid),
// Reserved for future use:
// Playlist(Uuid),
/// A CalDAV calendar. Membership + sharing lives in
/// `storage.role_grants` with `resource_type='calendar'` —
/// replaces the pre-Round-3 dedicated `caldav.calendar_shares`
/// table and the `check_calendar_access` bespoke helper. No
/// cascade parent (calendars are top-level per user); the engine
/// resolves directly against `role_grants` on the resource.
Calendar(Uuid),
/// A CardDAV address book. Same shape as `Calendar` —
/// `storage.role_grants` with `resource_type='address_book'`
/// replaces `carddav.address_book_shares` and the
/// `check_address_book_access` bespoke helper.
AddressBook(Uuid),
/// A music playlist. Same shape as `Calendar`/`AddressBook` —
/// `storage.role_grants` with `resource_type='playlist'` replaces
/// the pre-Round-3 dedicated `music.playlist_shares` table and the
/// bespoke `user_has_access` / `user_can_write` helpers on
/// `MusicStorageAdapter`.
Playlist(Uuid),
}
impl Resource {
@@ -92,18 +104,20 @@ impl Resource {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
Resource::Drive(_) => "drive",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
Resource::Calendar(_) => "calendar",
Resource::AddressBook(_) => "address_book",
Resource::Playlist(_) => "playlist",
}
}
pub fn id(&self) -> Uuid {
match self {
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
Resource::Folder(id)
| Resource::File(id)
| Resource::Drive(id)
| Resource::Calendar(id)
| Resource::AddressBook(id)
| Resource::Playlist(id) => *id,
}
}
@@ -112,12 +126,40 @@ impl Resource {
"folder" => Some(Resource::Folder(id)),
"file" => Some(Resource::File(id)),
"drive" => Some(Resource::Drive(id)),
//"calendar" => Some(Resource::Calendar(id)),
//"adressbook" => Some(Resource::AddressBook(id)),
//"playlist" => Some(Resource::Playlist(id)),
"calendar" => Some(Resource::Calendar(id)),
"address_book" => Some(Resource::AddressBook(id)),
"playlist" => Some(Resource::Playlist(id)),
_ => None,
}
}
/// Parse `(item_type, item_id)` from an API-facing pair of strings
/// (favorites, recent, batch endpoints all take this shape).
/// Combines UUID parse + type mapping so callers stay one-line and
/// error shapes are identical across surfaces. Returns
/// `DomainError::new(InvalidInput, …)` on malformed input; callers
/// that need the anti-enum 404 shape do that separately by feeding
/// the parsed `Resource` into `authz.require(...)`.
pub fn parse(
item_type: &str,
item_id: &str,
) -> Result<Self, crate::common::errors::DomainError> {
use crate::common::errors::{DomainError, ErrorKind};
let uuid = Uuid::parse_str(item_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Resource",
format!("Invalid item UUID '{item_id}'"),
)
})?;
Self::from_parts(item_type, uuid).ok_or_else(|| {
DomainError::new(
ErrorKind::InvalidInput,
"Resource",
format!("Unsupported item type '{item_type}'"),
)
})
}
}
impl fmt::Display for Resource {
@@ -508,11 +550,16 @@ mod tests {
#[test]
fn resource_roundtrip() {
let id = Uuid::new_v4();
for r in [Resource::Folder(id), Resource::File(id)] {
for r in [
Resource::Folder(id),
Resource::File(id),
Resource::Calendar(id),
Resource::AddressBook(id),
Resource::Playlist(id),
] {
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
assert_eq!(r, back);
}
assert!(Resource::from_parts("calendar", id).is_none());
}
#[test]
@@ -126,17 +126,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
Ok(calendars.into_iter().map(CalendarDto::from).collect())
}
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> Result<Vec<CalendarDto>, DomainError> {
let calendars = self
.calendar_repository
.list_calendars_shared_with_user(user_id)
.await?;
Ok(calendars.into_iter().map(CalendarDto::from).collect())
}
async fn list_public_calendars(
&self,
limit: i64,
@@ -149,78 +138,6 @@ impl CalendarStoragePort for CalendarStorageAdapter {
Ok(calendars.into_iter().map(CalendarDto::from).collect())
}
async fn check_calendar_access(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<bool, DomainError> {
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
)
})?;
self.calendar_repository
.user_has_calendar_access(&uuid, user_id)
.await
}
// Calendar sharing
async fn share_calendar(
&self,
calendar_id: &str,
user_id: Uuid,
access_level: &str,
) -> Result<(), DomainError> {
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
)
})?;
self.calendar_repository
.share_calendar(&uuid, user_id, access_level)
.await
}
async fn remove_calendar_sharing(
&self,
calendar_id: &str,
user_id: Uuid,
) -> Result<(), DomainError> {
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
)
})?;
self.calendar_repository
.remove_calendar_sharing(&uuid, user_id)
.await
}
async fn get_calendar_shares(
&self,
calendar_id: &str,
) -> Result<Vec<(String, String)>, DomainError> {
let uuid = Uuid::parse_str(calendar_id).map_err(|_| {
DomainError::new(
ErrorKind::InvalidInput,
"Calendar",
"Invalid calendar ID format",
)
})?;
self.calendar_repository.get_calendar_shares(&uuid).await
}
// Calendar properties
async fn set_calendar_property(
File diff suppressed because it is too large Load Diff
@@ -184,44 +184,6 @@ impl AddressBookRepository for AddressBookPgRepository {
Ok(result)
}
async fn get_shared_address_books(
&self,
user_id: Uuid,
) -> AddressBookRepositoryResult<Vec<AddressBook>> {
let rows = sqlx::query(
r#"
SELECT a.id, a.name, a.owner_id, a.description, a.color, a.is_public, a.created_at, a.updated_at
FROM carddav.address_books a
INNER JOIN carddav.address_book_shares s ON a.id = s.address_book_id
WHERE s.user_id = $1
ORDER BY a.name
"#
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to get shared address books: {}", e)))?;
let result = rows
.into_iter()
.map(|row| {
let owner_id: Uuid = row.get("owner_id");
AddressBook::from_raw(
row.get("id"),
row.get("name"),
owner_id.to_string(),
row.get("description"),
row.get("color"),
row.get("is_public"),
row.get("created_at"),
row.get("updated_at"),
)
})
.collect();
Ok(result)
}
async fn get_public_address_books(&self) -> AddressBookRepositoryResult<Vec<AddressBook>> {
let rows = sqlx::query(
r#"
@@ -256,79 +218,4 @@ impl AddressBookRepository for AddressBookPgRepository {
Ok(result)
}
async fn share_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
can_write: bool,
) -> AddressBookRepositoryResult<()> {
sqlx::query(
r#"
INSERT INTO carddav.address_book_shares (address_book_id, user_id, can_write)
VALUES ($1, $2, $3)
ON CONFLICT (address_book_id, user_id) DO UPDATE SET can_write = $3
"#,
)
.bind(address_book_id)
.bind(user_id)
.bind(can_write)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to share address book: {}", e)))?;
Ok(())
}
async fn unshare_address_book(
&self,
address_book_id: &Uuid,
user_id: Uuid,
) -> AddressBookRepositoryResult<()> {
sqlx::query(
r#"
DELETE FROM carddav.address_book_shares
WHERE address_book_id = $1 AND user_id = $2
"#,
)
.bind(address_book_id)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to unshare address book: {}", e))
})?;
Ok(())
}
async fn get_address_book_shares(
&self,
address_book_id: &Uuid,
) -> AddressBookRepositoryResult<Vec<(String, bool)>> {
let rows = sqlx::query(
r#"
SELECT user_id, can_write
FROM carddav.address_book_shares
WHERE address_book_id = $1
ORDER BY user_id
"#,
)
.bind(address_book_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get address book shares: {}", e))
})?;
let result = rows
.into_iter()
.map(|row| {
let user_id: Uuid = row.get("user_id");
(user_id.to_string(), row.get("can_write"))
})
.collect();
Ok(result)
}
}
@@ -216,44 +216,6 @@ impl CalendarRepository for CalendarPgRepository {
Ok(calendar)
}
async fn list_calendars_shared_with_user(
&self,
user_id: Uuid,
) -> CalendarRepositoryResult<Vec<Calendar>> {
let rows = sqlx::query(
r#"
SELECT c.id, c.name, c.owner_id, c.description, c.color, c.is_public, c.created_at, c.updated_at
FROM caldav.calendars c
INNER JOIN caldav.calendar_shares s ON c.id = s.calendar_id
WHERE s.user_id = $1
ORDER BY c.name
"#
)
.bind(user_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to get shared calendars: {}", e)))?;
let mut calendars = Vec::new();
for row in rows {
let calendar = Calendar::with_id(
row.get("id"),
row.get("name"),
row.get("owner_id"),
row.get("description"),
row.get("color"),
row.get("created_at"),
row.get("updated_at"),
)
.map_err(|e| {
DomainError::database_error(format!("Failed to create calendar object: {}", e))
})?;
calendars.push(calendar);
}
Ok(calendars)
}
async fn list_public_calendars(
&self,
limit: i64,
@@ -296,112 +258,6 @@ impl CalendarRepository for CalendarPgRepository {
Ok(calendars)
}
async fn user_has_calendar_access(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<bool> {
// Check if the user is the owner of the calendar or has a share
let row = sqlx::query(
r#"
SELECT EXISTS (
SELECT 1 FROM caldav.calendars c
WHERE c.id = $1 AND (c.owner_id = $2 OR c.is_public = true)
UNION
SELECT 1 FROM caldav.calendar_shares s
WHERE s.calendar_id = $1 AND s.user_id = $2
) as has_access
"#,
)
.bind(calendar_id)
.bind(user_id)
.fetch_one(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to check calendar access: {}", e))
})?;
Ok(row.get::<bool, _>("has_access"))
}
async fn share_calendar(
&self,
calendar_id: &Uuid,
user_id: Uuid,
access_level: &str,
) -> CalendarRepositoryResult<()> {
// Validate access level
if !["read", "write", "owner"].contains(&access_level) {
return Err(DomainError::validation_error(format!(
"Invalid access level: '{}'. Must be 'read', 'write', or 'owner'",
access_level
)));
}
sqlx::query(
r#"
INSERT INTO caldav.calendar_shares (calendar_id, user_id, access_level)
VALUES ($1, $2, $3)
ON CONFLICT (calendar_id, user_id) DO UPDATE SET access_level = $3
"#,
)
.bind(calendar_id)
.bind(user_id)
.bind(access_level)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to share calendar: {}", e)))?;
Ok(())
}
async fn remove_calendar_sharing(
&self,
calendar_id: &Uuid,
user_id: Uuid,
) -> CalendarRepositoryResult<()> {
sqlx::query(
r#"
DELETE FROM caldav.calendar_shares
WHERE calendar_id = $1 AND user_id = $2
"#,
)
.bind(calendar_id)
.bind(user_id)
.execute(&*self.pool)
.await
.map_err(|e| DomainError::database_error(format!("Failed to unshare calendar: {}", e)))?;
Ok(())
}
async fn get_calendar_shares(
&self,
calendar_id: &Uuid,
) -> CalendarRepositoryResult<Vec<(String, String)>> {
let rows = sqlx::query(
r#"
SELECT user_id, access_level
FROM caldav.calendar_shares
WHERE calendar_id = $1
ORDER BY user_id
"#,
)
.bind(calendar_id)
.fetch_all(&*self.pool)
.await
.map_err(|e| {
DomainError::database_error(format!("Failed to get calendar shares: {}", e))
})?;
let mut shares = Vec::new();
for row in rows {
shares.push((row.get("user_id"), row.get("access_level")));
}
Ok(shares)
}
async fn get_calendar_property(
&self,
calendar_id: &Uuid,
@@ -3,7 +3,7 @@
//! The repo deals only with the `storage.drives` table itself. Drive
//! membership lives in `storage.role_grants` (`resource_type='drive'`)
//! and is queried through the engine's existing grant paths;
//! `list_for_subjects` below resolves `role_grants` → `storage.drives`
//! `list_readable_by` below resolves `role_grants` → `storage.drives`
//! via a single join.
//!
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
@@ -75,7 +75,7 @@ impl DrivePgRepository {
/// is declared owner→viewer (strongest→weakest), so `MIN` picks the
/// strongest of the caller's grants on the drive (direct +
/// group-mediated collapsed by GROUP BY). Used only by
/// `list_for_subjects`.
/// `list_readable_by`.
fn row_to_drive_with_name_and_role(
row: &sqlx::postgres::PgRow,
) -> Result<DriveWithRootName, DriveRepositoryError> {
@@ -116,11 +116,22 @@ impl DriveRepository for DrivePgRepository {
.map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?;
// 1. Drive row (root_folder_id NULL — populated in step 3).
//
// Default personal drives are seeded with `include_in_photo_index`
// + `include_in_music_index` = true so the Photos / Music
// predicates (§15) can be a single positive rule keyed off the
// JSONB flag — no per-kind carve-out needed at query time. Any
// future admin PATCH toggling either flag off shows a confirm
// dialog in the UI (unusual action; empties the user's Photos
// timeline / Music library).
let drive_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.drives
(kind, default_for_user, quota_bytes, policies)
VALUES ('personal', $1, $2, '{}'::jsonb)
VALUES (
'personal', $1, $2,
'{"include_in_photo_index": true, "include_in_music_index": true}'::jsonb
)
RETURNING id
"#,
)
@@ -132,11 +143,16 @@ impl DriveRepository for DrivePgRepository {
// 2. Root folder. `parent_id IS NULL` makes it a root in the
// drive; `drive_id` closes the FK in this direction.
//
// Post-D7: `user_id` omitted from the INSERT column list —
// the column is nullable and no longer written to on new
// rows. `created_by` / `updated_by` bind to the owner
// (§14 provenance).
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $1, $2, $1, $1)
(name, parent_id, drive_id, created_by, updated_by)
VALUES ('Personal', NULL, $2, $1, $1)
RETURNING id
"#,
)
@@ -233,14 +249,14 @@ impl DriveRepository for DrivePgRepository {
.await
.map_err(|e| Self::map_sqlx_err("create_shared_drive_atomic.drive", e))?;
// 2. Root folder. The folder's `user_id` carries the admin (legacy
// column still NOT NULL during the dual-write window — D7
// drops it once `drive_id` is the canonical ownership signal).
// 2. Root folder. Post-D7: `user_id` omitted — the column is
// nullable and unused on new rows. `created_by` / `updated_by`
// bind to `granted_by` (§14 provenance).
let folder_id: Uuid = sqlx::query_scalar(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $2, $3, $2, $2)
(name, parent_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $3, $2, $2)
RETURNING id
"#,
)
@@ -452,13 +468,15 @@ impl DriveRepository for DrivePgRepository {
Self::row_to_drive_with_name(&row)
}
async fn list_for_subjects(
async fn list_readable_by(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
caller_id: Uuid,
) -> Result<Vec<DriveWithRootName>, DriveRepositoryError> {
// Joining role_grants → drives → folders returns every drive the
// expanded subject set can read, paired with its display name.
// caller can read, paired with its display name. Group
// memberships (direct + transitive) are expanded inline by
// `storage.caller_group_ids($caller)` — no Rust-side ceremony.
//
// ORDER BY puts default drives first (so the picker UI doesn't
// need a follow-up sort), then alphabetical by name. GROUP BY
// collapses duplicate role_grants on the same drive (direct +
@@ -470,8 +488,6 @@ impl DriveRepository for DrivePgRepository {
// weakest), so MIN returns the strongest. Cast `::text` matches
// the codebase convention for reading enum columns into Rust
// (see `pg_acl_engine.rs`); `Role::parse` handles the trip back.
// Collapses direct + group-mediated grants on the same drive
// into one row alongside the existing GROUP BY.
let rows = sqlx::query(
r#"
SELECT d.id, d.kind, d.default_for_user, d.root_folder_id,
@@ -484,8 +500,11 @@ impl DriveRepository for DrivePgRepository {
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
WHERE (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
GROUP BY d.id, d.kind, d.default_for_user, d.root_folder_id,
d.quota_bytes, d.used_bytes, d.policies,
@@ -494,16 +513,10 @@ impl DriveRepository for DrivePgRepository {
LOWER(f.name) ASC
"#,
)
.bind(
subject_types
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
)
.bind(subject_ids)
.bind(caller_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
.map_err(|e| Self::map_sqlx_err("list_readable_by", e))?;
rows.iter()
.map(Self::row_to_drive_with_name_and_role)
@@ -636,16 +649,17 @@ impl DriveRepository for DrivePgRepository {
async fn update_policies(
&self,
drive_id: Uuid,
partial: &crate::domain::entities::drive::DrivePolicies,
partial: &serde_json::Value,
) -> Result<crate::domain::entities::drive::DrivePolicies, DriveRepositoryError> {
// JSONB-level merge (`||`) keeps unknown keys already on disk —
// the column remains the canonical bag (see
// `DrivePolicies::from_value` — typed read is lenient, untyped
// write is preserving). RETURNING surfaces the post-merge bag so
// the audit log shows what the row actually carries afterwards.
let partial_json = serde_json::to_value(partial).map_err(|e| {
DriveRepositoryError::StorageError(format!("serialise partial policies: {e}"))
})?;
// write is preserving). The caller passes a raw `Value` with
// ONLY the keys it wants to change (never a full `DrivePolicies`
// round-trip, which would serialise all-false defaults into the
// merge and clobber other flags). RETURNING surfaces the
// post-merge bag so the audit log shows what the row actually
// carries afterwards.
let row: Option<(serde_json::Value,)> = sqlx::query_as(
"UPDATE storage.drives \
SET policies = policies || $2, \
@@ -654,7 +668,7 @@ impl DriveRepository for DrivePgRepository {
RETURNING policies",
)
.bind(drive_id)
.bind(&partial_json)
.bind(partial)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("update_policies", e))?;
@@ -41,8 +41,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
WHEN uf.item_type = 'folder' THEN fld.path
WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name)
ELSE NULL
END AS "item_path",
COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id"
END AS "item_path"
FROM auth.user_favorites uf
LEFT JOIN storage.files f ON uf.item_type = 'file'
AND f.id = uf.item_id::UUID
@@ -82,7 +81,6 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
parent_id: row.try_get("parent_id").ok(),
modified_at: row.try_get("modified_at").ok(),
item_path: row.try_get("item_path").ok(),
owner_id: row.try_get("owner_id").ok(),
// Temporary defaults; with_display_fields() computes the real values
icon_class: String::new(),
icon_special_class: String::new(),
@@ -309,10 +307,18 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
fld.created_by AS created_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = fld.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
uf.created_at AS favorited_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
@@ -333,10 +339,18 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
f.created_by AS created_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
@@ -489,7 +503,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
// Post-D7: `owner_id` retired; join by `created_by`.
"LEFT JOIN auth.users u ON u.id = r.created_by"
} else {
""
};
@@ -506,7 +521,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
r.drive_id, r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -577,7 +592,6 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
@@ -8,6 +8,8 @@
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
/// Row shape returned by media-file queries (avoids `clippy::type_complexity`).
/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no
/// longer projected.
type MediaFileRow = (
String, // id
String, // name
@@ -18,7 +20,6 @@ type MediaFileRow = (
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id
Option<Uuid>, // created_by (§14 provenance)
Option<Uuid>, // updated_by (§14 provenance)
i64, // sort_date
@@ -43,10 +44,44 @@ use crate::domain::services::path_service::StoragePath;
use crate::infrastructure::services::dedup_service::DedupService;
use uuid::Uuid;
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
/// any active `role_grants` on the drive owning `fi` (the aliased file
/// row). Group memberships (direct + transitive) are expanded inline via
/// `storage.caller_group_ids($1)` (recursive; see migration
/// `20260901000002_caller_group_ids_function.sql`).
///
/// Used by every drive-scoped file search query in this repo:
/// - `search_files_paginated`
/// - `search_files_in_subtree`
///
/// **Alias contract**: queries splicing this in MUST alias
/// `storage.files` as `fi`. `$1` is reserved for `caller_id`; other bind
/// params start at `$2`.
///
/// This mirrors — but is intentionally not shared with — the folder
/// variant in `folder_db_repository.rs` (aliased `fo.drive_id`) and the
/// drive-listing shapes in `drive_pg_repository`/`list_media_files`.
/// When the grant model changes, update all sites in parallel.
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
SELECT 1 \
FROM storage.role_grants g \
WHERE g.resource_type = 'drive' \
AND g.resource_id = fi.drive_id \
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
AND ( \
(g.subject_type = 'user' AND g.subject_id = $1) \
OR (g.subject_type = 'group' AND g.subject_id IN \
(SELECT storage.caller_group_ids($1))) \
) \
)";
/// Type alias for file metadata rows from SQL queries.
/// Fields: id, name, folder_id, folder_path, size, mime_type,
/// created_at, updated_at, blob_hash, user_id, created_by, updated_by.
/// created_at, updated_at, blob_hash, created_by, updated_by.
/// `created_by` / `updated_by` are the §14 provenance columns.
/// Post-D7-step-6: `storage.files.user_id` dropped, so it's no
/// longer part of the tuple; `row_to_file` populates the entity's
/// legacy `user_id` field with `None`.
type FileRow = (
String,
String,
@@ -59,7 +94,6 @@ type FileRow = (
String,
Option<Uuid>,
Option<Uuid>,
Option<Uuid>,
);
/// Append the optional type/date/size filters from `criteria` to
@@ -200,7 +234,7 @@ impl FileBlobReadRepository {
&self,
ids: &[String],
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<File>, DomainError> {
// Index hits are externally produced strings — parse defensively.
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
@@ -208,9 +242,15 @@ impl FileBlobReadRepository {
return Ok(Vec::new());
}
// Post-PR-B: drive-membership scoping via
// [`CALLER_CAN_READ_DRIVE`] (bound to `$1`) replaces the legacy
// `fi.user_id = $caller` predicate. Group grants are honoured
// inline through `storage.caller_group_ids`.
//
// Bind order: $1 = caller_id, $2 = ids array, $3.. = criteria.
let mut conditions: Vec<String> = vec![
"fi.id = ANY($1)".to_string(),
"fi.user_id = $2".to_string(),
CALLER_CAN_READ_DRIVE.to_string(),
"fi.id = ANY($2)".to_string(),
"fi.is_trashed = false".to_string(),
];
let mut bind_idx = 2u32;
@@ -234,7 +274,7 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
\
fi.created_by, fi.updated_by \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -242,8 +282,8 @@ impl FileBlobReadRepository {
);
let mut query = sqlx::query_as::<_, FileRow>(&sql)
.bind(uuid_ids)
.bind(user_id);
.bind(caller_id)
.bind(uuid_ids);
if let Some(folder_id) = criteria.folder_id.as_deref() {
query = query.bind(folder_id);
}
@@ -255,10 +295,8 @@ impl FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -286,7 +324,7 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
\
fi.created_by, fi.updated_by \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
@@ -301,10 +339,8 @@ impl FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -316,20 +352,6 @@ impl FileBlobReadRepository {
})
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
pub async fn get_file_user_id(&self, file_id: &str) -> Result<uuid::Uuid, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT user_id FROM storage.files WHERE id = $1::uuid")
.bind(file_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("user_id lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("File", file_id))
}
/// Returns `drive_id` for a given file. Drives the permission-floor
/// short-circuit in `PgAclEngine::check_inner` — drive membership is
/// the baseline floor per `drive.md §5`.
@@ -386,7 +408,6 @@ impl FileBlobReadRepository {
created_at: i64,
modified_at: i64,
blob_hash: String,
owner_id: Option<Uuid>,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
) -> Result<File, DomainError> {
@@ -400,7 +421,6 @@ impl FileBlobReadRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
blob_hash,
created_by,
updated_by,
@@ -445,12 +465,32 @@ impl FileBlobReadRepository {
///
/// Uses the denormalised `media_sort_date` column (synced from
/// `file_metadata.captured_at` by trigger) so no JOIN with
/// `file_metadata` is needed. The partial index
/// `idx_files_media_timeline` covers the full query: filter + ORDER BY
/// in a single Index Scan — O(LIMIT) not O(N).
/// `file_metadata` is needed. The partial covering index
/// `idx_files_media_timeline_by_drive` (migration 20260901000001)
/// keys on `(drive_id, media_sort_date DESC)` filtered on non-trashed
/// image/video rows — Postgres does one IndexScan per in-scope
/// drive_id already ordered by capture date, so LIMIT stops the scan
/// early. Same O(LIMIT) shape as the pre-D7 `user_id`-keyed hot path.
///
/// Scope (`docs/plan/drive.md` §15): drives with
/// `policies.include_in_photo_index = true` where the caller has a
/// direct grant (`subject_type = 'user'`) OR a grant on a group they
/// belong to transitively. Group membership is expanded inline by the
/// `storage.caller_group_ids(caller)` SQL function (migration
/// `20260901000002_caller_group_ids_function.sql`) — no ceremony at
/// the handler layer, no cross-space ambiguity from the earlier
/// parallel-arrays pattern.
///
/// Default personal drives always match because the flag is
/// materialised to `true` at drive creation (see
/// `DriveRepository::create_personal_drive_atomic` + the backfill
/// migration `20260901000000_default_personal_photo_music_flags.sql`)
/// — no per-kind carve-out needed. Non-default drives (secondary
/// personals, shared drives) surface here only after their owner
/// flips the flag on via the admin "Manage policies" modal.
pub async fn list_media_files(
&self,
owner_id: Uuid,
caller_id: Uuid,
before: Option<i64>,
limit: i64,
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
@@ -461,14 +501,27 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by,
EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id
WHERE fi.user_id = $1
WHERE fi.drive_id IN (
SELECT d.id
FROM storage.drives d
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (d.policies->>'include_in_photo_index')::boolean = true
)
AND NOT fi.is_trashed
AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%')
AND ($2::bigint IS NULL
@@ -477,7 +530,7 @@ impl FileBlobReadRepository {
LIMIT $3
"#,
)
.bind(owner_id)
.bind(caller_id)
.bind(before)
.bind(limit)
.fetch_all(self.pool.as_ref())
@@ -488,9 +541,9 @@ impl FileBlobReadRepository {
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
)?);
sort_dates.push(sd);
dims.push((w, h));
@@ -500,12 +553,20 @@ impl FileBlobReadRepository {
}
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
/// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`.
/// Returns one cluster per non-empty cell with its centroid, photo count
/// and a representative photo id (for the cluster thumbnail).
/// (degrees) within `bounds`. Plain SQL (no PostGIS).
///
/// Scope: same `include_in_photo_index` predicate as
/// `list_media_files` (§15). Places is the map view over the same
/// content set the Photos timeline shows, so the two surfaces MUST
/// agree on drive scope. Group membership is expanded inline by
/// `storage.caller_group_ids(caller)`.
///
/// This query is a per-cell aggregate (group by rounded lat/lng
/// bucket) rather than an ORDER BY / LIMIT hot path — the plain
/// `idx_files_drive_id` is sufficient to seek by drive.
pub async fn list_geo_clusters(
&self,
user_id: Uuid,
caller_id: Uuid,
bounds: GeoBounds,
cell: f64,
) -> Result<Vec<GeoCluster>, DomainError> {
@@ -517,7 +578,20 @@ impl FileBlobReadRepository {
min(fm.file_id::text) AS sample_id
FROM storage.file_metadata fm
JOIN storage.files fi ON fi.id = fm.file_id
WHERE fi.user_id = $1
WHERE fi.drive_id IN (
SELECT d.id
FROM storage.drives d
JOIN storage.role_grants g
ON g.resource_type = 'drive'
AND g.resource_id = d.id
WHERE (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (d.policies->>'include_in_photo_index')::boolean = true
)
AND NOT fi.is_trashed
AND fm.latitude IS NOT NULL
AND fm.longitude IS NOT NULL
@@ -526,7 +600,7 @@ impl FileBlobReadRepository {
GROUP BY round(fm.longitude / $6), round(fm.latitude / $6)
"#,
)
.bind(user_id)
.bind(caller_id)
.bind(bounds.west)
.bind(bounds.east)
.bind(bounds.south)
@@ -564,7 +638,6 @@ impl FileReadPort for FileBlobReadRepository {
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id (owner)
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
@@ -575,7 +648,6 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -593,7 +665,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
)
}
@@ -613,7 +685,6 @@ impl FileReadPort for FileBlobReadRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
@@ -624,7 +695,6 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -639,55 +709,7 @@ impl FileReadPort for FileBlobReadRepository {
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
)
}
async fn get_file_for_owner(&self, id: &str, owner_id: Uuid) -> Result<File, DomainError> {
let row = sqlx::query_as::<
_,
(
String, // id
String, // name
Option<String>, // folder_id
Option<String>, // folder path
i64, // size
String, // mime_type
i64, // created_at
i64, // updated_at
String, // blob_hash
Option<Uuid>, // user_id (owner)
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.id = $1::uuid
AND fi.user_id = $2
AND NOT fi.is_trashed
"#,
)
.bind(id)
.bind(owner_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get_for_owner: {e}")))?
// Return NotFound (not Forbidden) to avoid leaking file existence
.ok_or_else(|| DomainError::not_found("File", id))?;
self.hash_cache.insert(id.to_string(), row.8.clone());
Self::row_to_file(
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10,
)
}
@@ -701,7 +723,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -720,7 +742,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -735,72 +757,8 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
}
/// User-scoped file listing — adds `AND fi.user_id = $2` to prevent
/// cross-user data leakage in the REST API (`list_files_query`).
async fn list_files_for_owner(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
) -> Result<Vec<File>, DomainError> {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
AND fi.user_id = $2
ORDER BY fi.name
"#,
)
.bind(fid)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
AND fi.user_id = $1
ORDER BY fi.name
"#,
)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect()
@@ -829,7 +787,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -851,7 +809,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -869,82 +827,8 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
},
)
.collect()
}
/// User-scoped paginated file listing — adds `AND fi.user_id = $4` to
/// prevent cross-user data leakage in WebDAV PROPFIND.
async fn list_files_batch_for_owner(
&self,
folder_id: Option<&str>,
owner_id: Uuid,
offset: i64,
limit: i64,
) -> Result<Vec<File>, DomainError> {
let rows: Vec<FileRow> = if let Some(fid) = folder_id {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
AND fi.user_id = $4
ORDER BY fi.name
LIMIT $2 OFFSET $3
"#,
)
.bind(fid)
.bind(limit)
.bind(offset)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
} else {
sqlx::query_as(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
fi.size, fi.mime_type,
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
AND fi.user_id = $3
ORDER BY fi.name
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.bind(owner_id)
.fetch_all(self.pool.as_ref())
.await
}
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("list_batch_for_owner: {e}"))
})?;
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect()
@@ -1094,7 +978,6 @@ impl FileReadPort for FileBlobReadRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
@@ -1105,8 +988,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $1 AND fi.folder_id IS NULL
@@ -1134,7 +1016,6 @@ impl FileReadPort for FileBlobReadRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
),
@@ -1145,8 +1026,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
fi.created_by, fi.updated_by
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.path = $1 AND fi.name = $2
@@ -1163,7 +1043,7 @@ impl FileReadPort for FileBlobReadRepository {
match row {
Some(r) => Ok(Some(Self::row_to_file(
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11,
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10,
)?)),
None => Ok(None),
}
@@ -1183,8 +1063,8 @@ impl FileReadPort for FileBlobReadRepository {
let stream = async_stream::try_stream! {
let mut row_stream = sqlx::query_as::<_, (
String, String, Option<String>, Option<String>,
i64, String, i64, i64, String, Option<Uuid>,
Option<Uuid>, Option<Uuid>,
i64, String, i64, i64, String,
Option<Uuid>, Option<Uuid>, // created_by, updated_by (§14)
)>(
r#"
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
@@ -1192,8 +1072,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
fi.created_by, fi.updated_by
FROM storage.files fi
JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid)
@@ -1207,9 +1086,9 @@ impl FileReadPort for FileBlobReadRepository {
while let Some(row) = row_stream.try_next().await.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}"))
})? {
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row;
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub) = row;
let file = FileBlobReadRepository::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
)?;
yield file;
}
@@ -1223,11 +1102,15 @@ impl FileReadPort for FileBlobReadRepository {
/// Uses `COUNT(*) OVER()` window function to return the total matching
/// count alongside the paginated rows in a **single query** — no separate
/// COUNT round-trip.
///
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
/// rather than the legacy `fi.user_id = $caller` predicate. Group
/// grants are honoured inline through `storage.caller_group_ids`.
async fn search_files_paginated(
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
let offset = criteria.offset as i64;
let limit = criteria.limit as i64;
@@ -1245,10 +1128,10 @@ impl FileReadPort for FileBlobReadRepository {
// ── Build dynamic WHERE + bind indices ───────────────────────────
let mut conditions: Vec<String> = vec![
"fi.user_id = $1".to_string(),
CALLER_CAN_READ_DRIVE.to_string(),
"fi.is_trashed = false".to_string(),
];
let mut bind_idx = 1u32; // $1 = user_id
let mut bind_idx = 1u32; // $1 = caller_id
if folder_id.is_some() {
bind_idx += 1;
@@ -1272,7 +1155,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
\
fi.created_by, fi.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
@@ -1295,13 +1178,12 @@ impl FileReadPort for FileBlobReadRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
i64,
i64, // total_count
),
>(&sql)
.bind(user_id);
.bind(caller_id);
if let Some(fid) = folder_id {
query = query.bind(fid);
@@ -1320,15 +1202,13 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
// total_count is the same in every row; 0 when result set is empty.
let total_count = rows.first().map_or(0, |r| r.12) as usize;
let total_count = rows.first().map_or(0, |r| r.11) as usize;
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1346,16 +1226,20 @@ impl FileReadPort for FileBlobReadRepository {
///
/// Uses `COUNT(*) OVER()` to return the total count alongside the
/// paginated rows — no separate COUNT round-trip.
///
/// Post-PR-B: scoped by drive-membership (via [`CALLER_CAN_READ_DRIVE`])
/// rather than the legacy `fi.user_id = $caller` predicate — same
/// group-cascade semantics as `search_files_paginated`.
async fn search_files_in_subtree(
&self,
root_folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<(Vec<File>, usize), DomainError> {
// When no root folder specified, delegate to existing paginated search
let root_id = match root_folder_id {
None => {
return self.search_files_paginated(None, criteria, user_id).await;
return self.search_files_paginated(None, criteria, caller_id).await;
}
Some(id) => id,
};
@@ -1376,10 +1260,10 @@ impl FileReadPort for FileBlobReadRepository {
// ── Build dynamic WHERE clauses ──
let mut conditions = Vec::new();
let mut bind_idx = 2u32; // $1 = user_id, $2 = root_folder_id
let mut bind_idx = 2u32; // $1 = caller_id, $2 = root_folder_id
conditions.push("fi.is_trashed = false".to_string());
conditions.push("fi.user_id = $1".to_string());
conditions.push(CALLER_CAN_READ_DRIVE.to_string());
conditions.push(
"fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid)".to_string(),
);
@@ -1403,7 +1287,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id, \
\
fi.created_by, fi.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.files fi \
@@ -1426,13 +1310,12 @@ impl FileReadPort for FileBlobReadRepository {
i64,
i64,
String,
Option<Uuid>,
Option<Uuid>, // created_by (§14)
Option<Uuid>, // updated_by (§14)
i64,
i64, // total_count
),
>(&sql)
.bind(user_id)
.bind(caller_id)
.bind(root_id);
if let Some(name) = &criteria.name_contains
@@ -1449,15 +1332,13 @@ impl FileReadPort for FileBlobReadRepository {
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
})?;
let total_count = rows.first().map_or(0, |r| r.12) as usize;
let total_count = rows.first().map_or(0, |r| r.11) as usize;
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1473,10 +1354,10 @@ impl FileReadPort for FileBlobReadRepository {
&self,
folder_id: Option<&str>,
criteria: &SearchCriteriaDto,
user_id: Uuid,
caller_id: Uuid,
) -> Result<usize, DomainError> {
let (_, count) = self
.search_files_paginated(folder_id, criteria, user_id)
.search_files_paginated(folder_id, criteria, caller_id)
.await?;
Ok(count)
}
@@ -1499,7 +1380,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -1529,7 +1410,7 @@ impl FileReadPort for FileBlobReadRepository {
EXTRACT(EPOCH FROM fi.created_at)::bigint,
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
fi.blob_hash,
fi.user_id,
fi.created_by, fi.updated_by
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
@@ -1555,10 +1436,8 @@ impl FileReadPort for FileBlobReadRepository {
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| {
Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub,
)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
},
)
.collect()
@@ -104,7 +104,6 @@ impl FileBlobWriteRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
blob_hash: String,
created_by: Option<Uuid>,
updated_by: Option<Uuid>,
@@ -119,7 +118,6 @@ impl FileBlobWriteRepository {
folder_id,
created_at as u64,
modified_at as u64,
owner_id,
blob_hash,
created_by,
updated_by,
@@ -127,30 +125,24 @@ impl FileBlobWriteRepository {
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive `(user_id, drive_id)` from the parent folder. Both are
/// needed during the D0 dual-write window: `user_id` for the legacy
/// column (dropped in D7) and `drive_id` for the new owning-drive
/// reference.
async fn resolve_owner_and_drive(
&self,
folder_id: Option<&str>,
) -> Result<(Uuid, Uuid), DomainError> {
/// Derive `drive_id` from the parent folder. Post-D7: only the
/// drive is needed — the legacy `user_id` column is no longer
/// written on new rows.
async fn resolve_parent_drive(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
match folder_id {
Some(fid) => {
let row: Option<(Uuid, Uuid)> = sqlx::query_as::<_, (Uuid, Uuid)>(
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
})?;
row.ok_or_else(|| DomainError::not_found("Folder", fid))
}
Some(fid) => sqlx::query_scalar::<_, Uuid>(
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(fid)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("FileBlobWrite", format!("parent lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("Folder", fid)),
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
"folder_id is required to determine the target drive",
)),
}
}
@@ -290,20 +282,22 @@ impl FileBlobWriteRepository {
// attempt's error falls through untouched so the 23505 mapping holds
// (a retried INSERT can legitimately lose to a concurrent identical
// upload).
// Post-D7: `user_id` omitted from the INSERT column list and the
// parent CTE. `drive_id` alone is the inherit-from-parent axis;
// provenance is `created_by` / `updated_by` (§14).
let result = retry_on_deadlock("files.insert", || {
sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
sqlx::query_as::<_, (String, String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
WITH parent AS (
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
SELECT id, drive_id, path FROM storage.folders WHERE id = $2::uuid
)
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT $1, parent.id, parent.user_id, parent.drive_id, $3, $4,
SELECT $1, parent.id, parent.drive_id, $3, $4,
$5, $6, $7, $7
FROM parent
RETURNING id::text,
user_id,
(SELECT path FROM parent),
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
@@ -322,71 +316,70 @@ impl FileBlobWriteRepository {
})
.await;
let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) =
match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
return Err(DomainError::not_found("Folder", fid));
let (id, folder_path, created_at, updated_at, created_by, updated_by) = match result {
Ok(Some(row)) => row,
Ok(None) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after missing parent folder — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
// Idempotent re-upload: if the conflicting file already
// holds IDENTICAL content (same folder, same name, same
// blob hash), treat this as success and return that file
// instead of erroring. Re-uploading a partially-uploaded
// folder then becomes a clean no-op for everything that
// already landed — only the genuinely missing files
// transfer — instead of surfacing hundreds of spurious
// "already exists" failures. The duplicate blob reference
// taken during ingest was just released above, so the
// existing file's own reference is the only one (correct);
// a different-content clash still returns the conflict.
match self.fetch_identical_file(fid, &name, blob_hash).await {
Ok(Some(existing)) => {
tracing::info!(
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
name,
&blob_hash[..12]
);
return Ok(existing);
}
Ok(None) => {} // genuine conflict (different content)
Err(lookup_err) => {
tracing::warn!(
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
name,
&blob_hash[..12],
lookup_err
);
}
return Err(DomainError::not_found("Folder", fid));
}
Err(e) => {
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
tracing::error!(
"Blob orphaned after failed INSERT — hash: {}, err: {}",
&blob_hash[..12],
rollback_err
);
}
if let sqlx::Error::Database(ref db_err) = e
&& db_err.code().as_deref() == Some("23505")
{
// Idempotent re-upload: if the conflicting file already
// holds IDENTICAL content (same folder, same name, same
// blob hash), treat this as success and return that file
// instead of erroring. Re-uploading a partially-uploaded
// folder then becomes a clean no-op for everything that
// already landed — only the genuinely missing files
// transfer — instead of surfacing hundreds of spurious
// "already exists" failures. The duplicate blob reference
// taken during ingest was just released above, so the
// existing file's own reference is the only one (correct);
// a different-content clash still returns the conflict.
match self.fetch_identical_file(fid, &name, blob_hash).await {
Ok(Some(existing)) => {
tracing::info!(
"♻️ IDEMPOTENT UPLOAD: {} already present, identical content (hash: {})",
name,
&blob_hash[..12]
);
return Ok(existing);
}
Ok(None) => {} // genuine conflict (different content)
Err(lookup_err) => {
tracing::warn!(
"idempotency lookup failed for {} (hash {}): {} — returning conflict",
name,
&blob_hash[..12],
lookup_err
);
}
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
));
}
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
return Err(DomainError::already_exists(
"File",
format!("'{name}' already exists in this folder"),
));
}
};
return Err(DomainError::internal_error(
"FileBlobWrite",
format!("insert: {e}"),
));
}
};
tracing::info!(
"📡 STREAMING WRITE: {} ({} bytes, hash: {})",
@@ -404,7 +397,6 @@ impl FileBlobWriteRepository {
content_type,
created_at,
updated_at,
Some(user_id),
blob_hash.to_string(),
created_by,
updated_by,
@@ -421,11 +413,12 @@ impl FileBlobWriteRepository {
name: &str,
blob_hash: &str,
) -> Result<Option<File>, DomainError> {
// Post-D7: `f.user_id` is nullable on new rows; use
// `Option<Uuid>` to accept NULL.
let row = sqlx::query_as::<
_,
(
String,
Uuid,
String,
i64,
i64,
@@ -436,7 +429,7 @@ impl FileBlobWriteRepository {
),
>(
r#"
SELECT f.id::text, f.user_id, fo.path,
SELECT f.id::text, fo.path,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
f.created_by, f.updated_by, f.size, f.mime_type
@@ -460,7 +453,6 @@ impl FileBlobWriteRepository {
let Some((
id,
user_id,
folder_path,
created_at,
updated_at,
@@ -482,7 +474,6 @@ impl FileBlobWriteRepository {
mime_type,
created_at,
updated_at,
Some(user_id),
blob_hash.to_string(),
created_by,
updated_by,
@@ -535,11 +526,10 @@ impl FileWritePort for FileBlobWriteRepository {
>(
r#"
WITH dest AS (
SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
)
UPDATE storage.files f
SET folder_id = $1::uuid,
user_id = COALESCE((SELECT user_id FROM dest), f.user_id),
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
updated_at = NOW(),
updated_by = $3
@@ -568,7 +558,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
String::new(),
row.7,
row.8,
@@ -611,29 +600,27 @@ impl FileWritePort for FileBlobWriteRepository {
>(
r#"
WITH src AS (
SELECT name, folder_id, user_id, blob_hash, size, mime_type, category_order
SELECT name, folder_id, blob_hash, size, mime_type, category_order
FROM storage.files
WHERE id = $1::uuid AND NOT is_trashed
),
-- The destination folder may differ from the source's
-- folder (when $2 is set); derive drive_id from the
-- DESTINATION so cross-drive copies land in the right
-- drive. Files in personal drives only copy within the
-- same drive today, but the join makes the migration
-- future-proof for D2's cross-drive copy story.
-- drive. Post-D7: `user_id` no longer projected — the
-- column is not written on new rows.
dest_folder AS (
SELECT id, user_id, drive_id
SELECT id, drive_id
FROM storage.folders
WHERE id = COALESCE($2::uuid,
(SELECT folder_id FROM src))
),
new_file AS (
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
SELECT COALESCE($3::text, src.name),
dest_folder.id,
dest_folder.user_id,
dest_folder.drive_id,
src.blob_hash,
src.size,
@@ -718,7 +705,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
row.7,
row.8,
row.9,
@@ -781,7 +767,6 @@ impl FileWritePort for FileBlobWriteRepository {
row.4,
row.5,
row.6,
None,
String::new(),
row.7,
row.8,
@@ -837,23 +822,21 @@ impl FileWritePort for FileBlobWriteRepository {
size: u64,
caller_id: Uuid,
) -> Result<(File, PathBuf), DomainError> {
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
let drive_id = self.resolve_parent_drive(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
// §14: `created_by = $9 = updated_by = caller_id`. The legacy
// `user_id` column (dropped in D7) stays bound to the parent
// folder's owner; only the two provenance columns flip to the
// caller — see save_file_with_blob_impl.
// Post-D7: `user_id` omitted from the INSERT column list.
// §14: `created_by = $8 = updated_by = caller_id`.
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
(name, folder_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $9)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $8)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
@@ -863,7 +846,6 @@ impl FileWritePort for FileBlobWriteRepository {
)
.bind(&name)
.bind(&folder_id)
.bind(user_id)
.bind(drive_id)
.bind(placeholder_hash)
.bind(size as i64)
@@ -885,7 +867,6 @@ impl FileWritePort for FileBlobWriteRepository {
content_type,
row.1,
row.2,
Some(user_id),
String::new(),
row.3,
row.4,
@@ -21,20 +21,23 @@ use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::path_service::StoragePath;
/// Type alias for folder metadata rows from SQL queries.
/// Tuple order: id, name, path, parent_id, user_id, drive_id,
/// created_at, modified_at, tree_modified_at, created_by, updated_by.
/// Tuple order: id, name, path, parent_id, drive_id, created_at,
/// modified_at, tree_modified_at, created_by, updated_by.
/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every
/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
/// lookups. `created_by` / `updated_by` are the §14 provenance
/// columns, nullable because the FK is `ON DELETE SET NULL`.
///
/// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple
/// no longer carries it. The domain entity's `user_id` field is
/// populated with `None` at `row_to_folder` construction.
type FolderRow = (
String,
String,
String,
Option<String>,
Uuid,
Uuid,
i64,
i64,
i64,
@@ -43,14 +46,14 @@ type FolderRow = (
);
/// Type alias for paginated folder rows (includes total_count as
/// the last element after the §14 provenance columns).
/// the last element after the §14 provenance columns). Same
/// column set as [`FolderRow`] plus the trailing count.
type FolderRowPaginated = (
String,
String,
String,
Option<String>,
Uuid,
Uuid,
i64,
i64,
i64,
@@ -59,21 +62,38 @@ type FolderRowPaginated = (
i64,
);
/// Type alias for folder rows with optional user_id.
/// Includes the §14 provenance columns `created_by` / `updated_by`.
type FolderRowOptUser = (
String,
String,
String,
Option<String>,
Option<Uuid>,
Uuid,
i64,
i64,
i64,
Option<Uuid>,
Option<Uuid>,
);
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
/// any active `role_grants` on the drive owning `fo` (the aliased folder
/// row). Group memberships (direct + transitive) are expanded inline via
/// `storage.caller_group_ids($1)` (recursive; see migration
/// `20260901000002_caller_group_ids_function.sql`).
///
/// Used by every drive-scoped folder query in this repo:
/// - `list_root_folders_for_caller` / `_paginated`
/// - `search_folders` (all three branches)
/// - `list_descendant_folders`
///
/// **Alias contract**: queries splicing this in MUST alias
/// `storage.folders` as `fo`. `$1` is reserved for `caller_id`; other
/// bind params start at `$2`.
///
/// This mirrors — but is not shared with — the drive-membership shape in
/// `drive_pg_repository::list_readable_by` (uses `JOIN` on `d.id`) and
/// the media-scoping subqueries in `file_blob_read_repository` (use
/// `IN (SELECT d.id …)` with the `include_in_photo_index` policy
/// filter). When the grant model changes, update all sites in parallel.
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
SELECT 1 \
FROM storage.role_grants g \
WHERE g.resource_type = 'drive' \
AND g.resource_id = fo.drive_id \
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
AND ( \
(g.subject_type = 'user' AND g.subject_id = $1) \
OR (g.subject_type = 'group' AND g.subject_id IN \
(SELECT storage.caller_group_ids($1))) \
) \
)";
/// PostgreSQL-backed folder repository.
///
@@ -115,7 +135,6 @@ impl FolderDbRepository {
name: String,
path: String,
parent_id: Option<String>,
user_id: Option<Uuid>,
drive_id: Uuid,
created_at: i64,
modified_at: i64,
@@ -129,7 +148,6 @@ impl FolderDbRepository {
name,
storage_path,
parent_id,
user_id,
drive_id,
created_at as u64,
modified_at as u64,
@@ -153,7 +171,7 @@ impl FolderDbRepository {
let rows = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -168,9 +186,7 @@ impl FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
rows.into_iter()
.map(|r| {
Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10)
})
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9))
.collect()
}
}
@@ -182,13 +198,19 @@ impl FolderRepository for FolderDbRepository {
parent_id: Option<String>,
caller_id: Uuid,
) -> Result<Folder, DomainError> {
// Derive (user_id, drive_id) from parent folder in one round-trip.
// Root-level folders require the caller to have set up the home
// drive beforehand (done during user registration via the
// lifecycle hook).
let (user_id, drive_id): (Uuid, Uuid) = if let Some(ref pid) = parent_id {
sqlx::query_as::<_, (Uuid, Uuid)>(
"SELECT user_id, drive_id FROM storage.folders WHERE id = $1::uuid",
// Derive `drive_id` from the parent folder. Root-level folders
// are reserved for the atomic drive-creation transaction in
// `DrivePgRepository::create_personal_drive_atomic` (see
// `docs/plan/drive.md` §3) — the no-orphan-root-folder trigger
// enforces this at the DB level.
//
// Post-D7: only `drive_id` is fetched from the parent. The
// legacy `user_id` column is no longer written to on new rows
// (migration `20260902000000_files_folders_user_id_nullable.sql`);
// provenance flows through `created_by` / `updated_by` (§14).
let drive_id: Uuid = if let Some(ref pid) = parent_id {
sqlx::query_scalar::<_, Uuid>(
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
)
.bind(pid)
.fetch_optional(self.pool())
@@ -205,20 +227,20 @@ impl FolderRepository for FolderDbRepository {
));
};
// D0 dual-write: drive_id alongside user_id (drops in D7); plus
// §14 provenance — `created_by` / `updated_by` bind to the caller
// ($5), NOT to the parent folder's `user_id`. Pre-D2 they're
// silently equivalent (only the parent's owner can write); the
// distinction matters once shared drives let an Editor mutate
// a folder owned by someone else.
// Post-D7: no `user_id` in the INSERT column list — the column
// is nullable and copied rows / new rows leave it NULL.
// `created_by` / `updated_by` carry §14 provenance (both bind to
// the caller — pre-D2 that's silently the parent's owner too,
// but the distinction matters once shared drives let an Editor
// mutate a folder owned by someone else).
//
// RETURNING also surfaces the two provenance columns so the
// built entity / DTO carries fresh values without a re-read.
// RETURNING surfaces the two provenance columns so the built
// entity / DTO carries fresh values without a re-read.
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>)>(
r#"
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $5)
(name, parent_id, drive_id, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $4)
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
@@ -230,7 +252,6 @@ impl FolderRepository for FolderDbRepository {
)
.bind(&name)
.bind(&parent_id)
.bind(user_id)
.bind(drive_id)
.bind(caller_id)
.fetch_one(self.pool())
@@ -248,25 +269,16 @@ impl FolderRepository for FolderDbRepository {
})?;
Self::row_to_folder(
row.0,
name,
row.1,
parent_id,
Some(user_id),
drive_id,
row.2,
row.3,
row.4,
row.0, name, row.1, parent_id, drive_id, row.2, row.3, row.4,
// Fresh from RETURNING — caller_id was bound to both columns.
row.5,
row.6,
row.5, row.6,
)
}
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -282,17 +294,7 @@ impl FolderRepository for FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -318,7 +320,7 @@ impl FolderRepository for FolderDbRepository {
// wrapper scoping post-D0).
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -335,17 +337,7 @@ impl FolderRepository for FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -354,7 +346,7 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -370,7 +362,7 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -386,57 +378,56 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
#[allow(clippy::type_complexity)]
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<Folder>, DomainError> {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
"#,
)
.bind(pid)
.bind(owner_id)
// Drive-scoped root-folder listing: return every root folder
// whose drive the caller has any role_grant on. Group
// memberships (direct + transitive) resolve inline via
// `storage.caller_group_ids($1)`.
//
// Closes `bug_root_folder_listing_legacy_user_id`: pre-D7 this
// query filtered on `folders.user_id = $caller`, which returned
// rows admin had created for other users' drives without ever
// getting a role on them. The drive-membership predicate below
// makes the "admin's own listing" correct without a separate
// filter.
//
// `caller_role` is NOT surfaced here — see the memory
// `project_caller_role_on_file_folder_dto` and the note at the
// top of `folder_repository.rs`. Frontend cross-references
// `/api/drives::caller_role` via `folder.drive_id`.
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND NOT fo.is_trashed \
AND {CALLER_CAN_READ_DRIVE} \
ORDER BY fo.name"
);
let rows: Vec<FolderRow> = sqlx::query_as(&sql)
.bind(caller_id)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
"#,
)
.bind(owner_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_root_folders: {e}"))
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -455,7 +446,7 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -475,7 +466,7 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -496,90 +487,67 @@ impl FolderRepository for FolderDbRepository {
// total_count is identical in every row; 0 when the result set is empty.
let total = if include_total {
Some(rows.first().map_or(0, |r| r.11) as usize)
Some(rows.first().map_or(0, |r| r.10) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
},
)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect();
Ok((folders?, total))
}
/// Paginated folder listing filtered by owner — single query with
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
#[allow(clippy::type_complexity)]
async fn list_folders_by_owner_paginated(
/// Paginated companion to `list_root_folders_for_caller` — same
/// drive-membership predicate, adds LIMIT/OFFSET and an optional
/// window-function COUNT so total pages can be surfaced without a
/// second round-trip.
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,
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
LIMIT $3 OFFSET $4
"#,
)
.bind(pid)
.bind(owner_id)
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by, \
COUNT(*) OVER() AS total_count \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND NOT fo.is_trashed \
AND {CALLER_CAN_READ_DRIVE} \
ORDER BY fo.name \
LIMIT $2 OFFSET $3"
);
let rows: Vec<FolderRowPaginated> = sqlx::query_as(&sql)
.bind(caller_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
LIMIT $2 OFFSET $3
"#,
)
.bind(owner_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_root_folders_paginated: {e}"))
})?;
let total = if include_total {
Some(rows.first().map_or(0, |r| r.11) as usize)
Some(rows.first().map_or(0, |r| r.10) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
},
)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect();
Ok((folders?, total))
}
@@ -607,7 +575,7 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.folders
SET name = $1, updated_at = NOW(), updated_by = $3
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
RETURNING id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -631,17 +599,7 @@ impl FolderRepository for FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -678,7 +636,7 @@ impl FolderRepository for FolderDbRepository {
updated_at = NOW(),
updated_by = $3
WHERE f.id = $2::uuid AND NOT f.is_trashed
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.drive_id,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
@@ -695,17 +653,7 @@ impl FolderRepository for FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(
row.0,
row.1,
row.2,
row.3,
Some(row.4),
row.5,
row.6,
row.7,
row.8,
row.9,
row.10,
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
)
}
@@ -988,7 +936,7 @@ impl FolderRepository for FolderDbRepository {
#[allow(clippy::type_complexity)]
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
@@ -998,7 +946,7 @@ impl FolderRepository for FolderDbRepository {
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
ORDER BY fo.path";
let rows: Vec<FolderRowOptUser> = sqlx::query_as(sql)
let rows: Vec<FolderRow> = sqlx::query_as(sql)
.bind(folder_id)
.fetch_all(self.pool())
.await
@@ -1007,8 +955,8 @@ impl FolderRepository for FolderDbRepository {
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -1018,19 +966,22 @@ impl FolderRepository for FolderDbRepository {
///
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
/// - Recursive + folder_id: delegates to `list_descendant_folders`
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
/// - Recursive + no folder_id: drive-scoped `EXISTS role_grants` [AND LIKE]
///
/// Post-PR-B: filters by drive-membership grants inline (via
/// `caller_group_ids`) instead of `user_id = $caller`.
#[allow(clippy::type_complexity)]
async fn search_folders(
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder scope → existing optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, user_id)
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
@@ -1049,30 +1000,30 @@ impl FolderRepository for FolderDbRepository {
};
if recursive {
// Recursive, no folder scope → ALL user folders
// Recursive, no folder scope → ALL folders in caller's readable drives
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
WHERE {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
);
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.fetch_all(self.pool())
.await
}
@@ -1080,96 +1031,100 @@ impl FolderRepository for FolderDbRepository {
return rows
.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect();
}
// Non-recursive: direct children of parent_id, filtered by user
// Non-recursive: direct children of parent_id, restricted to drives
// the caller can read (parent_id already establishes the subtree).
let sql = if parent_id.is_some() {
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
WHERE fo.parent_id = $2::uuid \
AND {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause} \
ORDER BY fo.name"
)
} else {
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
let name_clause_root = match name_contains {
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
_ => "",
};
format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
AND {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
{name_clause_root} \
ORDER BY fo.name"
)
};
let rows: Vec<FolderRowOptUser> = if let Some(pid) = parent_id {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(caller_id)
.bind(pid)
.bind(user_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(caller_id)
.bind(pid)
.bind(user_id)
.fetch_all(self.pool())
.await
}
} else if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.fetch_all(self.pool())
.await
}
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
/// Lists all descendant folders in a subtree using ltree GiST index.
/// Lists all descendant folders in a subtree using ltree GiST index,
/// scoped to drives the caller can read.
///
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
/// subtree in one indexed scan. Optional name filter is pushed to SQL.
/// subtree in one indexed scan. Post-PR-B: drive-membership filter
/// inline via `caller_group_ids`, replacing the legacy
/// `fo.user_id = $caller` predicate.
#[allow(clippy::type_complexity)]
async fn list_descendant_folders(
&self,
folder_id: &str,
name_contains: Option<&str>,
user_id: Uuid,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> {
let (where_extra, name_pattern) = match name_contains {
Some(name) if name.len() >= 3 => {
@@ -1180,13 +1135,13 @@ impl FolderRepository for FolderDbRepository {
let sql = format!(
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, fo.drive_id, \
fo.drive_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
fo.created_by, fo.updated_by \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
WHERE {CALLER_CAN_READ_DRIVE} \
AND fo.is_trashed = false \
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid) \
AND fo.id != $2::uuid \
@@ -1194,16 +1149,16 @@ impl FolderRepository for FolderDbRepository {
ORDER BY fo.name"
);
let rows: Vec<FolderRowOptUser> = if let Some(ref pattern) = name_pattern {
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(folder_id)
.bind(pattern)
.fetch_all(self.pool())
.await
} else {
sqlx::query_as(&sql)
.bind(user_id)
.bind(caller_id)
.bind(folder_id)
.fetch_all(self.pool())
.await
@@ -1211,8 +1166,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -1230,7 +1185,7 @@ impl FolderRepository for FolderDbRepository {
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -1257,7 +1212,7 @@ impl FolderRepository for FolderDbRepository {
} else {
sqlx::query_as(
r#"
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
SELECT id::text, name, path, parent_id::text, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
@@ -1284,8 +1239,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
})
.collect()
}
@@ -1359,16 +1314,6 @@ impl FolderRepository for FolderDbRepository {
// ── Extra helpers for blob-storage bootstrap ──
impl FolderDbRepository {
/// Returns user_id for a given folder. Used by file repositories.
pub async fn get_folder_user_id(&self, folder_id: &str) -> Result<Uuid, DomainError> {
sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
.bind(folder_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
}
/// Returns `drive_id` for a given folder. Drives the new permission-floor
/// short-circuit in `PgAclEngine::check_inner` (a caller with any role
/// on the folder's drive automatically passes the check — drive
@@ -1382,22 +1327,6 @@ impl FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
}
/// Verifies that `folder_id` is owned by `owner_id`.
///
/// Returns `DomainError::not_found(...)` for both "folder missing" and
/// "folder owned by someone else" — same error to avoid leaking the
/// existence of resources belonging to other users.
pub async fn verify_owner(&self, folder_id: &str, owner_id: Uuid) -> Result<(), DomainError> {
let actual = self.get_folder_user_id(folder_id).await?;
if actual != owner_id {
return Err(DomainError::not_found(
"Folder",
"Target folder not found or access denied",
));
}
Ok(())
}
/// Cursor-paginated combined listing of sub-folders and files inside
/// `parent_id`, sorted by `order_by`.
///
@@ -1434,7 +1363,6 @@ impl FolderDbRepository {
-1::bigint AS size,
f.created_at,
f.updated_at AS modified_at,
f.user_id,
f.drive_id,
NULL::text AS blob_hash,
LOWER(f.name) AS sort_str,
@@ -1454,7 +1382,6 @@ impl FolderDbRepository {
fm.size::bigint,
fm.created_at,
fm.updated_at AS modified_at,
fm.user_id,
fm.drive_id,
fm.blob_hash,
LOWER(fm.name) AS sort_str,
@@ -1580,7 +1507,7 @@ impl FolderDbRepository {
let sql = format!(
"WITH resources AS ({cte_inner}) \
SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, user_id, drive_id, blob_hash, \
created_at, modified_at, drive_id, blob_hash, \
sort_str, type_order, folder_first \
FROM resources \
{where_clause} \
@@ -1589,7 +1516,7 @@ impl FolderDbRepository {
);
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, user_id, drive_id, blob_hash,
// created_at, modified_at, drive_id, blob_hash,
// sort_str, type_order, folder_first)
type Row = (
String,
@@ -1600,8 +1527,7 @@ impl FolderDbRepository {
i64,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
Uuid,
Uuid,
Uuid, // drive_id
Option<String>,
String,
i64,
@@ -1632,12 +1558,11 @@ impl FolderDbRepository {
size: r.5,
created_at: r.6,
modified_at: r.7,
owner_id: r.8,
drive_id: r.9,
blob_hash: r.10,
sort_str: r.11,
type_order: r.12,
folder_first: r.13,
drive_id: r.8,
blob_hash: r.9,
sort_str: r.10,
type_order: r.11,
folder_first: r.12,
})
.collect())
}
@@ -206,6 +206,20 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
// ── Build the UNION ALL CTE ─────────────────────────────────────────
let mut cte_branches: Vec<&str> = Vec::new();
// Post-D7: `is_owner` means "the caller holds an Owner
// role_grant on the drive owning this row". Personal drives:
// the single-owner invariant makes this trivially true for the
// owner and false for anyone else. Shared drives: multiple
// Owners possible; each of them gets `true`. Used only to gate
// whether the handler exposes the full path (path-hierarchy
// hiding for share recipients — see `recent_handler.rs`).
//
// The `created_by` projection is separate — §14 provenance,
// used for the "Owner" column and the owner sort's username
// JOIN. The two signals genuinely differ post-D2: e.g. Bob
// (Editor on Alice's shared drive) making a file has
// `created_by = Bob` but `is_owner = false` because Alice owns
// the drive.
let folder_branch = r#"
SELECT
'folder'::text AS resource_type,
@@ -216,10 +230,18 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
fld.created_by AS created_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = fld.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
ur.accessed_at AS accessed_at,
fld.path::text AS resource_path,
LOWER(fld.name) AS sort_str,
@@ -240,10 +262,18 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.size::bigint,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
f.created_by AS created_by,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role = 'owner'
AND g.subject_type = 'user'
AND g.subject_id = $1::uuid
AND (g.expires_at IS NULL OR g.expires_at > NOW())
) AS is_owner,
ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
LOWER(f.name) AS sort_str,
@@ -394,7 +424,9 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
};
let user_join = if need_user_join {
"LEFT JOIN auth.users u ON u.id = r.owner_id"
// Post-D7: `owner_id` column retired; use `created_by`
// (§14 provenance) as the "owner" identity for the sort.
"LEFT JOIN auth.users u ON u.id = r.created_by"
} else {
""
};
@@ -411,7 +443,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
r.drive_id, r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
{user_join}
@@ -486,7 +518,6 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
@@ -123,26 +123,45 @@ impl TrashRepository for TrashDbRepository {
}
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let rows =
sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
// Post-D7: the `WHERE t.user_id = $1` filter is gone — the
// `user_id` column was dropped from `storage.{files,folders}`
// and the view no longer projects it. Scope is drive-membership
// via role_grants; group memberships expand inline through
// `storage.caller_group_ids`. Same predicate shape as
// `list_root_folders_for_caller` / the file listings.
//
// Legacy method — the paginated `list_resources_paged` is the
// modern shape and takes explicit drive_ids from the service
// layer.
let rows = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
WHERE t.user_id = $1
WHERE EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = t.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($1)))
)
)
ORDER BY t.trashed_at DESC
"#,
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
)
.bind(user_id)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
Ok(rows
.into_iter()
.map(|(id, name, item_type, uid, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
.map(|(id, name, item_type, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, *user_id, trashed_at, path)
})
.collect())
}
@@ -153,9 +172,16 @@ impl TrashRepository for TrashDbRepository {
// …)` in the service callers (`restore_item`, `delete_permanently`).
// The drive precheck in `pg_acl_engine` then resolves Owner-on-drive
// → Delete-permission for items in shared drives.
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
//
// Post-D7: `t.user_id` no longer exists — the column is dropped
// from `storage.{files,folders}` and no longer projected by the
// view. The entity's `user_id` field is still non-optional;
// synthesize `Uuid::nil()`. AuthZ decisions don't consult this
// field — they've already resolved the caller's role on the
// target's drive.
let row = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
SELECT t.id, t.name, t.item_type, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
@@ -167,8 +193,8 @@ impl TrashRepository for TrashDbRepository {
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
Ok(row.map(|(id, name, item_type, uid, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, uid, trashed_at, path)
Ok(row.map(|(id, name, item_type, trashed_at, path)| {
self.row_to_trashed_item(id, name, item_type, Uuid::nil(), trashed_at, path)
}))
}
@@ -313,7 +339,6 @@ impl TrashDbRepository {
-1::bigint AS size,
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at,
@@ -340,7 +365,6 @@ impl TrashDbRepository {
f.size::bigint AS size,
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
f.trashed_at AS trashed_at,
@@ -472,7 +496,7 @@ impl TrashDbRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
@@ -529,7 +553,6 @@ LIMIT $6"
size,
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at,
+142 -42
View File
@@ -608,17 +608,26 @@ impl DedupService {
}
/// Of `hashes` (distinct), the subset `caller_id` may claim without
/// uploading bytes: chunks referenced by manifests of the caller's
/// files (live or trashed), or directly referenced as (legacy)
/// whole-file blobs. Backed by the GIN index on
/// uploading bytes: chunks referenced by manifests of files in drives
/// where the caller holds a **writable role** (owner / editor /
/// contributor), or directly referenced as (legacy) whole-file blobs
/// under the same predicate. Backed by the GIN index on
/// `chunk_manifests.chunk_hashes`.
///
/// Post-D7 (`project_d7_policy_calls` LOCKED design): entitlement is
/// drive-membership + writable-role, not the legacy `user_id`
/// filter. Viewers/commenters are excluded — they can't legitimately
/// upload content into a drive, so they can't claim
/// "already-uploaded" via dedup. Group memberships (direct +
/// transitive) are expanded inline through
/// `storage.caller_group_ids($2)`.
///
/// Trashed files count as ownership: a trashed file's content is still
/// the caller's (restorable until trash-empty), so a re-upload of the
/// same content should hit the dedup fast path instead of forcing the
/// caller to re-send bytes they already have on the server. Must stay
/// in lockstep with [`pin_claimable_chunks`], which actually bumps the
/// ref_count using the same entitlement set.
/// under the caller's writable scope (restorable until trash-empty),
/// so a re-upload of the same content should hit the dedup fast path
/// instead of forcing the caller to re-send bytes they already have
/// on the server. Must stay in lockstep with [`pin_claimable_chunks`],
/// which actually bumps the ref_count using the same entitlement set.
pub async fn claimable_chunks(
&self,
caller_id: uuid::Uuid,
@@ -633,13 +642,35 @@ impl DedupService {
SELECT 1
FROM storage.files f
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
WHERE f.user_id = $2
AND m.chunk_hashes @> ARRAY[c.h]
WHERE m.chunk_hashes @> ARRAY[c.h]
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
)
OR EXISTS (
SELECT 1 FROM storage.files f2
WHERE f2.user_id = $2
AND f2.blob_hash = c.h
WHERE f2.blob_hash = c.h
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f2.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
)",
)
.bind(hashes)
@@ -651,17 +682,23 @@ impl DedupService {
}
/// Pin one reference on each of `hashes` (distinct) that the caller is
/// entitled to claim — owned chunks (see [`claimable_chunks`]) or
/// unreferenced orphans (`ref_count = 0`, the just-uploaded state).
/// entitled to claim — writably-scoped chunks (see [`claimable_chunks`])
/// or unreferenced orphans (`ref_count = 0`, the just-uploaded state).
/// One statement: entitlement check and bump are atomic per row, so a
/// concurrent last-reference delete can never be resurrected and a
/// non-entitled hash is simply not returned.
///
/// Entitlement includes files in trash: a trashed file is still owned
/// by the user, the content is still theirs to re-reference, and the
/// race with trash-empty is handled the same way as `add_reference` —
/// if GC has already deleted the blob row, the UPDATE affects 0 rows
/// and the hash is simply absent from the returned set.
/// Post-D7 (`project_d7_policy_calls` LOCKED): entitlement uses the
/// same drive-membership + writable-role predicate as
/// [`claimable_chunks`] — MUST STAY IN LOCKSTEP with that query.
/// Group memberships resolve through `storage.caller_group_ids($2)`.
///
/// Entitlement includes files in trash: a trashed file is still
/// within the caller's writable scope, the content is still theirs
/// to re-reference, and the race with trash-empty is handled the
/// same way as `add_reference` — if GC has already deleted the blob
/// row, the UPDATE affects 0 rows and the hash is simply absent from
/// the returned set.
///
/// Returns the set actually pinned; the caller compares against its
/// input and reports the difference as `still_missing`.
@@ -682,13 +719,35 @@ impl DedupService {
SELECT 1
FROM storage.files f
JOIN storage.chunk_manifests m ON m.file_hash = f.blob_hash
WHERE f.user_id = $2
AND m.chunk_hashes @> ARRAY[b.hash::text]
WHERE m.chunk_hashes @> ARRAY[b.hash::text]
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
)
OR EXISTS (
SELECT 1 FROM storage.files f2
WHERE f2.user_id = $2
AND f2.blob_hash = b.hash
WHERE f2.blob_hash = b.hash
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f2.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2)))
)
)
) )
RETURNING b.hash",
)
@@ -1068,11 +1127,36 @@ impl DedupService {
.unwrap_or(false)
}
/// Returns `true` if `user_id` owns at least one (even trashed) file that
/// references the blob identified by `hash`.
/// Returns `true` if the caller has a **writable role** on at least one
/// drive containing a (possibly trashed) file that references the blob
/// identified by `hash`.
///
/// Post-D7 (`project_d7_policy_calls` LOCKED): same
/// drive-membership + writable-role predicate as
/// [`claimable_chunks`] / [`pin_claimable_chunks`] — MUST stay in
/// lockstep with them. Group memberships (direct + transitive)
/// expand inline via `storage.caller_group_ids($2)`. Viewers /
/// commenters are excluded — they can't legitimately upload into
/// a drive, so they can't claim "already-uploaded" via dedup.
pub async fn user_owns_blob_reference(&self, hash: &str, user_id: &str) -> bool {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM storage.files WHERE blob_hash = $1 AND user_id = $2::uuid)",
"SELECT EXISTS(
SELECT 1
FROM storage.files f
WHERE f.blob_hash = $1
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2::uuid)))
)
)
)",
)
.bind(hash)
.bind(user_id)
@@ -1082,13 +1166,14 @@ impl DedupService {
}
/// Batch variant of [`Self::user_owns_blob_reference`]: given candidate
/// hashes, return the subset the user already references — in ONE query
/// (backed by `idx_files_blob_hash`). Lets a client hash a whole upload set
/// and learn which files it can skip with a single round trip instead of
/// one probe per file.
/// hashes, return the subset the caller can already reference — in ONE
/// query (backed by `idx_files_blob_hash`). Lets a client hash a whole
/// upload set and learn which files it can skip with a single round trip
/// instead of one probe per file.
///
/// User-scoped, exactly like the single check: only the caller's own blobs
/// are returned, so it cannot probe whether *other* users hold a blob.
/// Post-D7: same drive-membership + writable-role predicate as the
/// single check. Anti-enumeration is preserved — only hashes present
/// in a drive the caller can write to come back.
pub async fn user_owned_blob_references(
&self,
hashes: &[String],
@@ -1098,8 +1183,21 @@ impl DedupService {
return Vec::new();
}
sqlx::query_scalar::<_, String>(
"SELECT DISTINCT blob_hash FROM storage.files \
WHERE blob_hash = ANY($1) AND user_id = $2::uuid",
"SELECT DISTINCT f.blob_hash
FROM storage.files f
WHERE f.blob_hash = ANY($1)
AND EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
AND g.resource_id = f.drive_id
AND g.role IN ('owner', 'editor', 'contributor')
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND (
(g.subject_type = 'user' AND g.subject_id = $2::uuid)
OR (g.subject_type = 'group' AND g.subject_id IN
(SELECT storage.caller_group_ids($2::uuid)))
)
)",
)
.bind(hashes)
.bind(user_id)
@@ -2949,19 +3047,20 @@ mod rechunk_integration_tests {
.await
.expect("insert legacy blob row");
let (user_id, drive_id) = seed_user(pool).await;
let (_user_id, drive_id) = seed_user(pool).await;
let mut file_ids = Vec::new();
for i in 0..n_files {
let name = format!(
"rust-test-rechunk-{label}-{}-{i}",
&Uuid::new_v4().to_string()[..8]
);
// Post-D7: `user_id` omitted — column is nullable and unused
// on new rows.
let id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(&name)
.bind(user_id)
.bind(drive_id)
.bind(&hash)
.bind(data.len() as i64)
@@ -3261,22 +3360,23 @@ mod delta_upload_integration_tests {
async fn seed_owned_content(
svc: &DedupService,
pool: &PgPool,
user_id: Uuid,
_user_id: Uuid,
drive_id: Uuid,
data: &[u8],
label: &str,
) -> (String, Vec<String>, Uuid) {
let file_hash = blake3::hash(data).to_hex().to_string();
// Post-D7: `user_id` omitted — column is nullable and unused on
// new rows.
let file_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
"INSERT INTO storage.files (name, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
)
.bind(format!(
"rust-test-delta-{label}-{}",
&Uuid::new_v4().to_string()[..8]
))
.bind(user_id)
.bind(drive_id)
.bind(&file_hash)
.bind(data.len() as i64)
@@ -15,6 +15,8 @@ use crate::application::dtos::display_helpers::{
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::errors::DomainError;
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
/// Result of resolving a WebDAV path — either a folder or a file.
#[derive(Debug, Clone)]
@@ -33,14 +35,20 @@ impl PathResolverService {
Self { pool }
}
/// Resolve `path` to a folder or file **owned by `user_id`**.
/// Resolve `path` to a folder or file **within the given drive**.
///
/// Adds `AND fo.user_id = $4` / `AND fi.user_id = $4` so that one
/// user can never resolve another user's resources.
pub async fn resolve_path_for_user(
/// Filters on `fo.drive_id = $4` / `fi.drive_id = $4`. Callers
/// pre-resolve which drive they're operating in — native WebDAV
/// derives it from the caller's default drive
/// (`resolve_drive_id_for_native_webdav`); NC WebDAV takes it from
/// the URL-selected chroot (`chroot.drive_id`). Shared by both
/// surfaces so the single-query UNION ALL optimisation lands
/// consistently and no path lookup keys on the doomed
/// `storage.{files,folders}.user_id` column.
pub async fn resolve_path_in_drive(
&self,
path: &str,
user_id: Uuid,
drive_id: Uuid,
) -> Result<ResolvedResource, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
@@ -55,6 +63,13 @@ impl PathResolverService {
String::new()
};
// Widened SELECT: also fetches `blob_hash` (for file ETag) and
// `tree_modified_at` (for folder ETag). Both share the same
// canonical formulas as the rest of the codebase — see
// [`File::compute_etag`] and [`Folder::compute_etag`]. Without
// these two extra columns the resolver used to emit empty
// ETag strings, and NC's `If-Match` round-trips broke
// (see the F6b regression on `test_nc_put_mkcol_blake3.sh`).
let row = sqlx::query_as::<
_,
(
@@ -63,34 +78,37 @@ impl PathResolverService {
String, // name
String, // path
Option<String>, // parent_id
Option<String>, // user_id
Uuid, // drive_id
i64, // created_at
i64, // modified_at
Option<i64>, // size
Option<String>, // mime_type
Option<String>, // folder_id
Option<String>, // blob_hash (files only)
Option<i64>, // tree_modified_at (folders only)
),
>(
r#"
SELECT resource_type, id, name, path, parent_id, user_id, drive_id,
created_at, modified_at, size, mime_type, folder_id
SELECT resource_type, id, name, path, parent_id, drive_id,
created_at, modified_at, size, mime_type, folder_id,
blob_hash, tree_modified_at
FROM (
SELECT 'folder'::text AS resource_type,
fo.id::text,
fo.name,
fo.path,
fo.parent_id::text,
fo.user_id::text,
fo.drive_id,
EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at,
NULL::bigint AS size,
NULL::text AS mime_type,
NULL::text AS folder_id
NULL::text AS folder_id,
NULL::text AS blob_hash,
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint AS tree_modified_at
FROM storage.folders fo
WHERE fo.path = $1 AND NOT fo.is_trashed
AND fo.user_id = $4
AND fo.drive_id = $4
UNION ALL
@@ -103,13 +121,14 @@ impl PathResolverService {
ELSE fi.name
END AS path,
NULL::text AS parent_id,
fi.user_id::text,
fi.drive_id,
EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at,
EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at,
fi.size,
fi.mime_type,
fi.folder_id::text
fi.folder_id::text,
fi.blob_hash,
NULL::bigint AS tree_modified_at
FROM storage.files fi
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
WHERE fi.name = $2
@@ -118,7 +137,7 @@ impl PathResolverService {
OR fo.path = $3
)
AND NOT fi.is_trashed
AND fi.user_id = $4
AND fi.drive_id = $4
) sub
LIMIT 1
"#,
@@ -126,10 +145,10 @@ impl PathResolverService {
.bind(path) // $1
.bind(filename) // $2
.bind(&folder_path) // $3
.bind(user_id) // $4
.bind(drive_id) // $4
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_for_user: {e}")))?
.map_err(|e| DomainError::internal_error("PathResolver", format!("resolve_in_drive: {e}")))?
.ok_or_else(|| DomainError::not_found("Resource", path))?;
let (
@@ -138,45 +157,46 @@ impl PathResolverService {
name,
res_path,
parent_id,
uid,
drive_id,
created_at,
modified_at,
size,
mime_type,
folder_id,
blob_hash,
tree_modified_at,
) = row;
match resource_type.as_str() {
"folder" => Ok(ResolvedResource::Folder(FolderDto {
etag: id.clone(),
id,
name: name.clone(),
path: res_path,
parent_id,
owner_id: uid,
drive_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by this resolver path —
// it's used for existence/type discrimination, not
// detailed DTO emission. Callers that need provenance
// reload through the repo.
created_by: None,
updated_by: None,
})),
"folder" => {
let tree_mod = tree_modified_at.unwrap_or(modified_at) as u64;
Ok(ResolvedResource::Folder(FolderDto {
etag: Folder::compute_etag(&id, tree_mod),
id,
name: name.clone(),
path: res_path,
parent_id,
drive_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
// §14 provenance not selected by this resolver path —
// it's used for existence/type discrimination, not
// detailed DTO emission. Callers that need provenance
// reload through the repo.
created_by: None,
updated_by: None,
}))
}
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
let sz = size.unwrap_or(0) as u64;
// `content_hash`/`etag` are empty here: this resolver
// path doesn't select `blob_hash` from SQL — callers
// are doing existence/type discrimination, not ETag
// emission. If a caller ever needs an ETag from this
// codepath, widen the SELECT and populate properly.
let hash = blob_hash.unwrap_or_default();
let modified_at_u = modified_at as u64;
let etag = File::compute_etag(&hash, modified_at_u);
Ok(ResolvedResource::File(FileDto {
id,
name: name.clone(),
@@ -185,15 +205,14 @@ impl PathResolverService {
mime_type: Arc::from(&*mime),
folder_id,
created_at: created_at as u64,
modified_at: modified_at as u64,
modified_at: modified_at_u,
icon_class: Arc::from(icon_class_for(&name, &mime)),
icon_special_class: Arc::from(icon_special_class_for(&name, &mime)),
category: Arc::from(category_for(&name, &mime)),
size_formatted: format_file_size(sz),
owner_id: uid,
sort_date: None,
content_hash: String::new(),
etag: String::new(),
content_hash: hash,
etag,
// §14 provenance not selected by this resolver path
created_by: None,
updated_by: None,
@@ -203,7 +222,10 @@ impl PathResolverService {
}
/// Returns `true` if the resource at `path` belongs to `user_id`.
pub async fn exists_for_user(&self, path: &str, user_id: Uuid) -> Result<bool, DomainError> {
/// Check whether `path` resolves to a folder or file within the
/// given drive. Companion to `resolve_path_in_drive` — same scope
/// filter, existence-only projection.
pub async fn exists_in_drive(&self, path: &str, drive_id: Uuid) -> Result<bool, DomainError> {
let path = path.trim_start_matches('/').trim_end_matches('/');
if path.is_empty() {
return Ok(false);
@@ -221,7 +243,7 @@ impl PathResolverService {
r#"
SELECT EXISTS(
SELECT 1 FROM storage.folders
WHERE path = $1 AND NOT is_trashed AND user_id = $4
WHERE path = $1 AND NOT is_trashed AND drive_id = $4
) OR EXISTS(
SELECT 1
FROM storage.files fi
@@ -229,18 +251,18 @@ impl PathResolverService {
WHERE fi.name = $2
AND (($3 = '' AND fi.folder_id IS NULL) OR fo.path = $3)
AND NOT fi.is_trashed
AND fi.user_id = $4
AND fi.drive_id = $4
)
"#,
)
.bind(path)
.bind(filename)
.bind(&folder_path)
.bind(user_id)
.bind(drive_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("PathResolver", format!("exists_for_user: {e}"))
DomainError::internal_error("PathResolver", format!("exists_in_drive: {e}"))
})?;
Ok(exists)
+179 -6
View File
@@ -283,6 +283,62 @@ impl PgAclEngine {
}
}
/// Drop the `owner_cache` entry for `resource`. Called after any
/// operation that changes which drive a file/folder belongs to —
/// the pre-D6 comment on `owner_cache` ("a resource's owner is
/// immutable") stopped being true when cross-drive MOVE landed.
///
/// Without this call, admin (or any other role holder) on the
/// destination drive gets `authz.denied` when acting on the moved
/// resource: the cached (stale) `Resource → src_drive_id` lookup
/// steers the drive-role precheck at `check_inner` toward the
/// SOURCE drive where the caller has no role, and the fallback
/// per-resource cascade doesn't cover drive-level grants. TTL
/// backstops eventually (5 min), but every write path that MOVEs
/// content across drives MUST invalidate here so authz observes
/// the new drive on the next check.
pub async fn invalidate_owner_cache_for_resource(&self, resource: Resource) {
self.owner_cache.invalidate(&resource).await;
}
/// Bulk cousin of [`Self::invalidate_owner_cache_for_resource`] —
/// clears the entire `owner_cache`. Called by folder cross-drive
/// MOVE where the moved subtree's descendants each carry their
/// own stale entry, and we don't (yet) walk the subtree to
/// invalidate them individually. The cache repopulates lazily on
/// next access; the overhead is a single JOIN per file/folder
/// touched in the following minute or two, versus a stale-authz
/// bug that returned `NotFound` for legitimate Delete.
pub async fn invalidate_owner_cache_all(&self) {
self.owner_cache.invalidate_all();
}
/// Sibling of [`Self::invalidate_drive_role_cache_for_drive`] keyed by
/// subject rather than drive. Used by the user-deleted lifecycle hook
/// to reap every cached "user X → drive Y = role R" entry after the
/// user row (and its DB-cascade-cleared role_grants) is gone. Without
/// this call the entry lingers until TTL; in practice auth rejection
/// on the deleted user's tokens fires first, but leaving stale
/// authorisation rows in the cache is poor hygiene and would surface
/// as an issue if a session survived (e.g. long-lived Basic Auth via
/// app password) or if a same-uuid user were ever recreated.
pub async fn invalidate_drive_role_cache_for_subject(&self, subject: Subject) {
if let Err(err) = self
.drive_role_cache
.invalidate_entries_if(move |key, _v| key.0 == subject)
{
tracing::error!(
target: "oxicloud::authz",
event = "authz.cache_invalidation_failed",
cache = "drive_role_cache",
subject = ?subject,
error = %err,
"drive_role_cache cannot be bulk-invalidated by subject — \
cache builder is missing support_invalidation_closures()",
);
}
}
/// Expand a user subject into the set of subject UUIDs that should match
/// in `access_grants`: the user's own UUID, every group the user is
/// transitively a member of, and (for internal users only) the implicit
@@ -377,10 +433,15 @@ impl PgAclEngine {
/// Public wrapper around `subject_match_set` for callers that need
/// the expanded `(subject_types, subject_ids)` pair without invoking
/// the engine's full `check`/`require` pipeline. Used by
/// `GET /api/drives` (and future drive-aware listing surfaces) to
/// ask the `DriveRepository` for every drive the caller can read,
/// reusing the engine's cached group-expansion logic.
/// the engine's full `check`/`require` pipeline.
///
/// **Retained for legacy callers only** — new listing queries embed
/// the `storage.caller_group_ids` PostgreSQL function inline (see
/// migration `20260901000002_caller_group_ids_function.sql`) and
/// take a bare `caller_id: Uuid` instead of the pre-expanded arrays.
/// The engine's Moka cache still backs the fast path for per-request
/// AuthZ decisions (`check_inner`, `drive_role_cache`) where the
/// same subject is looked up repeatedly.
pub async fn expand_subject_for_listing(
&self,
subject: Subject,
@@ -418,11 +479,23 @@ impl PgAclEngine {
/// Returns the `drive_id` for a File / Folder. Drives don't have a parent
/// drive — this returns `NotFound` for `Resource::Drive` and the caller
/// must not invoke it on Drive resources.
///
/// `Resource::Calendar`, `Resource::AddressBook` and
/// `Resource::Playlist` are top-level per user with no drive
/// ancestor; they also return `NotFound` and the engine
/// short-circuits to a direct `role_grants` lookup (no drive
/// precheck applies).
async fn drive_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
match resource {
Resource::Folder(id) => self.folder_repo.get_folder_drive_id(&id.to_string()).await,
Resource::File(id) => self.file_repo.get_file_drive_id(&id.to_string()).await,
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
Resource::Drive(_)
| Resource::Calendar(_)
| Resource::AddressBook(_)
| Resource::Playlist(_) => Err(DomainError::not_found(
resource.type_str(),
resource.id().to_string(),
)),
}
}
@@ -458,6 +531,49 @@ impl PgAclEngine {
/// permission — see `roles_implying()`.
///
/// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade.
/// Direct grant lookup with no cascade — used for top-level
/// resources whose ACL lives entirely on their own row
/// (`Resource::Calendar`, `Resource::AddressBook`). Same
/// role-array + subject-set shape as the cascade helpers so a
/// caller's group memberships still resolve, but no ltree /
/// folder ancestry / drive precheck applies. Calendars and
/// address books have no parent to inherit from.
async fn direct_grant_exists(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
permission: Permission,
resource_type: &'static str,
resource_id: Uuid,
counters: &QueryCounters,
) -> Result<bool, DomainError> {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
let roles = Self::roles_implying_strings(permission);
let exists: Option<i32> = sqlx::query_scalar(
r#"
SELECT 1
FROM storage.role_grants g
WHERE g.subject_type = ANY($1)
AND g.subject_id = ANY($2)
AND g.role = ANY($3::storage.grant_role[])
AND g.resource_type = $4
AND g.resource_id = $5
AND (g.expires_at IS NULL OR g.expires_at > NOW())
LIMIT 1
"#,
)
.bind(subject_types)
.bind(subject_ids)
.bind(&roles)
.bind(resource_type)
.bind(resource_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("direct grant: {e}")))?;
Ok(exists.is_some())
}
async fn folder_cascade_grant_exists(
&self,
subject_types: &[&str],
@@ -758,6 +874,52 @@ impl PgAclEngine {
.await?
.is_some_and(|r| r.expand().contains(&permission)))
}
// Top-level resources with no cascade parent — the ACL
// lives entirely on their own `role_grants` rows. Owner is
// an explicit grant seeded at MKCALENDAR / address-book
// create time (Round 3 phase 2 migration), so the common
// "owner accessing their own calendar" case is one SQL
// round-trip — no drive_role_cache short-circuit (no
// drive), no cascade.
Resource::Calendar(id) => {
let (subject_types, subject_ids) =
self.subject_match_set(subject, counters).await?;
self.direct_grant_exists(
&subject_types,
&subject_ids,
permission,
"calendar",
id,
counters,
)
.await
}
Resource::AddressBook(id) => {
let (subject_types, subject_ids) =
self.subject_match_set(subject, counters).await?;
self.direct_grant_exists(
&subject_types,
&subject_ids,
permission,
"address_book",
id,
counters,
)
.await
}
Resource::Playlist(id) => {
let (subject_types, subject_ids) =
self.subject_match_set(subject, counters).await?;
self.direct_grant_exists(
&subject_types,
&subject_ids,
permission,
"playlist",
id,
counters,
)
.await
}
}
}
}
@@ -2094,8 +2256,19 @@ impl UserLifecycleHook for AuthzCacheLifecycleHook {
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
// No DB writes here — just memory invalidation. `_tx` is
// intentionally ignored.
// intentionally ignored. The DB cascade
// (`trg_cleanup_role_grants_user`) already dropped every
// role_grants row for this subject; we mirror that cleanup on
// both authz caches:
// 1. `user_groups_cache` — recomputed group expansion.
// 2. `drive_role_cache` — cached "user X → drive Y = role R"
// entries seeded by prior authz checks. Without this
// the deleted user's role stays visible in-process for
// up to the cache TTL (~30 s).
self.engine.invalidate_user_groups_cache(user.id()).await;
self.engine
.invalidate_drive_role_cache_for_subject(Subject::User(user.id()))
.await;
Ok(())
}
}
@@ -29,7 +29,6 @@ use std::time::Duration;
use moka::sync::Cache;
use uuid::Uuid;
use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::ports::resource_access_hook::ResourceAccessHook;
use crate::application::services::recent_service::RecentService;
@@ -85,7 +84,16 @@ impl ResourceAccessHook for RecentRecordingHook {
let recent = Arc::clone(&self.recent);
let (caller_id, file_id) = key;
tokio::spawn(async move {
if let Err(e) = recent.record_item_access(caller_id, &file_id, "file").await {
// Fast path: skip the trait's `authz.require(Read, …)`
// (upstream `_with_perms` service already gated). The
// extra SQL round-trip pushes the upsert past the client's
// immediate `GET /api/recent/resources` in
// `tests/api/recent.hurl` step 7 — the whole reason for
// the internal variant.
if let Err(e) = recent
.record_item_access_internal(caller_id, &file_id, "file")
.await
{
tracing::warn!(
target: "oxicloud::recent",
caller_id = %caller_id,
@@ -242,30 +242,35 @@ impl ContentIndexWorker {
// Authoritative state re-read: a queued 'upsert' whose row vanished
// or got trashed in the meantime becomes a delete.
let files: Vec<(Uuid, String, String, String, String, String, i64)> =
if upsert_candidates.is_empty() {
Vec::new()
} else {
sqlx::query_as(
"SELECT fi.id, fi.user_id::text, fi.drive_id::text, fi.name,
fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&upsert_candidates)
.fetch_all(self.maintenance_pool.as_ref())
.await?
};
//
// Post-D7: `fi.user_id` is dropped — no longer projected. The
// Tantivy `user_id` field survives as defence-in-depth but now
// always indexes `""`. Every query is Must-scoped by `drive_id`.
// (file_id, drive_id, name, blob_hash, mime, size).
type FileIndexRow = (Uuid, String, String, String, String, i64);
let files: Vec<FileIndexRow> = if upsert_candidates.is_empty() {
Vec::new()
} else {
sqlx::query_as(
"SELECT fi.id, fi.drive_id::text, fi.name,
fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&upsert_candidates)
.fetch_all(self.maintenance_pool.as_ref())
.await?
};
let found: HashSet<Uuid> = files.iter().map(|f| f.0).collect();
deletes.extend(upsert_candidates.iter().filter(|id| !found.contains(id)));
// Per-blob text: batch-read the extraction cache, extract misses.
let wanted_hashes: Vec<String> = files
.iter()
.filter(|(_, _, _, name, _, mime, size)| {
.filter(|(_, _, name, _, mime, size)| {
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
})
.map(|f| f.4.clone())
.map(|f| f.3.clone())
.collect();
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
if !wanted_hashes.is_empty() {
@@ -282,7 +287,7 @@ impl ContentIndexWorker {
}
let mut records = Vec::with_capacity(files.len());
for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files {
for (file_id, drive_id, name, blob_hash, mime, size) in files {
let supported = text_extractor::supports(&name, &mime);
let content = if !supported {
None
@@ -301,7 +306,7 @@ impl ContentIndexWorker {
.map(|t| truncate_on_char(t, PREVIEW_BYTES));
records.push(IndexDocRecord {
file_id: file_id.to_string(),
user_id,
user_id: String::new(),
drive_id,
name,
content,
@@ -243,7 +243,10 @@ fn collect_xml_text<R: std::io::BufRead>(
}
match xml.read_event_into(&mut buf) {
Ok(Event::Text(t)) => {
if let Ok(decoded) = t.xml_content() {
// quick-xml 0.41+ makes XmlVersion explicit on xml_content()
// so callers pick 1.0 vs 1.1 entity-normalization rules. Text
// extraction is version-agnostic — 1.0 is the sane default.
if let Ok(decoded) = t.xml_content(quick_xml::XmlVersion::Implicit1_0) {
out.push_str(&decoded);
}
}
@@ -1479,7 +1479,7 @@ impl crate::application::ports::blob_lifecycle::BlobLifecycleHook for ThumbnailS
for format in [ThumbnailFormat::Webp, ThumbnailFormat::Jpeg] {
let path =
root.join(size.dir_name())
.join(format!("{}.{}", &blob_hash, format.ext()));
.join(format!("{}.{}", blob_hash, format.ext()));
if tokio::fs::metadata(&path).await.is_ok() {
let _ = tokio::fs::remove_file(&path).await;
}
@@ -32,6 +32,14 @@ const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours
pub struct LockEntry {
pub info: LockInfo,
pub path: String,
/// The user who acquired the lock. `None` for entries seeded by
/// unit tests or refresh paths that don't carry a caller (the
/// refresh flow rebuilds from the existing entry without a new
/// caller context, so we preserve whatever was there). RFC 4918
/// §9.11's "MUST be requested by the owner" rule for UNLOCK is
/// enforced by comparing this against the caller in
/// `handle_unlock`.
pub caller_user_id: Option<uuid::Uuid>,
}
/// Per-entry expiration policy for the `by_path` cache.
@@ -110,7 +118,12 @@ impl WebDavLockStore {
/// - The existing lock is exclusive (blocks any new lock), or
/// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8).
#[allow(clippy::result_large_err)]
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
pub fn acquire(
&self,
path: &str,
info: LockInfo,
caller_user_id: Option<uuid::Uuid>,
) -> Result<LockEntry, LockEntry> {
if let Some(existing) = self.by_path.get(path) {
// Exclusive existing lock → blocks everything.
// New exclusive lock → blocked by any existing lock (shared or exclusive).
@@ -123,6 +136,7 @@ impl WebDavLockStore {
let entry = LockEntry {
info,
path: path.to_owned(),
caller_user_id,
};
self.by_token
.insert(entry.info.token.clone(), path.to_owned());
@@ -132,6 +146,7 @@ impl WebDavLockStore {
let entry = LockEntry {
info,
path: path.to_owned(),
caller_user_id,
};
// `LockExpiry` derives the TTL from `entry.info.timeout` on insert —
@@ -254,6 +269,7 @@ mod tests {
LockEntry {
info: lock_info(token, timeout, LockScope::Exclusive),
path: "/file.txt".to_owned(),
caller_user_id: None,
}
}
@@ -305,7 +321,7 @@ mod tests {
let store = WebDavLockStore::new(16);
let info = lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive);
let acquired = store.acquire("/a.txt", info).expect("acquire");
let acquired = store.acquire("/a.txt", info, None).expect("acquire");
assert_eq!(acquired.info.token, "urn:token-1");
// Resolvable by both indexes.
@@ -332,12 +348,14 @@ mod tests {
.acquire(
"/a.txt",
lock_info("urn:token-1", Some("Second-600"), LockScope::Exclusive),
None,
)
.expect("first acquire");
let conflict = store.acquire(
"/a.txt",
lock_info("urn:token-2", Some("Second-600"), LockScope::Exclusive),
None,
);
assert!(conflict.is_err());
// The original holder is returned so the caller can report it.
@@ -351,6 +369,7 @@ mod tests {
.acquire(
"/a.txt",
lock_info("urn:token-1", Some("Infinite"), LockScope::Exclusive),
None,
)
.expect("acquire");
@@ -203,7 +203,10 @@ impl WopiDiscoveryService {
for attr in e.attributes().flatten() {
let value = attr
.decode_and_unescape_value(reader.decoder())
.decoded_and_normalized_value(
quick_xml::XmlVersion::Implicit1_0,
reader.decoder(),
)
.map(|value| value.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(&attr.value).to_string());
@@ -346,6 +346,18 @@ async fn handle_propfind(
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(response_body))
.unwrap())
} else if first_is_uuid {
// Path segment IS a UUID but the calendar isn't
// accessible to the caller — could be another
// owner's calendar or genuinely missing. Return
// 404 (anti-enum, matches every other OxiCloud
// surface post-D7). The pre-Round-3 fall-through
// silently listed the caller's OWN calendars,
// which was misleading (the URL claimed one calendar,
// response returned unrelated ones) and violated
// the anti-enumeration contract audited in
// `docs/plan/authz_audit/caldav_carddav_wopi.md`.
Err(AppError::not_found("Calendar not found"))
} else {
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
// List all calendars for this user
@@ -33,8 +33,8 @@ use crate::application::adapters::webdav_adapter::{PropFindRequest, PropFindType
use crate::application::dtos::address_book_dto::{CreateAddressBookDto, UpdateAddressBookDto};
use crate::application::dtos::contact_dto::CreateContactVCardDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::contact_service::ContactService;
use crate::common::di::AppState;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
@@ -177,7 +177,7 @@ fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
.ok_or_else(|| AppError::unauthorized("Authentication required"))
}
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.addressbook_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
@@ -187,7 +187,7 @@ fn get_addressbook_service(state: &AppState) -> Result<&Arc<ContactStorageAdapte
})
}
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactStorageAdapter>, AppError> {
fn get_contact_service(state: &AppState) -> Result<&Arc<ContactService>, AppError> {
state.contact_use_case.as_ref().ok_or_else(|| {
AppError::new(
StatusCode::NOT_IMPLEMENTED,
@@ -19,8 +19,8 @@ use crate::application::dtos::contact_dto::{
use crate::application::dtos::user_dto::UserDto;
use crate::application::ports::carddav_ports::{AddressBookUseCase, ContactUseCase};
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::contact_service::ContactService;
use crate::domain::errors::ErrorKind;
use crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter;
use crate::interfaces::middleware::auth::AuthUser;
const SYSTEM_BOOK_ID: &str = "system";
@@ -28,7 +28,7 @@ const SYSTEM_BOOK_ID: &str = "system";
/// Combined state for the contacts REST API.
#[derive(Clone)]
pub struct ContactsApiState {
pub contact_service: Arc<ContactStorageAdapter>,
pub contact_service: Arc<ContactService>,
pub auth_service: Option<Arc<AuthApplicationService>>,
/// When false, the virtual "system" address book (OxiCloud users) is hidden.
pub expose_system_users: bool,
+17 -25
View File
@@ -48,23 +48,7 @@ pub async fn list_drives(
) -> impl IntoResponse {
let caller_id = auth_user.id;
let (subject_types, subject_ids) = match state
.authorization
.expand_subject_for_listing(Subject::User(caller_id))
.await
{
Ok(pair) => pair,
Err(e) => {
error!("list_drives: subject expansion failed: {e}");
return AppError::from(e).into_response();
}
};
match state
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
match state.drive_repo.list_readable_by(caller_id).await {
Ok(drives) => {
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
(StatusCode::OK, Json(dtos)).into_response()
@@ -416,6 +400,10 @@ pub struct UpdateDrivePoliciesDto {
pub forbid_cross_drive_move: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forbid_owner_role_change: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_in_photo_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_in_music_index: Option<bool>,
}
/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy
@@ -494,18 +482,22 @@ pub async fn update_drive_policies(
serde_json::Value::Bool(v),
);
}
if let Some(v) = dto.include_in_photo_index {
partial_obj.insert("include_in_photo_index".into(), serde_json::Value::Bool(v));
}
if let Some(v) = dto.include_in_music_index {
partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v));
}
// Pass the raw JSON straight through so the JSONB `||` merge in
// the repo only touches keys the caller supplied. Round-tripping
// via `DrivePolicies` (which has `#[serde(default)]`) would
// silently fill every omitted field with `false` — the merge
// would then clobber every unmentioned policy on the row.
let partial_value = serde_json::Value::Object(partial_obj);
let partial: crate::domain::entities::drive::DrivePolicies =
match serde_json::from_value(partial_value) {
Ok(p) => p,
Err(e) => {
return AppError::bad_request(format!("invalid policy body: {e}")).into_response();
}
};
match state
.drive_management_service
.update_policies(auth_user.id, drive_id, partial)
.update_policies(auth_user.id, drive_id, partial_value)
.await
{
Ok(merged) => (StatusCode::OK, axum::Json(merged)).into_response(),
@@ -6,7 +6,7 @@ use axum::{
};
use serde::Deserialize;
use std::sync::Arc;
use tracing::{error, info};
use tracing::info;
use utoipa::ToSchema;
use crate::application::dtos::display_helpers::{
@@ -66,7 +66,8 @@ pub async fn add_favorite(
Json(serde_json::json!({
"error": "Item type must be 'file' or 'folder'"
})),
);
)
.into_response();
}
match favorites_service
@@ -81,16 +82,14 @@ pub async fn add_favorite(
"message": "Item added to favorites"
})),
)
.into_response()
}
Err(err) => {
error!("Error adding to favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to add to favorites"
})),
)
}
// Route through AppError so the `DomainError::kind` maps to the
// right status code (NotFound → 404 anti-enum for the pre-write
// authz gate, InvalidInput → 400 for a malformed UUID, etc.).
// A hardcoded 500 here would mask the 404 the Round 1 AuthZ
// fix relies on.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -129,6 +128,7 @@ pub async fn remove_favorite(
"message": "Item removed from favorites"
})),
)
.into_response()
} else {
info!("Item {} '{}' was not in favorites", item_type, item_id);
(
@@ -137,17 +137,12 @@ pub async fn remove_favorite(
"message": "Item was not in favorites"
})),
)
.into_response()
}
}
Err(err) => {
error!("Error removing from favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to remove from favorites"
})),
)
}
// Same rationale as `add_favorite` — preserve DomainError→HTTP
// status mapping instead of collapsing every error to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -215,7 +210,6 @@ pub async fn list_favorites_resources(
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
@@ -265,7 +259,6 @@ pub async fn list_favorites_resources(
)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
@@ -349,15 +342,10 @@ pub async fn batch_add_favorites(
);
(StatusCode::OK, Json(serde_json::json!(result))).into_response()
}
Err(err) => {
error!("Error in batch add favorites: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to batch add favorites"
})),
)
.into_response()
}
// Preserve DomainError→HTTP status mapping — the Round 1
// AuthZ fix relies on a per-item NotFound propagating out
// of the batch. A hardcoded 500 would mask the 404 that
// signals a cross-tenant probe.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -106,9 +106,12 @@ impl FolderHandler {
Self::list_folders_scoped(service, None, &auth_user).await
}
/// Internal helper: lists folders scoped to the authenticated user.
/// Uses `list_folders_for_owner` — the DB query filters by `user_id`,
/// so no data from other users ever leaves the database.
/// Internal helper: lists folders the authenticated caller can Read.
/// Post-PR-B, `list_root_folders_for_caller` scopes via
/// drive-membership grants (`role_grants` + group cascade via
/// `storage.caller_group_ids`) instead of the legacy `folders.user_id`
/// filter, so folders in shared drives the caller belongs to
/// surface here too.
async fn list_folders_scoped(
service: AppState,
parent_id: Option<&str>,
@@ -501,7 +504,6 @@ pub async fn list_folder_resources(
name: row.name.clone(),
path: String::new(), // cleared — share recipients must not see hierarchy
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
drive_id: row.drive_id,
created_at: row.created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
@@ -550,7 +552,6 @@ pub async fn list_folder_resources(
icon_special_class: Arc::from(icon_special_class_for(&row.name, mime)),
category: Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
@@ -113,6 +113,14 @@ pub async fn create_grant(
.get_by_id(id)
.await
.map(|d| d.drive.typed_policies()),
// Calendars, address books and playlists live outside the
// drive hierarchy (top-level per user), so no drive-level
// policy gates apply. If per-resource policies ever ship for
// these kinds, they'll live on the resource itself, not on a
// drive; the default-empty bag is the right no-op here.
Resource::Calendar(_) | Resource::AddressBook(_) | Resource::Playlist(_) => {
Ok(crate::domain::entities::drive::DrivePolicies::default())
}
};
let drive_policies = match drive_policies {
Ok(p) => p,
@@ -63,13 +63,13 @@ pub async fn list_photos(
headers: HeaderMap,
Query(params): Query<PhotosQueryParams>,
) -> impl IntoResponse {
let user_id = auth_user.id;
let caller_id = auth_user.id;
let limit = params.limit.unwrap_or(200).clamp(1, 500);
let file_read = &state.repositories.file_read_repository;
match file_read
.list_media_files(user_id, params.before, limit)
.list_media_files(caller_id, params.before, limit)
.await
{
Ok((files, sort_dates, dims)) => {
+11 -33
View File
@@ -5,7 +5,7 @@ use axum::{
response::IntoResponse,
};
use std::sync::Arc;
use tracing::{error, info};
use tracing::info;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
@@ -70,16 +70,10 @@ pub async fn record_item_access(
)
.into_response()
}
Err(err) => {
error!("Error recording access in recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to record access"
})),
)
.into_response()
}
// Preserve DomainError→HTTP status mapping — the Round 1
// AuthZ fix relies on the NotFound from `authz.require`
// propagating as 404 (anti-enum), not being masked as 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -130,16 +124,9 @@ pub async fn remove_from_recent(
.into_response()
}
}
Err(err) => {
error!("Error removing from recents: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to remove from recents"
})),
)
.into_response()
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -170,16 +157,9 @@ pub async fn clear_recent_items(
)
.into_response()
}
Err(err) => {
error!("Error clearing recent items: {}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to clear recent items"
})),
)
.into_response()
}
// Same rationale as `record_item_access` — preserve the
// DomainError→HTTP mapping instead of collapsing to 500.
Err(err) => AppError::from(err).into_response(),
}
}
@@ -246,7 +226,6 @@ pub async fn list_recent_resources(
name: row.name.clone(),
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
@@ -294,7 +273,6 @@ pub async fn list_recent_resources(
)),
category: std::sync::Arc::from(category_for(&row.name, mime)),
size_formatted: format_file_size(size_bytes),
owner_id: Some(row.owner_id.to_string()),
sort_date: None,
content_hash,
etag,
File diff suppressed because it is too large Load Diff
+182 -16
View File
@@ -20,10 +20,13 @@ use axum::{
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::application::services::wopi_lock_service::WopiLockService;
use crate::application::services::wopi_token_service::WopiTokenService;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService;
/// Shared state for WOPI handlers.
@@ -64,6 +67,37 @@ pub struct CheckFileInfoResponse {
pub close_url: String,
}
/// Enforce that the WOPI caller (`claims.sub`) still has `perm` on the
/// file at redemption time — not just at token-mint time.
///
/// **Why every verb needs this.** WOPI tokens are validated locally
/// (HMAC over claims), so a token that was legitimately minted stays
/// verify-able until its TTL. If a grant is revoked after mint, or the
/// token was minted for view but is used to POST content, the token's
/// signature alone doesn't catch it. This helper re-checks against the
/// live authorization engine on every verb — the memory note
/// `wopi-authz-bypass` calls out the class of bugs this fences.
///
/// Returns 404 (anti-enumeration — same shape as "file doesn't exist")
/// on both bad UUID and authorization denial. The engine emits a
/// structured `audit` line on denial internally, so ops sees the real
/// reason without the attacker being able to distinguish "gone" from
/// "revoked".
async fn require_wopi_perm(
authz: &PgAclEngine,
caller_sub: &str,
file_id: &str,
perm: Permission,
) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> {
let caller_uuid = uuid::Uuid::parse_str(caller_sub).map_err(|_| StatusCode::UNAUTHORIZED)?;
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
authz
.require(Subject::User(caller_uuid), perm, Resource::File(file_uuid))
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok((caller_uuid, file_uuid))
}
/// GET /wopi/files/{file_id} — CheckFileInfo
async fn check_file_info(
Path(file_id): Path<String>,
@@ -82,6 +116,19 @@ async fn check_file_info(
return StatusCode::UNAUTHORIZED.into_response();
}
// Redemption-time authz: even with a valid token, the caller must
// still hold Read on this file. Catches revoked-grant-mid-session.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Read,
)
.await
{
return status.into_response();
}
// Fetch file metadata
let file = match state
.app_state
@@ -99,16 +146,40 @@ async fn check_file_info(
.map(|dt| dt.to_rfc3339())
.unwrap_or_default();
// `user_can_write` = actual current Update permission ∧ token's
// can_write flag. If the caller's Update was revoked since the
// token was minted (e.g. their grant was downgraded from Editor
// to Viewer), the editor sees the file as read-only and won't
// even attempt PutFile. The stricter `require_wopi_perm(Update)`
// in put_file is the actual gate; this field is a UI hint.
let can_write_now = claims.can_write
&& state
.app_state
.authorization
.check(
Subject::User(uuid::Uuid::parse_str(&claims.sub).unwrap_or(uuid::Uuid::nil())),
Permission::Update,
Resource::File(uuid::Uuid::parse_str(&file_id).unwrap_or(uuid::Uuid::nil())),
)
.await
.unwrap_or(false);
let response = CheckFileInfoResponse {
base_file_name: file.name.clone(),
owner_id: file.owner_id.clone().unwrap_or_else(|| claims.sub.clone()),
// WOPI's `OwnerId` field is required. Post-D7 the DTO no
// longer carries `owner_id`; fall back to `created_by`
// (§14 provenance) with the requesting user as a final default.
owner_id: file
.created_by
.map(|u| u.to_string())
.unwrap_or_else(|| claims.sub.clone()),
size: file.size,
user_id: claims.sub.clone(),
version: file.modified_at.to_string(),
supports_locks: true,
supports_update: claims.can_write,
supports_update: can_write_now,
supports_rename: false,
user_can_write: claims.can_write,
user_can_write: can_write_now,
user_friendly_name: claims.username.clone(),
post_message_origin: state.public_base_url.clone(),
last_modified_time: last_modified,
@@ -139,6 +210,18 @@ async fn get_file(
return StatusCode::UNAUTHORIZED.into_response();
}
// Redemption-time authz — see require_wopi_perm docstring.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Read,
)
.await
{
return status.into_response();
}
match state
.app_state
.applications
@@ -178,6 +261,21 @@ async fn put_file(
return StatusCode::UNAUTHORIZED.into_response();
}
// Redemption-time authz: the token says the caller could write when
// it was minted, but Update permission may have been revoked since.
// Re-check now so a stale write-capable token can't survive a
// downgrade / share removal / drive-membership change until its TTL.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Update,
)
.await
{
return status.into_response();
}
// Check lock
let request_lock = headers
.get("X-WOPI-Lock")
@@ -258,7 +356,7 @@ async fn put_file(
.app_state
.applications
.file_upload_service
.update_file_streaming(
.update_file_streaming_with_perms(
&file.path,
drive_id,
ingested.stored(),
@@ -296,6 +394,22 @@ async fn file_operations(
return StatusCode::UNAUTHORIZED.into_response();
}
// Every lock op mutates shared state (LOCK / UNLOCK / REFRESH_LOCK
// change the lock; GET_LOCK reads it but the read is only useful
// to a caller who could subsequently take a write action — so gate
// on Update uniformly rather than splitting per-op). A Viewer with
// a stale token must not be able to hold or contend for a lock.
if let Err(status) = require_wopi_perm(
state.app_state.authorization.as_ref(),
&claims.sub,
&file_id,
Permission::Update,
)
.await
{
return status.into_response();
}
let override_header = headers
.get("X-WOPI-Override")
.and_then(|v| v.to_str().ok())
@@ -368,25 +482,71 @@ pub struct EditorUrlResponse {
pub access_token_ttl: i64,
}
/// Determines if `caller_id` can access `file_id` and with what permissions.
/// Resolve the WOPI mint target: gate on real permissions and derive
/// the `can_write` flag from the caller's ACTUAL Update rights.
///
/// Uses the SQL-level ownership check (`get_file_owned`) so that files
/// belonging to other users — or non-existent files — both return `NOT_FOUND`,
/// avoiding existence-leak oracles.
/// Prior behaviour used a naive `requested_action != "view"` heuristic
/// so a Viewer clicking "Edit in Collabora" received a write-capable
/// token, promoting themselves to Editor for the token's TTL. The
/// memory note `wopi-authz-bypass` fix #12 calls this out explicitly.
///
/// Returns `(FileDto, can_write)` on success.
/// Contract:
///
/// 1. **Read** is the bar to open the file in any mode. If the caller
/// has no Read grant, return 404 (anti-enum — same shape as "no such
/// file").
/// 2. **Update** determines the returned `can_write` bit — INDEPENDENT
/// of what the client's `requested_action` said. A Viewer who
/// requested `action=edit` gets `can_write=false` and Collabora
/// opens in view mode; the token stays authorised for view-only
/// ops and put_file will 404 at redemption regardless.
/// 3. `requested_action == "view"` is respected as a downgrade — an
/// Editor can explicitly request view mode (co-browsing a doc
/// without accidentally editing) and get `can_write=false`.
///
/// The `PgAclEngine::require`/`check` calls emit structured audit
/// lines on denial (`authz.denied` event), so a Viewer's "edit"
/// attempt shows up in the audit stream as a rejected Update check.
async fn authorize_wopi_access<S: FileRetrievalUseCase>(
authz: &PgAclEngine,
file_retrieval: &S,
file_id: &str,
caller_id: uuid::Uuid,
requested_action: &str,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval
.get_file_with_perms(file_id, caller_id)
let file_uuid = uuid::Uuid::parse_str(file_id).map_err(|_| StatusCode::NOT_FOUND)?;
// Step 1 — Read is required to even open the file.
authz
.require(
Subject::User(caller_id),
Permission::Read,
Resource::File(file_uuid),
)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only.
let can_write = requested_action != "view";
let file = file_retrieval
.get_file(file_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Step 2 — can_write reflects real Update, not the client's
// action-string. `check` returns bool without throwing; failure
// just means the caller lacks Update, so we degrade the token to
// read-only. Deliberately no `require` here — a Viewer opening
// the file is legitimate; only the write claim is suppressed.
let has_update = authz
.check(
Subject::User(caller_id),
Permission::Update,
Resource::File(file_uuid),
)
.await
.unwrap_or(false);
// Step 3 — allow explicit view-mode downgrade for Editors.
let can_write = has_update && requested_action != "view";
Ok((file, can_write))
}
@@ -403,6 +563,7 @@ pub async fn get_editor_url(
let username = &auth_user.username;
// Verify the caller owns the file (SQL-level check, no existence leak).
let (file, can_write) = match authorize_wopi_access(
state.app_state.authorization.as_ref(),
state.app_state.applications.file_retrieval_service.as_ref(),
&params.file_id,
user_id,
@@ -488,7 +649,8 @@ async fn host_page(
Ok(u) => u,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
let file = match authorize_wopi_access(
let (file, can_write_now) = match authorize_wopi_access(
state.app_state.authorization.as_ref(),
state.app_state.applications.file_retrieval_service.as_ref(),
&file_id,
caller_uuid,
@@ -496,7 +658,7 @@ async fn host_page(
)
.await
{
Ok((f, _)) => f,
Ok((f, cw)) => (f, cw),
Err(status) => return status.into_response(),
};
@@ -513,11 +675,15 @@ async fn host_page(
_ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
// Use the freshly-computed `can_write_now` (real Update permission
// ∧ requested_action) rather than the incoming token's `can_write`
// flag. Otherwise a Viewer who somehow reached this host page with
// a stale edit-capable token would get another one re-minted.
let (token, ttl) = match state.token_service.generate_token(
&file_id,
&claims.sub,
&claims.username,
claims.can_write,
can_write_now,
) {
Ok(t) => t,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+13 -11
View File
@@ -422,13 +422,17 @@ pub async fn handle_search(
let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results
// TODO(D1): drop the hardcoded "Personal/" prefix and read the
// caller's default-drive root folder name from `drives.root_folder_id`
// instead. Correct for D0-provisioned default drives; secondary
// drives keep their original root name.
// Map file results.
//
// `strip_drive_root_segment` handles both default and secondary
// drives — post-D0 the first path segment is the drive's root
// folder name (`"Personal"` for D0-provisioned defaults, the
// original sibling-root name for M2 backfilled secondaries).
// Read-scope is upstream in `state.applications.search_service`;
// this handler only formats display paths.
for file in &results.files {
let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = file_id_map.get(&file.id).copied();
@@ -452,12 +456,10 @@ pub async fn handle_search(
}));
}
// Map folder results — same TODO(D1) as above.
// Map folder results — same drive-agnostic strip as above.
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix("Personal/")
.unwrap_or(&folder.path);
let display_path =
crate::interfaces::nextcloud::webdav_handler::strip_drive_root_segment(&folder.path);
let display_path = format!("/{}", display_path);
entries.push(json!({
+25 -3
View File
@@ -11,11 +11,14 @@ use axum::{
use serde::Deserialize;
use std::sync::Arc;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PreviewParams {
@@ -89,9 +92,28 @@ pub async fn handle_preview(
}
};
// Verify the authenticated user owns this file
let user_id_str = user.id.to_string();
if file.owner_id.as_deref() != Some(user_id_str.as_str()) {
// Verify the authenticated user can Read this file. Anti-enum: any
// AuthZ denial surfaces as 404 (same shape as "unknown file" above),
// and the engine emits an `authz.denied` audit line internally.
let file_uuid = match Uuid::parse_str(&file.id) {
Ok(u) => u,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
.unwrap();
}
};
if state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await
.is_err()
{
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
+77 -21
View File
@@ -63,6 +63,15 @@ async fn handle_filter_files(
) -> Result<Response<Body>, AppError> {
let user = &session.user;
let url_user = &session.raw_username;
// Chroot-scope the response: NC's `oc:filter-files` REPORT is a
// single-drive surface (the client PROPFINDs favorites under its
// "home" URL and has no cross-drive concept). Favorites that live
// in another drive the caller is a member of are dropped from
// this response; they're still reachable via REST
// `/api/favorites/resources`. `session.require_chroot()` is safe
// here — the REPORT verb only reaches this handler through a
// path-scoped route.
let chroot = session.require_chroot()?;
let fav_svc = match state.favorites_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
@@ -85,11 +94,11 @@ async fn handle_filter_files(
// All items in this response are favorites.
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
// TODO(D1): replace the hardcoded "Personal/" prefix with the
// caller's default-drive root folder name read from
// `drives.root_folder_id`. Correct for D0-provisioned default
// drives; secondary drives keep their original root name.
let home_prefix = "Personal/";
// `home_prefix` is unused after the chroot-aware strip
// (see `strip_home_prefix`); kept as a positional argument in
// the emit calls below for signature stability with the
// report-handler tests and the parallel search-pass caller.
let home_prefix = "";
// Pass 1: resolve the favorited DTOs in two batch queries (was one
// get_* per favorite — up to N serial round-trips on a sync client's
@@ -156,7 +165,17 @@ async fn handle_filter_files(
// multi-drive `~{drive}` form is echoed back to the client;
// owner-id stays canonical via `&user.username`.
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
// Skip favorites that live outside the caller's chroot
// (other-drive favorites); reachable via REST if needed.
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT filter-files: dropping cross-chroot favorite '{}' at '{}'",
file.id,
file.path,
);
continue;
};
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -174,7 +193,15 @@ async fn handle_filter_files(
}
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT filter-files: dropping cross-chroot favorite folder '{}' at '{}'",
folder.id,
folder.path,
);
continue;
};
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -210,9 +237,13 @@ async fn handle_search(
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
// Validate chroot up-front (path-scoped handler); `resolve_scope_folder`
// below re-pulls it from the session for the path-mapping step.
session.require_chroot()?;
// Chroot-scope the response: NC's search REPORT is a single-drive
// surface. Results that live outside the chroot (other drives the
// caller is a member of) are dropped from the multistatus and
// recorded at debug — reachable via REST search if needed.
// `resolve_scope_folder` below re-pulls chroot from the session
// for the path-mapping step.
let chroot = session.require_chroot()?;
let url_user = &session.raw_username;
let search_svc = match state.applications.search_service.as_ref() {
Some(svc) => svc,
@@ -244,10 +275,9 @@ async fn handle_search(
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
// TODO(D1): same as the favorites pass above — replace the
// hardcoded "Personal/" with the caller's actual default-drive
// root folder name from `drives.root_folder_id`.
let home_prefix = "Personal/";
// See the favorites pass above: `home_prefix` is unused after the
// chroot-aware strip, kept only for signature stability.
let home_prefix = "";
// No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new();
@@ -269,7 +299,15 @@ async fn handle_search(
// Files.
for file in &files {
let subpath = strip_home_prefix(&file.path, home_prefix);
let Some(subpath) = strip_home_prefix(chroot, &file.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT search: dropping cross-chroot file '{}' at '{}'",
file.id,
file.path,
);
continue;
};
let href = nc_href(url_user, subpath);
let fid = file_id_map.get(&file.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -288,7 +326,15 @@ async fn handle_search(
// Folders.
for folder in &folders {
let subpath = strip_home_prefix(&folder.path, home_prefix);
let Some(subpath) = strip_home_prefix(chroot, &folder.path, home_prefix) else {
tracing::debug!(
target: "oxicloud::nc",
"REPORT search: dropping cross-chroot folder '{}' at '{}'",
folder.id,
folder.path,
);
continue;
};
let href = format!("{}/", nc_href(url_user, subpath));
let fid = folder_id_map.get(&folder.id).copied();
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
@@ -345,7 +391,6 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
.into(),
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
size_formatted: format_file_size(fr.size),
owner_id: None,
sort_date: None,
content_hash: fr.blob_hash.clone(),
etag,
@@ -365,7 +410,6 @@ fn folder_dto_from_search(
name: sr.name.clone(),
path: sr.path.clone(),
parent_id: sr.parent_id.clone(),
owner_id: None,
drive_id: sr.drive_id,
created_at: sr.created_at,
modified_at: sr.modified_at,
@@ -552,7 +596,19 @@ fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option<String> {
None
}
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
path.strip_prefix(prefix).unwrap_or(path)
/// Strip the caller's chroot prefix from an internal path so the
/// caller-facing DAV subpath is chroot-relative. Delegates to
/// `webdav_handler::strip_chroot_prefix` — chroot-aware, multi-segment
/// safe, and rejects items outside the chroot. Callers must decide
/// per-response whether an out-of-chroot item is dropped or falls
/// back to the naive strip.
///
/// See `strip_chroot_prefix` for the full contract. The `_prefix`
/// legacy arg stays for signature stability with the emit helpers.
fn strip_home_prefix<'a>(
chroot: &crate::application::dtos::folder_dto::FolderDto,
path: &'a str,
_prefix: &str,
) -> Option<&'a str> {
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, path)
}
+57 -15
View File
@@ -81,6 +81,14 @@ async fn handle_propfind(
session: &crate::interfaces::nextcloud::session::NcSession,
) -> Result<Response<Body>, AppError> {
let user = &session.user;
// Chroot-scope the trashbin view: `get_trash_items(user.id)`
// spans every drive the caller is a member of, but NC's
// trashbin surface is a single-drive concept from the client's
// POV. Items outside the chroot are dropped from the multistatus
// (see `write_trashbin_multistatus` → `strip_home_prefix` →
// `webdav_handler::strip_chroot_prefix`) and remain reachable
// via REST `/api/trash/resources`.
let chroot = session.require_chroot()?;
let trash_svc = state
.trash_service
.as_ref()
@@ -95,7 +103,7 @@ async fn handle_propfind(
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
@@ -259,18 +267,23 @@ fn mime_from_name(name: &str) -> String {
.to_string()
}
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
/// Strip the caller's chroot prefix from an original path to produce
/// the Nextcloud-relative original-location value.
///
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
/// default-drive root folder name read from `drives.root_folder_id`.
/// Correct for D0-provisioned default drives; secondary drives keep
/// their original root name. The `_username` arg stays for now so the
/// upcoming dynamic lookup has a way to identify the caller.
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
original_path
.strip_prefix("Personal/")
.unwrap_or(original_path)
/// Delegates to `webdav_handler::strip_chroot_prefix` — chroot-aware,
/// multi-segment safe, and returns `None` when the item is outside
/// the chroot (e.g. a trashed item in another drive the caller is a
/// member of). The `_username` arg stays for signature stability
/// with call sites that thread it; the strip itself no longer uses it.
///
/// See the doc on `strip_chroot_prefix` for the AuthZ caveat — this
/// is a display helper, not an ownership check.
fn strip_home_prefix<'a>(
original_path: &'a str,
_username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
) -> Option<&'a str> {
crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(chroot, original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
@@ -280,10 +293,16 @@ use crate::application::services::nextcloud_file_id_service::NextcloudFileIdServ
use std::collections::HashMap;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
///
/// `chroot` scopes the response — items whose original path is outside
/// the chroot (other drives the caller is a member of) are dropped
/// silently. NC's trashbin surface is single-drive from the client's
/// perspective; cross-drive items remain reachable via REST.
async fn write_trashbin_multistatus<W: std::io::Write>(
writer: W,
items: &[TrashedItemDto],
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
) -> Result<(), String> {
let mut xml = Writer::new(writer);
@@ -315,9 +334,24 @@ async fn write_trashbin_multistatus<W: std::io::Write>(
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
id_map.extend(folder_id_map);
// Individual trashed items.
// Individual trashed items — skip those whose original path is
// outside the chroot (other-drive trash reachable via REST).
for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
if crate::interfaces::nextcloud::webdav_handler::strip_chroot_prefix(
chroot,
&item.original_path,
)
.is_none()
{
tracing::debug!(
target: "oxicloud::nc",
"trashbin PROPFIND: dropping cross-chroot item '{}' at '{}'",
item.id,
item.original_path,
);
continue;
}
write_trash_item_response(&mut xml, item, username, chroot, file_id_svc, &id_map)?;
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
@@ -363,10 +397,18 @@ fn write_trash_root_response<W: std::io::Write>(
}
/// Write a single trashed item as a `<d:response>` element.
///
/// Caller is expected to have already verified the item is inside
/// `chroot` — see the guard in `write_trashbin_multistatus`. This
/// function trusts the invariant and expects `strip_home_prefix` to
/// return `Some(_)`; if it ever returns `None` (chroot drift between
/// the guard and the emit, defensive-only), the original-location
/// falls back to an empty string.
fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>,
item: &TrashedItemDto,
username: &str,
chroot: &crate::application::dtos::folder_dto::FolderDto,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
) -> Result<(), String> {
@@ -427,7 +469,7 @@ fn write_trash_item_response<W: std::io::Write>(
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
// nc:trashbin-original-location
let original_location = strip_home_prefix(&item.original_path, username);
let original_location = strip_home_prefix(&item.original_path, username, chroot).unwrap_or("");
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
// nc:trashbin-deletion-time
+11 -8
View File
@@ -367,12 +367,14 @@ async fn handle_assemble(
let chroot = session.require_chroot()?;
let drive_id = chroot.drive_id;
// TODO(D1): read the caller's default-drive root folder name from
// `drives.root_folder_id` instead of hardcoding "Personal". The
// constant is correct for every default personal drive provisioned
// by the D0 lifecycle hook, but secondary drives (M2 backfill from
// SQL-created sibling root folders) keep their original name.
let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/'));
// Route through `nc_to_internal_path(chroot, …)` so the write
// lands under the caller's actual default-drive root (not the
// literal "Personal" folder). Post-D3 chroot resolution puts the
// correct FolderDto — including the drive's real root name — on
// the NcSession; secondary drives with SQL-provisioned sibling
// root names now work.
let internal_path =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &dest_subpath)?;
let filename = filename_from_path(&dest_subpath).to_string();
let ingested = ingest_stream_to_cas(
@@ -393,7 +395,7 @@ async fn handle_assemble(
let etag: Option<String> = if existing.is_ok() {
let dto = upload_service
.update_file_streaming(
.update_file_streaming_with_perms(
&internal_path,
drive_id,
ingested.stored(),
@@ -412,7 +414,8 @@ async fn handle_assemble(
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/'));
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;
+505 -211
View File
@@ -17,6 +17,7 @@ use crate::application::adapters::webdav_adapter::{
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
};
use crate::application::dtos::pagination::PaginationRequestDto;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
@@ -25,6 +26,8 @@ use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::common::mime_detect::filename_from_path;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
use crate::interfaces::api::handlers::webdav_handler::{
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
@@ -82,6 +85,78 @@ pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result<String,
Ok(format!("{}/{}", chroot.path, subpath))
}
/// Strip the caller's chroot prefix from an internal
/// `storage.folders.path` so the DAV subpath surfaced to the NC
/// client is chroot-relative. Handles multi-segment chroots
/// correctly (e.g. a future `"Personal/folderA/subfolder"` chroot
/// against an item at `"Personal/folderA/subfolder/file.txt"`
/// returns `"file.txt"`, not `"folderA/subfolder/file.txt"`).
///
/// Returns `None` when the path is NOT inside the chroot. Callers
/// should skip such items from the response (they belong to a
/// different drive or the caller's read scope has drifted) — do NOT
/// fall back to a naive segment strip, which would surface a
/// misleading display path.
///
/// **Defensive but not an AuthZ boundary.** Every current caller
/// reaches items through a `_with_perms` method upstream that
/// already gates Read; this helper is the display-string layer
/// that also serves as a "does this item belong under the chroot"
/// sanity check.
pub fn strip_chroot_prefix<'a>(chroot: &FolderDto, internal_path: &'a str) -> Option<&'a str> {
// Normalize both sides: `FolderDto.path` comes from
// `StoragePath::to_string()` which prepends a leading `/`
// (e.g. `"/Personal"`), but DB-side paths coming from
// `storage.folders.path` (composed by the `compute_folder_path`
// trigger) never have a leading slash. Trim both so `"/Personal"`
// vs `"Personal/g9-tree"` matches the intended prefix.
let root = chroot.path.trim_matches('/');
if root.is_empty() {
// Guard against a mis-set chroot with an empty root path —
// stripping "" from anything would return the whole path.
return None;
}
let path = internal_path.trim_start_matches('/');
let rest = path.strip_prefix(root)?;
// Reject a partial prefix match — a chroot of "Personal" must
// not match an item at "PersonalSecrets/…".
match rest.strip_prefix('/') {
Some(subpath) => Some(subpath),
// Item path equals the chroot exactly — the chroot itself
// (i.e. a folder) is not a legitimate response item, so
// treat as an empty subpath.
None if rest.is_empty() => Some(""),
None => None,
}
}
/// Naive fallback: strip the first path segment from an internal
/// `storage.folders.path`. Post-D0 every path starts with its drive's
/// root folder name (single segment), so for the current schema this
/// gives the drive-relative subpath.
///
/// Use this ONLY when the caller doesn't have a chroot in scope
/// (e.g. OCS unified search, whose results legitimately span every
/// drive the caller has Read on — no single chroot covers them all).
/// Every path-scoped NC handler that DOES have `session` in scope
/// should prefer [`strip_chroot_prefix`] — it validates the item
/// belongs under the chroot instead of trusting the schema
/// invariant, and it survives a future composed chroot like
/// `"Personal/folderA/subfolder"`.
///
/// **Not an AuthZ boundary.** Same caveat as `strip_chroot_prefix`
/// — AuthZ is enforced upstream via `_with_perms` methods; this
/// helper only formats display strings.
///
/// Returns `""` when the path is a single segment (i.e. the drive
/// root itself, which is never a legitimate item target).
pub fn strip_drive_root_segment(internal_path: &str) -> &str {
match internal_path.split_once('/') {
Some((_root, rest)) => rest,
None => "",
}
}
/// Build the Nextcloud DAV href for a **collection** (folder). Always
/// terminates with `/` — RFC 4918 §5.2 requires collection URLs to end
/// in a slash, and the Nextcloud desktop client strictly enforces this
@@ -235,76 +310,124 @@ async fn handle_propfind(
let internal_path = nc_to_internal_path(chroot, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Try to resolve as folder first.
let folder_result = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await;
if let Ok(folder) = folder_result {
// It's a folder — stream the multistatus: children are fetched in
// pages and serialized chunk by chunk, so memory stays O(batch)
// regardless of how many entries the folder holds.
//
// Multi-drive POC: the hrefs in the response must echo the
// wire form (`{user}~{drive}`) the client requested, so we
// pass `url_user` (not `user.username`) as the streaming
// function's username arg. Refining the owner-id usages
// back to the canonical username is deferred to the
// NcSession commit.
return Ok(build_nc_streaming_propfind(
state.clone(),
folder,
depth,
user.id,
url_user.to_string(),
subpath.to_string(),
));
}
// Not a folder — try as a file.
let file_result = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await;
if let Ok(file) = file_result {
// Batch-check favorites for this single file.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let dead_props = file_dead_props(&state, &file).await;
let mut buf = Vec::new();
write_nc_file_multistatus(
&mut buf,
&file,
url_user,
&user.username,
subpath,
file_id_svc,
(&favorite_ids, &dead_props),
)
// Single-query path resolution (drive-scoped) — same shared
// resolver as native `/webdav/…`. Post-D7 the resolver is not
// owner-scoped, so we `authz.require(Read, …)` on the returned
// resource explicitly before emitting the multistatus.
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
.ok_or_else(|| AppError::not_found("Resource not found"))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
match resolved {
ResolvedResource::Folder(folder) => {
let folder_uuid = Uuid::parse_str(&folder.id)
.map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(folder_uuid),
)
.await?;
// It's a folder — stream the multistatus: children are fetched in
// pages and serialized chunk by chunk, so memory stays O(batch)
// regardless of how many entries the folder holds.
//
// Multi-drive POC: the hrefs in the response must echo the
// wire form (`{user}~{drive}`) the client requested, so we
// pass `url_user` (not `user.username`) as the streaming
// function's username arg. Refining the owner-id usages
// back to the canonical username is deferred to the
// NcSession commit.
Ok(build_nc_streaming_propfind(
state.clone(),
folder,
depth,
user.id,
url_user.to_string(),
subpath.to_string(),
))
}
ResolvedResource::File(file) => {
let file_uuid =
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await?;
// Batch-check favorites for this single file.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let dead_props = file_dead_props(&state, &file).await;
let mut buf = Vec::new();
write_nc_file_multistatus(
&mut buf,
&file,
url_user,
&user.username,
subpath,
file_id_svc,
(&favorite_ids, &dead_props),
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
}
}
Err(AppError::not_found("Resource not found"))
/// NC-surface path resolution: try the single-query resolver, fall back
/// to the double-query `get_*_by_path` pair when the resolver isn't
/// configured. Same shape and drive-scope as the native surface —
/// callers `authz.require(…)` on the returned resource.
async fn nc_resolve_or_fallback(
state: &Arc<AppState>,
internal_path: &str,
drive_id: Uuid,
) -> Option<ResolvedResource> {
if let Some(resolver) = &state.path_resolver
&& let Ok(r) = resolver
.resolve_path_in_drive(internal_path, drive_id)
.await
{
return Some(r);
}
let folder_service = &state.applications.folder_service;
if let Ok(folder) = folder_service
.get_folder_by_path(internal_path, drive_id)
.await
{
return Some(ResolvedResource::Folder(folder));
}
let file_service = &state.applications.file_retrieval_service;
if let Ok(file) = file_service.get_file_by_path(internal_path, drive_id).await {
return Some(ResolvedResource::File(file));
}
None
}
// ──────────────────── GET ────────────────────
@@ -325,27 +448,50 @@ async fn handle_get(
.unwrap());
}
let user = &session.user;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder first (NC clients use GET as existence check)
if folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
// Single-query path resolution. NC clients use GET on a folder as
// an existence probe (returns 200 empty); file GETs serve content.
// Post-D7 the resolver is drive-scoped, so both branches
// `authz.require(Read, …)` before responding.
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
.ok_or_else(|| AppError::not_found("File not found"))?;
let file = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("File not found"))?;
let file = match resolved {
ResolvedResource::Folder(folder) => {
let folder_uuid =
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(folder_uuid),
)
.await?;
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
ResolvedResource::File(f) => {
let file_uuid =
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await?;
f
}
};
// ETag comes from `FileDto::etag` (populated from `File::etag()`
// in the `From<File>` impl) — single source of truth, so GET,
@@ -412,27 +558,47 @@ async fn handle_head(
.unwrap());
}
let user = &session.user;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder (NC clients use HEAD as existence check)
if folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
// Single-query path resolution. Both branches `authz.require(Read, …)`
// on the returned resource before responding.
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
.ok_or_else(|| AppError::not_found("File not found"))?;
let file = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
.map_err(|_| AppError::not_found("File not found"))?;
let file = match resolved {
ResolvedResource::Folder(folder) => {
let folder_uuid =
Uuid::parse_str(&folder.id).map_err(|_| AppError::not_found("File not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(folder_uuid),
)
.await?;
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
ResolvedResource::File(f) => {
let file_uuid =
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("File not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await?;
f
}
};
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
@@ -496,25 +662,38 @@ async fn handle_proppatch(
// silently no-opping on a nonexistent path would be a foot-gun —
// matches the native `/webdav/` handler's contract.
let internal_path = nc_to_internal_path(chroot, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let (resource_ref, item_id, item_type, is_collection) = if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
let id = Uuid::parse_str(&file.id)
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
(ResourceRef::File(id), file.id, "file", false)
} else if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
{
let id = Uuid::parse_str(&folder.id)
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
(ResourceRef::Folder(id), folder.id, "folder", true)
} else {
return Err(AppError::not_found("Resource not found"));
};
// Single-query path resolution — PROPPATCH may target either a
// folder or a file. Post-D7 the resolver is drive-scoped, so we
// `authz.require(Read, …)` on the returned resource before
// reading its type. The favorite mutation below itself doesn't
// require additional authz (favorites are per-user; the caller can
// favourite any resource they can see).
let (resource_ref, item_id, item_type, is_collection) =
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
Some(ResolvedResource::File(file)) => {
let id = Uuid::parse_str(&file.id)
.map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
.await?;
(ResourceRef::File(id), file.id, "file", false)
}
Some(ResolvedResource::Folder(folder)) => {
let id = Uuid::parse_str(&folder.id)
.map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(id),
)
.await?;
(ResourceRef::Folder(id), folder.id, "folder", true)
}
None => return Err(AppError::not_found("Resource not found")),
};
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
@@ -745,7 +924,7 @@ async fn handle_put(
// Single streaming path — handles both update and create internally,
// swapping the file row onto the already-ingested blob.
let stored = upload_service
.update_file_streaming(
.update_file_streaming_with_perms(
&internal_path,
chroot.drive_id,
ingested.stored(),
@@ -865,74 +1044,79 @@ async fn handle_delete(
let chroot = session.require_chroot()?;
let internal_path = nc_to_internal_path(chroot, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Prefer soft-delete (move to trash) when trash service is available.
// This is what Nextcloud clients expect — items appear in the trashbin.
if let Some(trash_svc) = state.trash_service.as_ref() {
if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
.await
{
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
return Err(AppError::not_found("Resource not found"));
}
// Fallback: hard delete when trash service is not available.
let file_mgmt = &state.applications.file_management_service;
if let Ok(folder) = folder_service
.get_folder_by_path(&internal_path, chroot.drive_id)
// Single-query path resolution. Post-D7 the resolver is drive-scoped,
// so we `authz.require(Read, …)` on the returned resource before
// dispatching. The actual delete is authorised as `Permission::Delete`
// inside the downstream service (`trash_svc.move_to_trash` /
// `delete_folder_with_perms` / `delete_file_with_perms` all take
// `caller_id`).
let resolved = nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id)
.await
{
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
.ok_or_else(|| AppError::not_found("Resource not found"))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
match resolved {
ResolvedResource::Folder(folder) => {
let folder_uuid = Uuid::parse_str(&folder.id)
.map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(folder_uuid),
)
.await?;
if let Some(trash_svc) = state.trash_service.as_ref() {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to trash folder: {}", e))
})?;
} else {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete folder: {}", e))
})?;
}
}
ResolvedResource::File(file) => {
let file_uuid =
Uuid::parse_str(&file.id).map_err(|_| AppError::not_found("Resource not found"))?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await?;
if let Some(trash_svc) = state.trash_service.as_ref() {
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to trash file: {}", e))
})?;
} else {
let file_mgmt = &state.applications.file_management_service;
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to delete file: {}", e))
})?;
}
}
}
if let Ok(file) = file_service
.get_file_by_path(&internal_path, chroot.drive_id)
.await
{
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
Err(AppError::not_found("Resource not found"))
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
}
// ──────────────────── MOVE ────────────────────
@@ -980,21 +1164,18 @@ async fn handle_move(
let file_mgmt = &state.applications.file_management_service;
// ── Destination-collision precondition (RFC 4918 §9.9.4) ──────────
// Resolved once up-front so the file/folder branches below don't
// each have to repeat the check. `dest_existed_before` becomes the
// 204-vs-201 selector at response time.
// Single-query probe via the shared resolver — the destination is
// either a file, a folder, or absent. `dest_existed_before`
// becomes the 204-vs-201 selector at response time. Post-D7 the
// resolver is drive-scoped; on the overwrite path we
// `authz.require(Read, …)` explicitly and the downstream delete
// enforces `Permission::Delete`.
let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?;
let dest_existing_file = file_service
.get_file_by_path(&dest_internal_precheck, chroot.drive_id)
.await
.ok();
let dest_existing_folder = folder_service
.get_folder_by_path(&dest_internal_precheck, chroot.drive_id)
.await
.ok();
let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some();
let dest_existing =
nc_resolve_or_fallback(&state, &dest_internal_precheck, chroot.drive_id).await;
let dest_existed_before = dest_existing.is_some();
if dest_existed_before {
if let Some(existing) = dest_existing {
if overwrite_forbidden {
return Ok(Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
@@ -1005,23 +1186,51 @@ async fn handle_move(
// then proceed with the move. Trashing is fine: per RFC the source
// resource appears at the destination URI; what happens to the
// overwritten one is up to the server.
if let Some(existing_file) = &dest_existing_file {
file_mgmt
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to overwrite destination file: {}", e))
match existing {
ResolvedResource::File(existing_file) => {
let file_uuid = Uuid::parse_str(&existing_file.id).map_err(|_| {
AppError::internal_error("Failed to overwrite destination file")
})?;
} else if let Some(existing_folder) = &dest_existing_folder {
folder_service
.delete_folder_with_perms(&existing_folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to overwrite destination folder: {}",
e
))
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::File(file_uuid),
)
.await?;
file_mgmt
.delete_and_cleanup_with_perms(&existing_file.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to overwrite destination file: {}",
e
))
})?;
}
ResolvedResource::Folder(existing_folder) => {
let folder_uuid = Uuid::parse_str(&existing_folder.id).map_err(|_| {
AppError::internal_error("Failed to overwrite destination folder")
})?;
state
.authorization
.require(
Subject::User(user.id),
Permission::Read,
Resource::Folder(folder_uuid),
)
.await?;
folder_service
.delete_folder_with_perms(&existing_folder.id, user.id)
.await
.map_err(|e| {
AppError::internal_error(format!(
"Failed to overwrite destination folder: {}",
e
))
})?;
}
}
}
@@ -1681,7 +1890,6 @@ mod tests {
name: path.rsplit('/').next().unwrap_or("").to_string(),
path: path.to_string(),
parent_id: None,
owner_id: None,
// Test stub — path mapper doesn't read drive_id.
drive_id: uuid::Uuid::nil(),
created_at: 0,
@@ -1744,6 +1952,92 @@ mod tests {
);
}
// ── strip_chroot_prefix ──
//
// Regression guard for the "chroot.path has a leading slash from
// StoragePath::to_string() but DB-side original_path doesn't" trap
// that broke the NC trashbin PROPFIND after Round 2 rolled out.
// Also pins the composed-chroot behaviour Ed asked about.
#[test]
fn strip_chroot_prefix_default_drive_root() {
// FolderDto.path carries a leading slash (StoragePath Display);
// DB paths do not. Both must normalise to the same prefix.
let chroot = stub_folder("/Personal");
assert_eq!(
strip_chroot_prefix(&chroot, "Personal/g9-tree"),
Some("g9-tree")
);
}
#[test]
fn strip_chroot_prefix_deep_path() {
let chroot = stub_folder("/Personal");
assert_eq!(
strip_chroot_prefix(&chroot, "Personal/inner/deep.txt"),
Some("inner/deep.txt")
);
}
#[test]
fn strip_chroot_prefix_out_of_chroot_returns_none() {
// Items on a different drive (whose root isn't "Personal")
// must NOT be surfaced under the caller's chroot.
let chroot = stub_folder("/Personal");
assert_eq!(strip_chroot_prefix(&chroot, "team-drive/report.pdf"), None);
}
#[test]
fn strip_chroot_prefix_rejects_partial_prefix_match() {
// "Personal" is a prefix substring of "PersonalSecrets" but
// NOT a path-segment prefix — must reject.
let chroot = stub_folder("/Personal");
assert_eq!(
strip_chroot_prefix(&chroot, "PersonalSecrets/foo.txt"),
None
);
}
#[test]
fn strip_chroot_prefix_composed_chroot() {
// The future composed-chroot case Ed raised: chroot points at
// a subfolder inside a drive. The strip must remove the ENTIRE
// composed prefix, not just the first segment.
let chroot = stub_folder("/Personal/folderA/subfolder");
assert_eq!(
strip_chroot_prefix(&chroot, "Personal/folderA/subfolder/foo.txt"),
Some("foo.txt")
);
}
#[test]
fn strip_chroot_prefix_composed_chroot_sibling_leaks_blocked() {
// Same composed chroot, but the item lives in a sibling
// subfolder — must be rejected, not naively strip 1 segment.
let chroot = stub_folder("/Personal/folderA/subfolder");
assert_eq!(
strip_chroot_prefix(&chroot, "Personal/folderA/other/foo.txt"),
None
);
}
#[test]
fn strip_chroot_prefix_chroot_root_itself() {
// Item path equals chroot exactly — legitimate for a PROPFIND
// Depth:0 on the chroot itself. Subpath is empty.
let chroot = stub_folder("/Personal");
assert_eq!(strip_chroot_prefix(&chroot, "Personal"), Some(""));
}
#[test]
fn strip_chroot_prefix_empty_chroot_returns_none() {
// Defensive: a mis-set chroot with an empty path must not
// strip anything (stripping "" from any path would return
// the whole path — a silent leak).
let chroot = stub_folder("/");
assert_eq!(strip_chroot_prefix(&chroot, "Personal/foo.txt"), None);
}
// ── nc_href ──
#[test]