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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user