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
+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())