feat(drive): start implementation of drive

- add storage.drives
    - prepare migration phase
    - add created_by and updated_by on storage.folders
This commit is contained in:
Edouard Vanbelle
2026-06-18 13:29:41 +02:00
parent 77545aee05
commit eab7a609b9
43 changed files with 2434 additions and 154 deletions
+70
View File
@@ -0,0 +1,70 @@
//! DTOs for the `/api/drives` endpoint surface.
//!
//! D0 surfaces only the read-only list. Mutating endpoints
//! (`POST /api/drives` for shared-drive creation, `PATCH` for rename /
//! policy edits, membership APIs) land in D2/D3.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domain::entities::drive::{Drive, DriveKind};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DriveKindDto {
Personal,
Shared,
}
impl From<DriveKind> for DriveKindDto {
fn from(k: DriveKind) -> Self {
match k {
DriveKind::Personal => DriveKindDto::Personal,
DriveKind::Shared => DriveKindDto::Shared,
}
}
}
/// One row in `GET /api/drives` — a drive the caller can read.
///
/// `default_for_user` is `Some(<caller_id>)` for the caller's default
/// Personal drive and `None` otherwise. The picker UI uses this to put
/// the default at the top of the list and mark it as "your home".
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct DriveDto {
pub id: Uuid,
pub name: String,
pub kind: DriveKindDto,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_for_user: Option<Uuid>,
/// Storage cap in bytes. `None` means "no quota" (admin override /
/// future system drives).
#[serde(skip_serializing_if = "Option::is_none")]
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally in D4;
/// on D0 this reflects the backfilled baseline.
pub used_bytes: i64,
/// Capability-flag bag — clients render UI affordances based on
/// known keys (`forbid_public_links`, `include_in_photo_index`,
/// `forbid_music_index`, …). Unknown keys preserved verbatim.
pub policies: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<Drive> for DriveDto {
fn from(d: Drive) -> Self {
Self {
id: d.id,
name: d.name,
kind: d.kind.into(),
default_for_user: d.default_for_user,
quota_bytes: d.quota_bytes,
used_bytes: d.used_bytes,
policies: d.policies,
created_at: d.created_at,
updated_at: d.updated_at,
}
}
}
+3
View File
@@ -58,6 +58,7 @@ impl From<Subject> for SubjectDto {
pub enum ResourceTypeDto {
Folder,
File,
Drive,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -72,6 +73,7 @@ impl From<ResourceDto> for Resource {
match dto.kind {
ResourceTypeDto::Folder => Resource::Folder(dto.id),
ResourceTypeDto::File => Resource::File(dto.id),
ResourceTypeDto::Drive => Resource::Drive(dto.id),
}
}
}
@@ -81,6 +83,7 @@ impl From<Resource> for ResourceDto {
let (kind, id) = match r {
Resource::Folder(id) => (ResourceTypeDto::Folder, id),
Resource::File(id) => (ResourceTypeDto::File, id),
Resource::Drive(id) => (ResourceTypeDto::Drive, id),
};
ResourceDto { kind, id }
}
+1
View File
@@ -6,6 +6,7 @@ pub mod calendar_dto;
pub mod contact_dto;
pub mod device_auth_dto;
pub mod display_helpers;
pub mod drive_dto;
pub mod favorites_dto;
pub mod file_dto;
pub mod folder_dto;
@@ -61,6 +61,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
+17 -5
View File
@@ -35,14 +35,26 @@ pub struct ContentHitDto {
/// holds an `Option<Arc<dyn ContentIndexPort>>` (the feature is toggleable).
#[async_trait]
pub trait ContentIndexPort: Send + Sync + 'static {
/// Search indexed file names + content for `query`, scoped to `user_id`.
/// Search indexed file names + content for `query`, scoped to the drives
/// the caller can read.
///
/// Returns up to `limit` hits sorted by BM25 score descending. Matching is
/// tokenized (not substring): exact terms, typo-tolerant fuzzy terms
/// (edit distance 1) and prefix expansion on the last query token.
/// The filter is applied as an `Occur::Must` set-membership clause on
/// the `drive_id` field — Tantivy's collector only ever sees documents
/// in one of the accessible drives, so counts, snippets, and
/// pagination cursors all reflect the filtered set (no anti-
/// enumeration leak — see `docs/plan/drive.md` §11). Pass the
/// caller's full accessible-drive set; the engine already expands
/// group-mediated drive grants before this is called.
///
/// An empty `accessible_drive_ids` returns no hits — same semantics
/// as "no drives, no search" (e.g. external users with grants only).
/// Returns up to `limit` hits sorted by BM25 score descending.
/// Matching is tokenized (not substring): exact terms, typo-tolerant
/// fuzzy terms (edit distance 1) and prefix expansion on the last
/// query token.
async fn search_content(
&self,
user_id: Uuid,
accessible_drive_ids: &[Uuid],
query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError>;
+3
View File
@@ -85,9 +85,12 @@ pub trait FolderUseCase: Send + Sync + 'static {
async fn delete_folder_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user during registration.
/// `drive_id` is the user's personal drive; the wrapper folder stays
/// during the D0 dual-write window (retires in M2b later).
async fn create_home_folder(
&self,
user_id: Uuid,
drive_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError>;
+134 -26
View File
@@ -167,6 +167,7 @@ impl FolderService {
async fn create_home_folder(
&self,
_user_id: Uuid,
_drive_id: Uuid,
_name: String,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::empty())
@@ -235,14 +236,18 @@ impl FolderUseCase for FolderService {
}
/// Creates a root-level home folder for a user during registration.
/// `drive_id` is the user's personal drive — the wrapper folder lives
/// inside it during the D0 dual-write window (M2b retires the wrapper
/// later).
async fn create_home_folder(
&self,
user_id: Uuid,
drive_id: Uuid,
name: String,
) -> Result<FolderDto, DomainError> {
let folder = self
.folder_storage
.create_home_folder(user_id, name)
.create_home_folder(user_id, drive_id, name)
.await
.map_err(|e| {
DomainError::internal_error(
@@ -632,6 +637,7 @@ impl FolderService {
pub async fn ensure_home_folder(
&self,
user_id: Uuid,
drive_id: Uuid,
username: Option<&str>,
) -> Result<bool, DomainError> {
let existing = self
@@ -653,7 +659,7 @@ impl FolderService {
None => format!("My Folder - {}", user_id),
};
self.folder_storage
.create_home_folder(user_id, folder_name.clone())
.create_home_folder(user_id, drive_id, folder_name.clone())
.await
.map_err(|e| {
DomainError::internal_error(
@@ -743,36 +749,140 @@ use async_trait::async_trait;
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
use crate::domain::entities::user::User;
/// Lifecycle hook: provisions and (in PR 4) deprovisions a user's home folder.
pub struct HomeFolderLifecycleHook {
/// Lifecycle hook: provisions a user's default Personal drive at first
/// login (replaces the legacy `My Folder - <username>` wrapper as of D0).
///
/// Two writes happen on first provisioning:
/// 1. A row in `storage.drives` with `kind='personal'`,
/// `default_for_user=<uid>`, and the user's quota carried over from
/// `auth.users.storage_quota_bytes`.
/// 2. An Owner role grant in `storage.role_grants` so the user can
/// read/write/manage their own drive (the engine's owner short-
/// circuit applies to folders/files but not drives — see
/// `pg_acl_engine::check_inner` D0-6 rewrite).
///
/// Both writes are idempotent: `find_default_for_user` short-circuits
/// when the drive already exists; `set_role` is an UPSERT that no-ops
/// when the Owner row is already present.
pub struct PersonalDriveLifecycleHook {
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
folder_service: Arc<FolderService>,
// The `AuthorizationEngine` trait isn't `dyn`-compatible (native
// async-fn-in-trait methods are not object-safe), so we hold the
// concrete engine. This matches the convention already used by
// `AppState.authorization`.
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
}
impl HomeFolderLifecycleHook {
pub fn new(folder_service: Arc<FolderService>) -> Self {
Self { folder_service }
impl PersonalDriveLifecycleHook {
pub fn new(
drive_repo: Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>,
folder_service: Arc<FolderService>,
authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
) -> Self {
Self {
drive_repo,
folder_service,
authorization,
}
}
/// Idempotent provisioning shared by `on_user_created` and
/// `on_user_login`. External users are skipped per tip #2 in the
/// trait docstring.
/// trait docstring — they have no resources of their own, only
/// grants on other users' resources.
async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> {
use crate::domain::repositories::drive_repository::{
CreatePersonalDriveInput, DriveRepositoryError,
};
use crate::domain::services::authorization::{Resource, Role, Subject};
if user.is_external() {
return Ok(());
}
// `ensure_home_folder` handles the "does the user already have a
// root folder?" check internally and is a no-op if so.
self.folder_service
.ensure_home_folder(user.id(), user.username())
// Idempotent shortcut: if the user already has a default drive,
// nothing to do. Covers re-runs from `on_user_login` plus the
// case where `on_user_created` ran successfully but logged in
// before reaching the role_grant step (next-login retry lands
// here and finds the drive, completing the role_grant if missing).
match self.drive_repo.find_default_for_user(user.id()).await {
Ok(drive) => {
// Drive exists; ensure the Owner role_grant is in
// place too. `set_role` is an UPSERT — safe to re-run.
self.authorization
.set_role(
user.id(),
Subject::User(user.id()),
Role::Owner,
Resource::Drive(drive.id),
None,
)
.await
.map(|_grant| ())?;
return Ok(());
}
Err(DriveRepositoryError::NotFound(_)) => { /* fall through to create */ }
Err(e) => {
return Err(DomainError::internal_error(
"PersonalDriveHook",
format!("find_default lookup: {e}"),
));
}
}
// Create the drive.
let drive = self
.drive_repo
.create_personal(CreatePersonalDriveInput {
name: "Personal".to_owned(),
owner_id: user.id(),
is_default: true,
quota_bytes: Some(user.storage_quota_bytes()),
})
.await
.map(|_created| ())
.map_err(|e| {
DomainError::internal_error("PersonalDriveHook", format!("create_personal: {e}"))
})?;
// Stamp the Owner role_grant.
self.authorization
.set_role(
user.id(),
Subject::User(user.id()),
Role::Owner,
Resource::Drive(drive.id),
None,
)
.await
.map(|_grant| ())?;
// Provision the wrapper `My Folder - <username>` folder under
// the new drive. The wrapper is retained through the D0 dual-
// write window (M2b retires it later); without it, existing API
// surfaces that assume `GET /api/folders` returns a root folder
// (the UI listing, the WebDAV resolver, the Hurl baselines) all
// break for newly-provisioned users.
self.folder_service
.ensure_home_folder(user.id(), drive.id, user.username())
.await
.map(|_created| ())?;
tracing::info!(
target: "user_lifecycle",
hook = "personal_drive",
user_id = %user.id(),
drive_id = %drive.id,
"Default personal drive + wrapper folder provisioned"
);
Ok(())
}
}
#[async_trait]
impl UserLifecycleHook for HomeFolderLifecycleHook {
impl UserLifecycleHook for PersonalDriveLifecycleHook {
fn name(&self) -> &'static str {
"home_folder"
"personal_drive"
}
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
@@ -787,7 +897,7 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
}
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
// Folders don't react to logout. Explicit no-op per the
// Drives don't react to logout. Explicit no-op per the
// "no defaults" convention.
Ok(())
}
@@ -798,24 +908,22 @@ impl UserLifecycleHook for HomeFolderLifecycleHook {
mode: DeletionMode,
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), DomainError> {
// For both DeletionMode variants today the FK CASCADE on
// `storage.folders.user_id` (and downstream files/blobs)
// removes the home folder + contents when the user row goes.
// `storage.drives.default_for_user` has ON DELETE CASCADE
// referencing `auth.users(id)`, and `storage.folders.drive_id`
// / `storage.files.drive_id` both have ON DELETE CASCADE on
// `storage.drives(id)` (M3). So a user delete cascades:
// user → drive → folders → files in one transaction.
//
// The hook emits a per-mode tracing event so audit can tell
// AdminDelete (currently recoverable only via DB-level rollback
// before commit) from GdprPurge (no sweeper exists yet — the
// variant is reserved for a future PR that adds retention).
//
// The `tx` is provided per the trait contract but unused here:
// emitting a tracing event doesn't require DB access. Future
// policy (trash with retention) would write to `storage.trash`
// inside this same tx.
tracing::info!(
target: "user_lifecycle",
hook = "home_folder",
hook = "personal_drive",
user_id = %user.id(),
mode = ?mode,
"Home folder will be removed via FK CASCADE on user delete"
"Personal drive (and tree) will be removed via FK CASCADE on user delete"
);
Ok(())
}
@@ -307,6 +307,23 @@ 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(_) => {
tracing::info!(
target: "audit",
event = "magic_link.invitation_suppressed",
reason = "drive_resource_unsupported",
user_id = %recipient.id(),
"📭 magic-link invitation suppressed: drive resources aren't invitable until D2",
);
return Ok(());
}
};
// Invitation tokens are cross-device by design (recipient has
// no prior browser context with the server) — no challenge
@@ -329,6 +346,11 @@ impl MagicLinkInviteService {
let kind_key = match resource {
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",
};
// PR C: render in the recipient's preferred locale (set by UI
// switcher, OIDC JIT claim, or inviter inheritance at row
@@ -731,6 +753,15 @@ impl From<ResourceKind> for MagicLinkResourceKind {
match kind {
ResourceKind::Folder => Self::Folder,
ResourceKind::File => Self::File,
// Drives aren't a magic-link invite target in D0. The
// grant DTO surface accepts drive resources, but the
// grant_handler doesn't issue magic-links for them
// (drive sharing lands in D2). Mapping Drive → Folder
// gives a non-panicking fallback that would still emit a
// valid token shape if the path were ever reached; the
// runtime branches above suppress drive invitations
// before reaching this conversion.
ResourceKind::Drive => Self::Folder,
}
}
}
@@ -472,6 +472,11 @@ 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",
};
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
// Short form for the subject, long form (with email) for the
+92 -3
View File
@@ -50,6 +50,20 @@ pub struct SearchService {
/// matches; hits are hydrated and re-filtered through SQL before use.
content_index: Option<Arc<dyn ContentIndexPort>>,
/// Optional authorization engine — needed to resolve the caller's
/// accessible drive set before querying the content index, and to
/// re-verify each Tantivy hit against `engine.check(Read, File(id))`
/// as a defense-in-depth measure (catches index staleness and
/// per-file grants that the drive-only Tantivy filter misses; see
/// `docs/plan/drive.md` §11). `None` short-circuits the content
/// index (the cheapest safe degradation).
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
/// Optional drive repository — used in tandem with the authorization
/// engine to resolve the caller's accessible drives for the Tantivy
/// filter. `None` short-circuits the content index.
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
/// Lock-free concurrent cache with automatic TTL and LRU eviction (moka).
/// Values are `Arc<SearchResultsDto>` so cache insert/hit is a single
/// atomic ref-count increment (~1 ns) instead of cloning thousands of Strings.
@@ -151,6 +165,8 @@ impl SearchService {
file_repository: Arc<FileBlobReadRepository>,
folder_repository: Arc<FolderDbRepository>,
content_index: Option<Arc<dyn ContentIndexPort>>,
authorization: Option<Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>>,
drive_repo: Option<Arc<dyn crate::domain::repositories::drive_repository::DriveRepository>>,
cache_ttl: u64,
max_cache_size: usize,
) -> Self {
@@ -163,6 +179,8 @@ impl SearchService {
file_repository,
folder_repository,
content_index,
authorization,
drive_repo,
search_cache,
}
}
@@ -250,9 +268,18 @@ impl SearchService {
criteria: &SearchCriteriaDto,
user_id: Uuid,
) -> Vec<ContentHitDto> {
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::domain::services::authorization::{Permission, Resource, Subject};
let Some(index) = &self.content_index else {
return Vec::new();
};
let Some(authz) = &self.authorization else {
return Vec::new();
};
let Some(drive_repo) = &self.drive_repo else {
return Vec::new();
};
if criteria.offset != 0 {
return Vec::new();
}
@@ -265,16 +292,78 @@ impl SearchService {
return Vec::new();
};
match index
.search_content(user_id, query, CONTENT_HITS_LIMIT)
// Resolve the caller's accessible drive set via the engine
// (handles group-mediated drive grants) + the repo lookup.
let caller = Subject::User(user_id);
let (subject_types, subject_ids) = match authz.expand_subject_for_listing(caller).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!("Content-index: subject expansion failed — degrading to empty: {e}");
return Vec::new();
}
};
let accessible_drives: Vec<Uuid> = match drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => drives.into_iter().map(|d| d.id).collect(),
Err(e) => {
tracing::warn!("Content-index: drive lookup failed — degrading to empty: {e}");
return Vec::new();
}
};
// Tantivy filter (Must drive_id ∈ accessible_drives) handles
// the cross-drive isolation. Empty drive list short-circuits
// inside `search_content`.
let hits = match index
.search_content(&accessible_drives, query, CONTENT_HITS_LIMIT)
.await
{
Ok(hits) => hits,
Err(e) => {
tracing::warn!("Content-index lookup failed — returning name-only results: {e}");
Vec::new()
return Vec::new();
}
};
// Defense in depth: re-verify each hit through the engine.
// Catches two cases the drive_id filter can't:
// * Index staleness — the file just moved drives and the
// worker hasn't caught up.
// * Per-file grants — ReBAC can grant a single file inside a
// drive the caller doesn't otherwise have. The Tantivy
// filter is drive-only; this re-check restores per-file
// resolution.
// Failures degrade conservatively (drop the hit, log it) —
// never leak.
let mut verified = Vec::with_capacity(hits.len());
for hit in hits {
let file_uuid = match Uuid::parse_str(&hit.file_id) {
Ok(u) => u,
Err(_) => {
tracing::warn!("Content-index hit had non-UUID file_id: {}", hit.file_id);
continue;
}
};
match authz
.check(caller, Permission::Read, Resource::File(file_uuid))
.await
{
Ok(true) => verified.push(hit),
Ok(false) => {
tracing::debug!(
target: "oxicloud::search",
file_id = %file_uuid,
"dropping content-index hit: ReBAC denies Read after Tantivy filter",
);
}
Err(e) => {
tracing::warn!("ReBAC re-check failed for {file_uuid}: {e}");
}
}
}
verified
}
/// Merge content-index hits into the name-search result page:
@@ -1020,6 +1020,7 @@ mod tests {
async fn create_home_folder(
&self,
_user_id: Uuid,
_drive_id: Uuid,
_name: String,
) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
@@ -826,6 +826,7 @@ impl FolderRepository for MockFolderRepository {
async fn create_home_folder(
&self,
_user_id: Uuid,
_drive_id: Uuid,
_name: String,
) -> std::result::Result<Folder, DomainError> {
Ok(Folder::default())
+20 -1
View File
@@ -454,6 +454,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
trash_service: Option<Arc<TrashService>>,
authz: &Arc<PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
storage_usage: &Arc<StorageUsageService>,
content_index: Option<Arc<TantivyContentIndex>>,
plugin_dispatch: Option<
@@ -540,6 +541,8 @@ impl AppServiceFactory {
repos.file_read_repository.clone(),
repos.folder_repository.clone(),
content_index_port,
Some(authz.clone()),
Some(drive_repo.clone()),
300, // Cache TTL in seconds (5 minutes)
1000, // Maximum cache entries
)));
@@ -1050,6 +1053,12 @@ impl AppServiceFactory {
subject_group_repo.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.
let drive_repo =
Arc::new(crate::infrastructure::repositories::pg::DrivePgRepository::new(pool.clone()));
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
@@ -1078,6 +1087,7 @@ impl AppServiceFactory {
&repos,
trash_service.clone(),
&authorization,
&drive_repo,
&storage_usage,
content_index.as_ref().map(|(idx, _)| idx.clone()),
plugin_dispatch.clone(),
@@ -1190,8 +1200,10 @@ impl AppServiceFactory {
crate::application::services::user_lifecycle_service::AuditLifecycleHook,
))
.with_hook(Arc::new(
crate::application::services::folder_service::HomeFolderLifecycleHook::new(
crate::application::services::folder_service::PersonalDriveLifecycleHook::new(
drive_repo.clone(),
apps.folder_service_concrete.clone(),
authorization.clone(),
),
))
.with_hook(Arc::new(
@@ -1379,6 +1391,7 @@ impl AppServiceFactory {
webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
authorization,
drive_repo: drive_repo.clone(),
subject_group_service: Some(Arc::new(
crate::application::services::subject_group_service::SubjectGroupService::new(
subject_group_repo.clone(),
@@ -1839,6 +1852,12 @@ pub struct AppState {
/// an enum dispatcher or `Arc<dyn AuthorizationEngine>` (with
/// `async_trait` boxing).
pub authorization: Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
/// Drive entity repository — `GET /api/drives`, the personal-drive
/// lifecycle hook, and (post-D2) shared-drive creation flow all read
/// through this. Backing table is `storage.drives`; membership is
/// resolved through `role_grants` not a separate `drive_members`
/// table (see `docs/plan/drive.md` §3).
pub drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
/// ReBAC subject-group management (CRUD + membership). `None` when the
/// auth subsystem is not configured.
pub subject_group_service:
+2
View File
@@ -322,6 +322,7 @@ impl FolderRepository for StubFolderStoragePort {
async fn create_home_folder(
&self,
_user_id: Uuid,
_drive_id: Uuid,
_name: String,
) -> Result<Folder, DomainError> {
Ok(Folder::default())
@@ -459,6 +460,7 @@ impl FolderUseCase for StubFolderUseCase {
async fn create_home_folder(
&self,
_user_id: Uuid,
_drive_id: Uuid,
_name: String,
) -> Result<FolderDto, DomainError> {
Ok(FolderDto::default())
+126
View File
@@ -0,0 +1,126 @@
//! Drive — the top-level container that owns a tree of folders/files.
//!
//! Drives replaced the per-user `My Folder - <username>` wrapper at D0.
//! Every folder and file row carries a `drive_id` (added by D0's
//! migration); a drive is the natural unit of quota, sharing, and
//! lifecycle. Membership is expressed through `storage.role_grants` rows
//! with `resource_type='drive'` — there is no separate `drive_members`
//! table.
//!
//! ## Kinds
//!
//! Two kinds today; the discriminant is the `kind` column with a CHECK
//! constraint.
//!
//! - **`personal`** — single-user, single-owner. The owner is captured
//! by `default_for_user` (for the default Personal drive) or by an
//! Owner role_grant on a secondary personal drive. Personal drives
//! refuse `add_member`, `remove_member`, and `delete_drive` (when
//! it's the user's only or default drive). A user can have multiple
//! personal drives — one is marked default (`default_for_user =
//! <uid>`), the others are secondaries (`default_for_user = NULL`,
//! one Owner row in role_grants pinning them to the same user).
//!
//! - **`shared`** — multi-member, group-aware, full role roster
//! (viewer / commenter / contributor / editor / owner). Members
//! come from role_grants; group subjects expand transitively via
//! the existing `subject_groups` machinery. Last-owner protection
//! applies on member removal and drive deletion. Quota is set by
//! the drive owner (or admin); `used_bytes` tracks consumption.
//!
//! Future kinds (e.g. `system` for built-in scratch space) drop in by
//! extending the CHECK + the `DriveKind` enum.
//!
//! ## Policies
//!
//! `policies` is a JSONB bag carrying feature flags / capability toggles
//! that drive owners can flip without a schema change. Known keys live in
//! `docs/plan/drive.md` §8 and §15 (e.g. `forbid_public_links`,
//! `include_in_photo_index`, `forbid_music_index`). Unknown keys are
//! preserved by the application — the schema is intentionally permissive
//! so future capability flags can land without a migration.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Drive kind discriminant. Mirrors the `storage.drives.kind` CHECK
/// constraint values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DriveKind {
/// Single-owner storage compartment. Cannot have members added or
/// removed via the membership API; the owner is fixed for the drive's
/// lifetime.
Personal,
/// Multi-member drive supporting the full role roster. Membership is
/// open to admin/owner-driven changes through the membership API.
Shared,
}
impl DriveKind {
pub fn as_str(self) -> &'static str {
match self {
DriveKind::Personal => "personal",
DriveKind::Shared => "shared",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"personal" => Some(DriveKind::Personal),
"shared" => Some(DriveKind::Shared),
_ => None,
}
}
}
/// Domain entity for a row in `storage.drives`.
///
/// Field-level constraints are enforced at the SQL layer (CHECK on
/// `kind`, partial UNIQUE on `default_for_user`). The struct mirrors
/// the column set 1:1; behaviour beyond field access lives in
/// `DriveRepository` (D0-5) and `DriveService` (post-D0).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Drive {
/// Stable identifier. Generated server-side at creation.
pub id: Uuid,
/// Display name. Renameable by owners; defaults to "Personal" for
/// the user's default personal drive, or the original sibling-root
/// folder name for secondaries promoted by the D0 backfill.
pub name: String,
/// Discriminant — see [`DriveKind`].
pub kind: DriveKind,
/// Set iff this is the user's default personal drive (UNIQUE in SQL
/// via a partial index `WHERE default_for_user IS NOT NULL`). NULL
/// on shared drives and on secondary personal drives.
pub default_for_user: Option<Uuid>,
/// Soft cap on this drive's storage usage, in bytes. `None` means
/// "no quota" (rare; reserved for admin overrides). The default
/// initial quota for a fresh personal drive is taken from the
/// owner's `auth.users.storage_quota_bytes` at creation time (see
/// Open Question 2 in `docs/plan/drive.md`).
pub quota_bytes: Option<i64>,
/// Running total of bytes consumed. Maintained incrementally by
/// upload/delete paths in D4; on D0 still reflects the pre-Drive
/// per-user counters via the backfill.
pub used_bytes: i64,
/// Capability flags / feature toggles. Extensible JSONB — see
/// `docs/plan/drive.md` §8 and §15 for the known keys.
pub policies: serde_json::Value,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl Drive {
/// `true` for the user's default personal drive (the only drive for
/// which `default_for_user` is set to that user's id).
pub fn is_default_for(&self, user_id: Uuid) -> bool {
self.default_for_user == Some(user_id)
}
/// `true` if this drive is a personal drive of any kind (default or
/// secondary). Encapsulates the kind check at the call site.
pub fn is_personal(&self) -> bool {
matches!(self.kind, DriveKind::Personal)
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod calendar;
pub mod calendar_event;
pub mod contact;
pub mod device_code;
pub mod drive;
pub mod entity_errors;
pub mod face;
pub mod file;
+108
View File
@@ -0,0 +1,108 @@
//! Repository for [`Drive`] entities backed by `storage.drives`.
//!
//! Drives have no separate membership table — owner/editor/viewer
//! membership lives in `storage.role_grants` with
//! `resource_type='drive'`. That means **listing the drives a user can
//! reach goes through the role-grant query, not through this
//! repository**. This repo handles:
//!
//! * Creating a drive (used by the user-creation lifecycle hook and
//! by D3's shared-drive flow).
//! * Looking up a single drive by id (used by the engine's owner_of /
//! check paths, by `/api/drives/{id}`, and by the drive picker).
//! * Finding the caller's default drive (used by the Photos / Music
//! endpoints and by D1's redirect-from-`/` logic).
//!
//! Membership-flavoured queries (e.g. "list every drive user X can
//! read") live in `DriveListingService` (post-D0) which reads
//! `role_grants` and resolves the matching drive rows here.
use thiserror::Error;
use uuid::Uuid;
use crate::domain::entities::drive::{Drive, DriveKind};
#[derive(Debug, Error)]
pub enum DriveRepositoryError {
#[error("Drive not found: {0}")]
NotFound(String),
/// A user already has a default drive set — partial unique index on
/// `default_for_user` rejects a second one. Surfaces the constraint
/// explicitly so the lifecycle hook can no-op idempotently.
#[error("User already has a default drive: {0}")]
DefaultDriveAlreadyExists(String),
#[error("Invalid drive kind: {0}")]
InvalidKind(String),
#[error("Storage error: {0}")]
StorageError(String),
}
/// Input parameters for creating a new personal drive.
///
/// Shared drives land in D3 with their own creation surface
/// (`create_shared_drive`). For now D0 only mints personal drives —
/// either as the default for a fresh user (via the lifecycle hook) or
/// as a secondary promoted by the M2 backfill.
#[derive(Debug, Clone)]
pub struct CreatePersonalDriveInput {
/// Display name. The lifecycle hook passes `"Personal"`; the M2
/// backfill carries over the original sibling-root folder name for
/// secondaries.
pub name: String,
/// The owner. For personal drives the owner is exactly one user.
pub owner_id: Uuid,
/// `true` when this is the user's default drive (sets the partial-
/// unique `default_for_user` column). `false` for secondaries.
pub is_default: bool,
/// Initial storage quota in bytes. `None` defers to admin policy
/// (typically copied from `auth.users.storage_quota_bytes` at the
/// call site).
pub quota_bytes: Option<i64>,
}
#[async_trait::async_trait]
pub trait DriveRepository: Send + Sync + 'static {
/// Insert a personal drive row. The caller is responsible for
/// inserting the matching owner row in `storage.role_grants` in the
/// same transaction (the lifecycle hook handles this; M2's backfill
/// did it directly in SQL).
///
/// Returns `DefaultDriveAlreadyExists` when `is_default=true` and the
/// owner already has a default drive — relies on the partial UNIQUE
/// index on `default_for_user`.
async fn create_personal(
&self,
input: CreatePersonalDriveInput,
) -> Result<Drive, DriveRepositoryError>;
/// Fetch a drive by id. `NotFound` when no row matches.
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError>;
/// Return the caller's default personal drive, or `NotFound` if they
/// don't have one (e.g. external users; users created before the
/// lifecycle hook fired). Drives the Photos timeline scope, the
/// `/api/recent/*` scope, and D1's redirect-from-`/`.
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError>;
/// 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.
///
/// Returns rows in a stable order: default drive first (if any),
/// then by name. The `/api/drives` handler relies on that order for
/// the picker UI without a follow-up sort.
async fn list_for_subjects(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<Drive>, DriveRepositoryError>;
}
/// Convenience: convert the canonical kind discriminator from its SQL
/// form into the typed enum. Mirrored on the entity for symmetry.
impl DriveKind {
pub fn from_sql(s: &str) -> Result<Self, DriveRepositoryError> {
DriveKind::parse(s).ok_or_else(|| DriveRepositoryError::InvalidKind(s.to_owned()))
}
}
+11 -3
View File
@@ -98,9 +98,17 @@ pub trait FolderRepository: Send + Sync + 'static {
/// Permanently deletes a folder (used by the trash)
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
/// Creates a root-level home folder for a user.
/// This is used during user registration to create the user's personal folder.
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError>;
/// Creates a root-level home folder for a user inside their personal drive.
/// Called during user registration / first login to maintain the wrapper-
/// folder convention through the D0 dual-write window (the wrapper itself
/// retires in a follow-up migration; for now it stays as a real folder
/// row stamped with `drive_id`).
async fn create_home_folder(
&self,
user_id: Uuid,
drive_id: Uuid,
name: String,
) -> Result<Folder, DomainError>;
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
///
+1
View File
@@ -2,6 +2,7 @@ pub mod address_book_repository;
pub mod calendar_event_repository;
pub mod calendar_repository;
pub mod contact_repository;
pub mod drive_repository;
pub mod file_repository;
pub mod folder_repository;
pub mod magic_link_token_repository;
+10 -3
View File
@@ -74,6 +74,10 @@ impl fmt::Display for Subject {
pub enum Resource {
Folder(Uuid),
File(Uuid),
/// A drive — root scope for a tree of folders/files plus its own
/// 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:
@@ -87,6 +91,7 @@ impl Resource {
match self {
Resource::Folder(_) => "folder",
Resource::File(_) => "file",
Resource::Drive(_) => "drive",
//Resource::Calendar(_) => "calendar",
//Resource::AddressBook(_) => "adressbook",
//Resource::Playlist(_) => "playlist",
@@ -95,12 +100,10 @@ impl Resource {
pub fn id(&self) -> Uuid {
match self {
Resource::Folder(id)
| Resource::File(id)
Resource::Folder(id) | Resource::File(id) | Resource::Drive(id) => *id,
//| Resource::Calendar(id)
//| Resource::AddressBook(id)
//| Resource::Playlist(id)
=> *id,
}
}
@@ -108,6 +111,7 @@ impl Resource {
match resource_type {
"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)),
@@ -351,6 +355,7 @@ impl Grant {
pub enum ResourceKind {
File,
Folder,
Drive,
// Future: Calendar, AddressBook, Playlist, …
}
@@ -359,6 +364,7 @@ impl ResourceKind {
match self {
ResourceKind::File => "file",
ResourceKind::Folder => "folder",
ResourceKind::Drive => "drive",
}
}
@@ -366,6 +372,7 @@ impl ResourceKind {
match s {
"file" => Some(ResourceKind::File),
"folder" => Some(ResourceKind::Folder),
"drive" => Some(ResourceKind::Drive),
_ => None,
}
}
@@ -0,0 +1,173 @@
//! PostgreSQL implementation of [`DriveRepository`].
//!
//! 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`
//! via a single join.
//!
//! See `migrations/20260802000000_drives_schema_additive.sql` for the
//! schema and `docs/plan/drive.md` §3 / §15 for the locked design.
use std::sync::Arc;
use sqlx::{PgPool, Row, types::Uuid};
use crate::domain::entities::drive::{Drive, DriveKind};
use crate::domain::repositories::drive_repository::{
CreatePersonalDriveInput, DriveRepository, DriveRepositoryError,
};
pub struct DrivePgRepository {
pool: Arc<PgPool>,
}
impl DrivePgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
fn map_sqlx_err(context: &'static str, e: sqlx::Error) -> DriveRepositoryError {
if let sqlx::Error::Database(ref dberr) = e
&& let Some(code) = dberr.code()
&& code.as_ref() == "23505"
{
// unique_violation. With drives, the only relevant unique is
// the partial index `idx_drives_default_for_user_unique` —
// surface the typed variant so the lifecycle hook can detect
// idempotent re-runs (D0-9 calls create_personal during
// user provisioning).
return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string());
}
DriveRepositoryError::StorageError(format!("{context}: {e}"))
}
fn row_to_drive(row: &sqlx::postgres::PgRow) -> Result<Drive, DriveRepositoryError> {
let kind_str: String = row.get("kind");
let kind = DriveKind::from_sql(&kind_str)?;
Ok(Drive {
id: row.get("id"),
name: row.get("name"),
kind,
default_for_user: row.get("default_for_user"),
quota_bytes: row.get("quota_bytes"),
used_bytes: row.get("used_bytes"),
policies: row.get("policies"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
})
}
}
#[async_trait::async_trait]
impl DriveRepository for DrivePgRepository {
async fn create_personal(
&self,
input: CreatePersonalDriveInput,
) -> Result<Drive, DriveRepositoryError> {
let default_for_user = if input.is_default {
Some(input.owner_id)
} else {
None
};
let row = sqlx::query(
r#"
INSERT INTO storage.drives
(name, kind, default_for_user, quota_bytes, policies)
VALUES ($1, 'personal', $2, $3, '{}'::jsonb)
RETURNING id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
"#,
)
.bind(&input.name)
.bind(default_for_user)
.bind(input.quota_bytes)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("create_personal", e))?;
Self::row_to_drive(&row)
}
async fn get_by_id(&self, id: Uuid) -> Result<Drive, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
FROM storage.drives
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("get_by_id", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(id.to_string()))?;
Self::row_to_drive(&row)
}
async fn find_default_for_user(&self, user_id: Uuid) -> Result<Drive, DriveRepositoryError> {
let row = sqlx::query(
r#"
SELECT id, name, kind, default_for_user, quota_bytes,
used_bytes, policies, created_at, updated_at
FROM storage.drives
WHERE default_for_user = $1
"#,
)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("find_default_for_user", e))?
.ok_or_else(|| DriveRepositoryError::NotFound(user_id.to_string()))?;
Self::row_to_drive(&row)
}
async fn list_for_subjects(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
) -> Result<Vec<Drive>, DriveRepositoryError> {
// Joining `role_grants` → `storage.drives` returns every drive
// the expanded subject set can read. ORDER BY puts default
// drives first (so the picker UI doesn't need a follow-up
// sort), then alphabetical by name. DISTINCT collapses the
// case where a caller has multiple role_grants on the same
// drive (e.g. direct + group-mediated); a GROUP BY on the
// drive id sidesteps PostgreSQL's "ORDER BY expression must
// appear in select list" rule that `SELECT DISTINCT` imposes.
let rows = sqlx::query(
r#"
SELECT d.id, d.name, d.kind, d.default_for_user,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at
FROM storage.drives d
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)
AND (g.expires_at IS NULL OR g.expires_at > NOW())
GROUP BY d.id, d.name, d.kind, d.default_for_user,
d.quota_bytes, d.used_bytes, d.policies,
d.created_at, d.updated_at
ORDER BY (d.default_for_user IS NULL) ASC,
LOWER(d.name) ASC
"#,
)
.bind(
subject_types
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
)
.bind(subject_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?;
rows.iter().map(Self::row_to_drive).collect()
}
}
@@ -27,6 +27,10 @@ use crate::infrastructure::services::dedup_service::DedupService;
pub struct FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<DedupService>,
/// Retained on the struct after D0-8 inlined parent-folder lookups
/// directly via SQL; kept for now so D0's diff stays scoped to drive_id
/// + provenance plumbing. Slated for removal in a follow-up cleanup.
#[allow(dead_code)]
folder_repo: Arc<FolderDbRepository>,
/// Shared handle to `FileBlobReadRepository`'s file_id → blob_hash
/// cache. Content swaps and hard deletes invalidate the mapping here
@@ -128,10 +132,27 @@ impl FileBlobWriteRepository {
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
/// Derive user_id from the parent folder, or error if folder_id is None.
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<Uuid, DomainError> {
/// 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> {
match folder_id {
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
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))
}
None => Err(DomainError::internal_error(
"FileBlobWrite",
"folder_id is required to determine file owner",
@@ -168,7 +189,8 @@ impl FileBlobWriteRepository {
)
UPDATE storage.files f
SET blob_hash = $1, size = $2,
updated_at = COALESCE(to_timestamp($4), NOW())
updated_at = COALESCE(to_timestamp($4), NOW()),
updated_by = f.user_id
FROM old
WHERE f.id = old.id
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
@@ -263,11 +285,14 @@ impl FileBlobWriteRepository {
sqlx::query_as::<_, (String, Uuid, String, i64, i64)>(
r#"
WITH parent AS (
SELECT id, user_id, path FROM storage.folders WHERE id = $2::uuid
SELECT id, user_id, drive_id, path FROM storage.folders WHERE id = $2::uuid
)
INSERT INTO storage.files
(name, folder_id, user_id, blob_hash, size, mime_type, category_order)
SELECT $1, parent.id, parent.user_id, $3, $4, $5, $6 FROM parent
(name, folder_id, user_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,
$5, $6, parent.user_id, parent.user_id
FROM parent
RETURNING id::text,
user_id,
(SELECT path FROM parent),
@@ -363,12 +388,19 @@ impl FileWritePort for FileBlobWriteRepository {
// If moving to a different folder, get the new user_id (must be same user)
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET folder_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
WITH dest AS (
SELECT user_id, 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 = COALESCE((SELECT user_id FROM dest), f.user_id)
WHERE f.id = $2::uuid AND NOT f.is_trashed
RETURNING f.id::text, f.name, f.folder_id::text, f.size, f.mime_type,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint
"#,
)
.bind(&target_folder_id)
@@ -424,16 +456,33 @@ impl FileWritePort for FileBlobWriteRepository {
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.
dest_folder AS (
SELECT id, user_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, blob_hash, size, mime_type, category_order)
SELECT COALESCE($3::text, name),
COALESCE($2::uuid, folder_id),
user_id,
blob_hash,
size,
mime_type,
category_order
FROM src
INSERT INTO storage.files
(name, folder_id, user_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,
src.mime_type,
src.category_order,
dest_folder.user_id,
dest_folder.user_id
FROM src, dest_folder
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
@@ -497,7 +546,7 @@ impl FileWritePort for FileBlobWriteRepository {
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
r#"
UPDATE storage.files
SET name = $1, updated_at = NOW()
SET name = $1, updated_at = NOW(), updated_by = user_id
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, folder_id::text, size, mime_type,
EXTRACT(EPOCH FROM created_at)::bigint,
@@ -580,7 +629,7 @@ impl FileWritePort for FileBlobWriteRepository {
content_type: String,
size: u64,
) -> Result<(File, PathBuf), DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
let (user_id, drive_id) = self.resolve_owner_and_drive(folder_id.as_deref()).await?;
// For deferred registration we use a placeholder hash.
// The write-behind cache will call update_file_content later.
@@ -589,8 +638,10 @@ impl FileWritePort for FileBlobWriteRepository {
let row = retry_on_deadlock("files.insert_deferred", || {
sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
INSERT INTO storage.files
(name, folder_id, user_id, drive_id, blob_hash, size,
mime_type, category_order, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $3, $3)
RETURNING id::text,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
@@ -599,6 +650,7 @@ impl FileWritePort for FileBlobWriteRepository {
.bind(&name)
.bind(&folder_id)
.bind(user_id)
.bind(drive_id)
.bind(placeholder_hash)
.bind(size as i64)
.bind(&content_type)
@@ -638,7 +690,8 @@ impl FileWritePort for FileBlobWriteRepository {
SET is_trashed = TRUE,
trashed_at = NOW(),
original_folder_id = folder_id,
updated_at = NOW()
updated_at = NOW(),
updated_by = user_id
WHERE id = $1::uuid AND NOT is_trashed
"#,
)
@@ -665,7 +718,8 @@ impl FileWritePort for FileBlobWriteRepository {
trashed_at = NULL,
folder_id = COALESCE(original_folder_id, folder_id),
original_folder_id = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = user_id
WHERE id = $1::uuid AND is_trashed
"#,
)
@@ -148,18 +148,19 @@ impl FolderRepository for FolderDbRepository {
name: String,
parent_id: Option<String>,
) -> Result<Folder, DomainError> {
// Derive user_id from parent folder. Root-level folders require the
// caller to have set up the home folder beforehand (done during user
// registration).
let user_id: Uuid = if let Some(ref pid) = parent_id {
sqlx::query_scalar::<_, Uuid>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
.bind(pid)
.fetch_optional(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("parent lookup: {e}"))
})?
.ok_or_else(|| DomainError::not_found("Folder", pid))?
// 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",
)
.bind(pid)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", pid))?
} else {
return Err(DomainError::internal_error(
"FolderDb",
@@ -167,10 +168,17 @@ impl FolderRepository for FolderDbRepository {
));
};
// D0 dual-write: drive_id alongside user_id (drops in D7), plus
// provenance columns created_by/updated_by. The repo derives
// created_by from user_id because the parent's owner is the
// creator on personal drives (the only kind that exists in D0).
// D2 plumbs the real caller_id when shared drives let other
// members write into a drive they don't own.
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, $2::uuid, $3)
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, $2::uuid, $3, $4, $3, $3)
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
@@ -181,6 +189,7 @@ impl FolderRepository for FolderDbRepository {
.bind(&name)
.bind(&parent_id)
.bind(user_id)
.bind(drive_id)
.fetch_one(self.pool())
.await
.map_err(|e| {
@@ -489,7 +498,7 @@ impl FolderRepository for FolderDbRepository {
sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET name = $1, updated_at = NOW()
SET name = $1, updated_at = NOW(), updated_by = user_id
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
@@ -528,7 +537,7 @@ impl FolderRepository for FolderDbRepository {
sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET parent_id = $1::uuid, updated_at = NOW()
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
@@ -634,7 +643,8 @@ impl FolderRepository for FolderDbRepository {
SET is_trashed = TRUE,
trashed_at = NOW(),
original_parent_id = parent_id,
updated_at = NOW()
updated_at = NOW(),
updated_by = user_id
WHERE id = $1::uuid AND NOT is_trashed
RETURNING id, lpath
),
@@ -642,7 +652,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.folders f
SET is_trashed = TRUE,
trashed_at = NOW(),
updated_at = NOW()
updated_at = NOW(),
updated_by = f.user_id
FROM trash_root tr
WHERE f.lpath <@ tr.lpath
AND f.id != tr.id
@@ -653,7 +664,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.files fi
SET is_trashed = TRUE,
trashed_at = NOW(),
updated_at = NOW()
updated_at = NOW(),
updated_by = fi.user_id
FROM trash_root tr
JOIN storage.folders f ON f.lpath <@ tr.lpath
WHERE fi.folder_id = f.id
@@ -698,7 +710,8 @@ impl FolderRepository for FolderDbRepository {
trashed_at = NULL,
parent_id = COALESCE(original_parent_id, parent_id),
original_parent_id = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = user_id
WHERE id = $1::uuid AND is_trashed
RETURNING id, lpath
),
@@ -706,7 +719,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.folders f
SET is_trashed = FALSE,
trashed_at = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = f.user_id
FROM restore_root rr
WHERE f.lpath <@ rr.lpath
AND f.id != rr.id
@@ -718,7 +732,8 @@ impl FolderRepository for FolderDbRepository {
UPDATE storage.files fi
SET is_trashed = FALSE,
trashed_at = NULL,
updated_at = NOW()
updated_at = NOW(),
updated_by = fi.user_id
FROM restore_root rr
JOIN storage.folders f ON f.lpath <@ rr.lpath
WHERE fi.folder_id = f.id
@@ -775,11 +790,24 @@ impl FolderRepository for FolderDbRepository {
Ok(())
}
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
async fn create_home_folder(
&self,
user_id: Uuid,
drive_id: Uuid,
name: String,
) -> Result<Folder, DomainError> {
// D0-9 keeps the wrapper-folder convention through the dual-write
// window: the lifecycle hook creates the personal drive AND a
// root folder under it. Wrapper retirement (the `My Folder -
// <username>/` prefix and the wrapper row itself) lands in M2b
// alongside the path rewrite. drive_id is required (M3 NOT NULL);
// created_by/updated_by are stamped from user_id for D0
// provenance.
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
INSERT INTO storage.folders
(name, parent_id, user_id, drive_id, created_by, updated_by)
VALUES ($1, NULL, $2, $3, $2, $2)
ON CONFLICT DO NOTHING
RETURNING id::text,
path,
@@ -790,6 +818,7 @@ impl FolderRepository for FolderDbRepository {
)
.bind(&name)
.bind(user_id)
.bind(drive_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
@@ -6,6 +6,7 @@ mod contact_group_pg_repository;
mod contact_persistence_dto;
mod contact_pg_repository;
mod device_code_pg_repository;
mod drive_pg_repository;
mod face_pg_repository;
mod favorites_pg_repository;
pub mod file_metadata_repository;
@@ -34,6 +35,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
pub use contact_persistence_dto::*;
pub use contact_pg_repository::ContactPgRepository;
pub use device_code_pg_repository::DeviceCodePgRepository;
pub use drive_pg_repository::DrivePgRepository;
pub use face_pg_repository::FacePgRepository;
pub use favorites_pg_repository::FavoritesPgRepository;
pub use file_blob_read_repository::FileBlobReadRepository;
+44 -23
View File
@@ -2776,12 +2776,22 @@ mod rechunk_integration_tests {
Arc::new(pool)
}
async fn seed_user(pool: &PgPool) -> Uuid {
sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.map(|r| r.get::<Uuid, _>("id"))
.expect("auth.users must be seeded (init-test-schema.sh)")
/// Returns `(user_id, drive_id)`. Post-D0 every internal user has a
/// default Personal drive (provisioned by `PersonalDriveLifecycleHook`
/// during init-test-schema.sh's user seeding); the JOIN below picks
/// the user-drive pair atomically so test fixtures can insert into
/// `storage.files` with both `user_id` and `drive_id` populated.
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
sqlx::query(
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1",
)
.fetch_one(pool)
.await
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
}
/// Plain local backend in a fresh temp dir.
@@ -2848,7 +2858,7 @@ mod rechunk_integration_tests {
.await
.expect("insert legacy blob row");
let user_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!(
@@ -2856,11 +2866,12 @@ mod rechunk_integration_tests {
&Uuid::new_v4().to_string()[..8]
);
let id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) RETURNING id",
)
.bind(&name)
.bind(user_id)
.bind(drive_id)
.bind(&hash)
.bind(data.len() as i64)
.fetch_one(pool)
@@ -3121,12 +3132,20 @@ mod delta_upload_integration_tests {
Arc::new(pool)
}
async fn seed_user(pool: &PgPool) -> Uuid {
sqlx::query("SELECT id FROM auth.users LIMIT 1")
.fetch_one(pool)
.await
.map(|r| r.get::<Uuid, _>("id"))
.expect("auth.users must be seeded (init-test-schema.sh)")
/// Returns `(user_id, drive_id)` — same shape as the rechunk tests'
/// `seed_user`. Post-D0 every internal user has a default Personal
/// drive provisioned by `PersonalDriveLifecycleHook`.
async fn seed_user(pool: &PgPool) -> (Uuid, Uuid) {
sqlx::query(
"SELECT u.id AS user_id, d.id AS drive_id
FROM auth.users u
JOIN storage.drives d ON d.default_for_user = u.id
LIMIT 1",
)
.fetch_one(pool)
.await
.map(|r| (r.get::<Uuid, _>("user_id"), r.get::<Uuid, _>("drive_id")))
.expect("auth.users + storage.drives must be seeded (init-test-schema.sh)")
}
async fn local_svc(pool: &Arc<PgPool>, dir: &TempDir) -> DedupService {
@@ -3141,6 +3160,7 @@ mod delta_upload_integration_tests {
svc: &DedupService,
pool: &PgPool,
user_id: Uuid,
drive_id: Uuid,
data: &[u8],
label: &str,
) -> (String, Vec<String>, Uuid) {
@@ -3159,14 +3179,15 @@ mod delta_upload_integration_tests {
.expect("chunks");
let file_id: Uuid = sqlx::query_scalar(
"INSERT INTO storage.files (name, user_id, blob_hash, size)
VALUES ($1, $2, $3, $4) RETURNING id",
"INSERT INTO storage.files (name, user_id, drive_id, blob_hash, size)
VALUES ($1, $2, $3, $4, $5) 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)
.fetch_one(pool)
@@ -3226,13 +3247,13 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// Owned content (multi-chunk), one foreign chunk (ref 1, no file
// row for this user), one orphan (ref 0), one unknown hash.
let data = content(3 * 1024 * 1024, 21);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "claim").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "claim").await;
assert!(owned_chunks.len() >= 3, "3 MiB must split into ≥3 chunks");
let foreign = blake3::hash(format!("foreign-{}", Uuid::new_v4()).as_bytes())
@@ -3316,12 +3337,12 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
// An owned chunk that the client redundantly re-uploads.
let data = content(100 * 1024, 22);
let (file_hash, owned_chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "loose").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "loose").await;
let owned_chunk_bytes = {
let mut stream = svc.read_blob_stream(&file_hash).await.expect("stream");
let mut out = Vec::new();
@@ -3540,11 +3561,11 @@ mod delta_upload_integration_tests {
let pool = test_pool().await;
let dir = TempDir::new().unwrap();
let svc = local_svc(&pool, &dir).await;
let user = seed_user(&pool).await;
let (user, drive_id) = seed_user(&pool).await;
let data = content(2 * 1024 * 1024 + 137, 24);
let (file_hash, _chunks, file_id) =
seed_owned_content(&svc, &pool, user, &data, "verify").await;
seed_owned_content(&svc, &pool, user, drive_id, &data, "verify").await;
let manifest: (Vec<String>, Vec<i64>) = sqlx::query_as(
"SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1",
+68 -1
View File
@@ -230,11 +230,32 @@ 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.
pub async fn expand_subject_for_listing(
&self,
subject: Subject,
) -> Result<(Vec<&'static str>, Vec<Uuid>), DomainError> {
let counters = QueryCounters::default();
self.subject_match_set(subject, &counters).await
}
/// Returns the owner UUID for any resource type.
async fn owner_of(&self, resource: Resource) -> Result<Uuid, DomainError> {
match resource {
Resource::Folder(id) => self.folder_repo.get_folder_user_id(&id.to_string()).await,
Resource::File(id) => self.file_repo.get_file_user_id(&id.to_string()).await,
// Drive owner resolution wires up in D0-6 once `DriveRepository`
// lands (D0-5). Drive entity carries `default_for_user` for
// `kind='personal'`; shared drives resolve through role_grants
// (Owner role). Returning NotFound here means a permission
// check that reached owner_of on a Drive falls through to the
// grant-lookup path — safe default during D0-1.
Resource::Drive(_) => Err(DomainError::not_found("Drive", resource.id().to_string())),
}
}
@@ -360,6 +381,43 @@ impl PgAclEngine {
Ok(exists.is_some())
}
/// Direct grant lookup for a drive — no ltree cascade (drives have
/// no ancestors). Mirrors the cascade helpers above but with a
/// straight `resource_type='drive' AND resource_id=$4` filter.
async fn drive_grant_exists(
&self,
subject_types: &[&str],
subject_ids: &[Uuid],
permission: Permission,
drive_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 = 'drive'
AND g.resource_id = $4
AND (g.expires_at IS NULL OR g.expires_at > NOW())
LIMIT 1
"#,
)
.bind(subject_types)
.bind(subject_ids)
.bind(&roles)
.bind(drive_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("drive grant: {e}")))?;
Ok(exists.is_some())
}
/// Look up a single role grant by id, returning the actors a revoke /
/// notify handler needs to make a decision without a second round-trip.
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
@@ -459,7 +517,12 @@ impl PgAclEngine {
) -> Result<bool, DomainError> {
// Owner short-circuit (only for User subjects — groups/tokens/external
// are never owners of resources).
if let Subject::User(uid) = subject {
// Owner short-circuit applies to Folder/File only — they carry a
// single-owner `user_id` column in their respective tables. Drives
// model ownership through the `Owner` role in `role_grants`, so
// there's no analogous fast path: the grant lookup below resolves
// a drive owner via the same query that resolves any drive role.
if let (Subject::User(uid), Resource::Folder(_) | Resource::File(_)) = (subject, resource) {
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
match self.owner_of(resource).await {
Ok(owner) if owner == uid => return Ok(true),
@@ -500,6 +563,10 @@ impl PgAclEngine {
)
.await
}
Resource::Drive(id) => {
self.drive_grant_exists(&subject_types, &subject_ids, permission, id, counters)
.await
}
}
}
}
@@ -242,14 +242,15 @@ 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, i64)> =
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.name, fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
"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())
@@ -261,10 +262,10 @@ impl ContentIndexWorker {
// 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.3.clone())
.map(|f| f.4.clone())
.collect();
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
if !wanted_hashes.is_empty() {
@@ -281,7 +282,7 @@ impl ContentIndexWorker {
}
let mut records = Vec::with_capacity(files.len());
for (file_id, user_id, name, blob_hash, mime, size) in files {
for (file_id, user_id, drive_id, name, blob_hash, mime, size) in files {
let supported = text_extractor::supports(&name, &mime);
let content = if !supported {
None
@@ -301,6 +302,7 @@ impl ContentIndexWorker {
records.push(IndexDocRecord {
file_id: file_id.to_string(),
user_id,
drive_id,
name,
content,
preview,
@@ -37,7 +37,15 @@ use crate::common::errors::DomainError;
/// Bump whenever the Tantivy schema OR the text extractor output changes in a
/// way that requires re-indexing. A mismatch with the on-disk marker wipes the
/// index directory and reseeds the dirty queue with every live file.
pub const INDEX_SCHEMA_VERSION: &str = "1";
///
/// Version history:
/// 1 — initial schema (file_id, user_id, name, content, preview)
/// 2 — D0 added `drive_id` field; query filter pivots from user_id
/// to a `drive_id ∈ accessible_drives` set membership clause. On
/// deploy, every operator's index is wiped and reseeded against
/// the post-D0 schema (the worker drains the dirty queue with
/// drive_id-aware records).
pub const INDEX_SCHEMA_VERSION: &str = "2";
/// Recorded in `storage.blob_extracted_text.extractor`; rows from another
/// version are dropped at worker startup (the reseed re-extracts them).
@@ -73,6 +81,11 @@ const PREFIX_MIN_CHARS: usize = 3;
pub struct IndexDocRecord {
pub file_id: String,
pub user_id: String,
/// Owning drive — written verbatim into the `drive_id` STRING field
/// for set-membership filtering at query time. The user_id field is
/// kept during the D0 dual-write window for rollback safety; the
/// query filter no longer reads it.
pub drive_id: String,
pub name: String,
pub content: Option<String>,
pub preview: Option<String>,
@@ -82,6 +95,7 @@ pub struct IndexDocRecord {
struct IndexFields {
file_id: Field,
user_id: Field,
drive_id: Field,
name: Field,
content: Field,
preview: Field,
@@ -105,6 +119,7 @@ impl TantivyContentIndex {
let fields = IndexFields {
file_id: builder.add_text_field("file_id", STRING | STORED),
user_id: builder.add_text_field("user_id", STRING),
drive_id: builder.add_text_field("drive_id", STRING),
name: builder.add_text_field("name", TEXT),
content: builder.add_text_field("content", TEXT),
preview: builder.add_text_field("preview", STORED),
@@ -197,6 +212,7 @@ impl TantivyContentIndex {
let mut document = doc!(
self.fields.file_id => record.file_id,
self.fields.user_id => record.user_id,
self.fields.drive_id => record.drive_id,
self.fields.name => record.name,
);
if let Some(content) = record.content {
@@ -234,15 +250,26 @@ impl TantivyContentIndex {
/// Build the scored query: every token must match (in name OR content,
/// exact OR fuzzy OR — for the last token — prefix), and the whole thing
/// is `Must`-scoped to the user.
fn build_query(fields: IndexFields, user_id: &str, tokens: &[String]) -> Box<dyn Query> {
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_text(fields.user_id, user_id),
IndexRecordOption::Basic,
)),
)];
/// is `Must`-scoped to the caller's accessible drives.
///
/// The drive filter is expressed as a BoolQuery with `Should` arms —
/// at least one drive_id must match — wrapped under an outer `Must`.
/// Equivalent to a TermSetQuery; this form avoids the API churn of
/// rebuilding the same shape across Tantivy versions.
fn build_query(fields: IndexFields, drive_ids: &[String], tokens: &[String]) -> Box<dyn Query> {
// Drive-membership Must clause: union of Term(drive_id = $each).
let drive_alternatives: Vec<(Occur, Box<dyn Query>)> = drive_ids
.iter()
.map(|d| {
let q: Box<dyn Query> = Box::new(TermQuery::new(
Term::from_field_text(fields.drive_id, d),
IndexRecordOption::Basic,
));
(Occur::Should, q)
})
.collect();
let mut clauses: Vec<(Occur, Box<dyn Query>)> =
vec![(Occur::Must, Box::new(BooleanQuery::new(drive_alternatives)))];
let last = tokens.len().saturating_sub(1);
for (i, token) in tokens.iter().enumerate() {
@@ -306,7 +333,7 @@ impl TantivyContentIndex {
searcher: tantivy::Searcher,
analyzer: TextAnalyzer,
fields: IndexFields,
user_id: &str,
drive_ids: &[String],
raw_query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
@@ -315,7 +342,7 @@ impl TantivyContentIndex {
return Ok(Vec::new());
}
let query = Self::build_query(fields, user_id, &tokens);
let query = Self::build_query(fields, drive_ids, &tokens);
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
@@ -365,18 +392,25 @@ impl TantivyContentIndex {
impl ContentIndexPort for TantivyContentIndex {
async fn search_content(
&self,
user_id: Uuid,
accessible_drive_ids: &[Uuid],
query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
// No accessible drives → no hits, no Tantivy work. Matches the
// anti-enumeration semantics (empty filter set returns empty
// results without any side channel).
if accessible_drive_ids.is_empty() {
return Ok(Vec::new());
}
let searcher = self.reader.searcher();
let analyzer = self.analyzer.clone();
let fields = self.fields;
let user_id = user_id.to_string();
let drive_ids: Vec<String> = accessible_drive_ids.iter().map(|d| d.to_string()).collect();
let query = query.to_owned();
tokio::task::spawn_blocking(move || {
Self::search_blocking(searcher, analyzer, fields, &user_id, &query, limit)
Self::search_blocking(searcher, analyzer, fields, &drive_ids, &query, limit)
})
.await
.map_err(|e| DomainError::internal_error("ContentIndex", format!("join: {e}")))?
@@ -391,6 +425,10 @@ mod tests {
IndexDocRecord {
file_id: file_id.to_owned(),
user_id: user_id.to_owned(),
// Tests stamp a placeholder drive_id derived from user_id so the
// record satisfies the post-D0 schema. Query-side filtering by
// drive_id is exercised in D0-12's integration tests, not here.
drive_id: format!("{user_id}-drive"),
name: name.to_owned(),
content: content.map(str::to_owned),
preview: content.map(str::to_owned),
@@ -401,11 +439,16 @@ mod tests {
// Force a reader reload — OnCommitWithDelay is asynchronous and tests
// must observe the commit immediately.
index.reader.reload().unwrap();
// Test records derive `drive_id = format!("{user_id}-drive")` —
// the same convention used by `record()`. Filtering by that
// single drive id exercises the same path the production
// search uses.
let drive_ids = vec![format!("{user_id}-drive")];
TantivyContentIndex::search_blocking(
index.reader.searcher(),
index.analyzer.clone(),
index.fields,
user_id,
&drive_ids,
query,
32,
)
@@ -113,13 +113,15 @@ impl TreeEtagFlushService {
FROM storage.tree_etag_dirty
ORDER BY id
LIMIT $1)
RETURNING lpath, folder_id
RETURNING lpath, folder_id, drive_id
),
targets AS (
-- Captured chain: covers target folders deleted or
-- moved away since enqueue (the old location's
-- surviving ancestors still get their bump).
SELECT lpath FROM drained
-- surviving ancestors still get their bump). drive_id
-- comes along so the victims walk can enforce
-- cross-drive isolation (D0-13).
SELECT lpath, drive_id FROM drained
UNION
-- Flush-time resolution: a folder MOVED since
-- enqueue had its subtree's lpaths rewritten, so
@@ -128,19 +130,29 @@ impl TreeEtagFlushService {
-- this, a bump queued just before a move would be
-- silently lost and sync clients would never
-- discover the change.
SELECT fo.lpath
SELECT fo.lpath, fo.drive_id
FROM storage.folders fo
JOIN drained d ON fo.id = d.folder_id
),
victims AS (
-- `lpath @> target` = the target folder itself plus
-- every ancestor (GiST-indexed). Folder rows deleted
-- since enqueue simply don't match. Lock in id order
-- so overlapping closures cannot deadlock.
-- every ancestor (GiST-indexed). The `drive_id`
-- predicate prevents a numerically-overlapping
-- lpath in a SIBLING drive from spuriously matching
-- (D0-13). Rows from old queue entries (pre-M4) have
-- NULL `drive_id` — `IS NOT DISTINCT FROM` falls
-- back to pure lpath matching for those, preserving
-- the rollover semantics for any rows enqueued
-- between this migration committing and the
-- service restart.
SELECT f.id
FROM storage.folders f
WHERE EXISTS (SELECT 1 FROM targets t
WHERE f.lpath @> t.lpath)
WHERE EXISTS (
SELECT 1 FROM targets t
WHERE f.lpath @> t.lpath
AND (t.drive_id IS NULL
OR f.drive_id = t.drive_id)
)
ORDER BY f.id
FOR NO KEY UPDATE
),
@@ -0,0 +1,72 @@
//! `GET /api/drives` — list every drive the caller can read.
//!
//! D0 ships the read-only listing; D2 adds shared-drive membership
//! mutations (`POST/DELETE/PUT /api/drives/{id}/members`), D3 adds the
//! create-shared-drive flow, etc.
//!
//! The handler resolves the caller's expanded subject set through the
//! engine (so group-mediated drive grants surface — the foundation for
//! D2/D3) and asks the `DriveRepository` for every drive that set can
//! read. Authorization is purely the subject-expansion step: no
//! `require(...)` call here, because "your accessible drives" is a
//! listing query, not a permission decision on a specific drive.
use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use tracing::error;
use crate::application::dtos::drive_dto::DriveDto;
use crate::common::di::AppState;
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::services::authorization::Subject;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
#[utoipa::path(
get,
path = "/api/drives",
responses(
(status = 200, description = "Drives the caller can read", body = Vec<DriveDto>),
(status = 500, description = "Internal server error"),
),
security(("bearerAuth" = [])),
tag = "drives"
)]
pub async fn list_drives(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
) -> impl IntoResponse {
let caller_id = auth_user.id;
// Expand the caller's `Subject::User` into the `(types, ids)` pair
// that includes every group the user transitively belongs to. The
// engine caches this expansion in its Moka cache; if the caller
// just ran a permission check, this is a hit.
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
{
Ok(drives) => {
let dtos: Vec<DriveDto> = drives.into_iter().map(DriveDto::from).collect();
(StatusCode::OK, Json(dtos)).into_response()
}
Err(e) => {
error!("list_drives: repo lookup failed: {e}");
AppError::internal_error(format!("Failed to list drives: {e}")).into_response()
}
}
}
@@ -719,6 +719,13 @@ pub async fn list_shared_with_me(
summary.resource_id
),
},
// Drive grants don't appear in the file/folder "Shared with me"
// listing — they're surfaced through `GET /api/drives` (D0).
// Silently skipping here is the right behaviour: a drive grant
// discovered by `list_incoming_resources_paged` is not a stale
// grant, just a different resource type with a different
// listing surface.
ResourceKind::Drive => continue,
}
}
@@ -953,6 +960,10 @@ pub async fn list_my_shares(
summary.resource_id
),
},
// Drive grants are surfaced via `GET /api/drives` (D0), not
// through the My Shares outgoing-resources surface. Silently
// skip — symmetric with the `list_shared_with_me` arm above.
ResourceKind::Drive => continue,
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod contacts_handler;
pub mod dedup_handler;
pub mod delta_upload_handler;
pub mod device_auth_handler;
pub mod drive_handler;
pub mod favorites_handler;
pub mod file_handler;
pub mod folder_handler;
+6
View File
@@ -13,6 +13,7 @@ use utoipa::{Modify, OpenApi};
use crate::application::dtos::contact_dto::{
AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto,
};
use crate::application::dtos::drive_dto::{DriveDto, DriveKindDto};
use crate::application::dtos::favorites_dto::{
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto, FavoritesResourceItemDto,
};
@@ -165,6 +166,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// Photos handler (free function)
handlers::photos_handler::list_photos,
handlers::photos_handler::list_photos_geo,
// Drive handler (free function)
handlers::drive_handler::list_drives,
// Batch handlers (free functions)
handlers::batch_handler::move_files_batch,
handlers::batch_handler::copy_files_batch,
@@ -359,6 +362,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
SharedWithMeDto,
SharedWithMeItemDto,
OutgoingResourceItemDto,
// Drive schemas
DriveDto,
DriveKindDto,
// Subject-group (ReBAC named groups) schemas
handlers::subject_group_handler::CreateGroupRequest,
handlers::subject_group_handler::UpdateGroupRequest,
+13
View File
@@ -440,6 +440,19 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
router = router.nest("/photos", photos_router);
}
// Drives — every drive the caller can read. D0 ships the read-only
// listing; D2 adds the membership API + shared-drive endpoints under
// `/api/drives/{id}/members`.
{
use crate::interfaces::api::handlers::drive_handler;
let drives_router = Router::new()
.route("/", get(drive_handler::list_drives))
.with_state(app_state.clone());
router = router.nest("/drives", drives_router);
}
// People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on.
if app_state.people_service.is_some() {
use crate::interfaces::api::handlers::people_handler;