feat(drive): ensure drive_id updated on file|folder moved to another drive

This commit is contained in:
Edouard Vanbelle
2026-06-29 21:11:40 +02:00
parent acf2311cea
commit ee92d365b9
7 changed files with 506 additions and 42 deletions
@@ -0,0 +1,73 @@
-- D6: cross-drive folder moves must propagate `drive_id` to the moved
-- folder's subtree (descendant folders + files), not just `lpath`.
--
-- Today's `cascade_folder_path()` trigger only rewrites `path` + `lpath`
-- on descendants — it leaves `drive_id` untouched. That worked when
-- moves were intra-drive (drive_id never changed), but after D5 the
-- `forbid_cross_drive_move` policy gate exposed the gap: a successful
-- cross-drive move (gate off OR not yet enforced) leaves the subtree
-- in an inconsistent state — lpath rooted in drive B but `drive_id`
-- column still drive A on every descendant row. Any drive-id-scoped
-- query then returns the wrong drive's content.
--
-- The fix is to extend the cascade trigger so a change in the parent
-- folder's `drive_id` (the only thing that changes drive_id during a
-- move) cascades to every descendant folder + every descendant file.
-- Files cascade too because `storage.files.drive_id` is the canonical
-- per-file drive-membership signal (D0 dual-write).
--
-- Migration is idempotent via `CREATE OR REPLACE FUNCTION`.
CREATE OR REPLACE FUNCTION storage.cascade_folder_path()
RETURNS trigger AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
IF OLD.path IS DISTINCT FROM NEW.path OR OLD.lpath IS DISTINCT FROM NEW.lpath THEN
-- Single batch update: rewrite path/lpath for every descendant
-- folder at once via the GiST lpath index.
UPDATE storage.folders
SET path = NEW.path || substr(path, length(OLD.path) + 1),
lpath = NEW.lpath || subpath(lpath, nlevel(OLD.lpath))
WHERE lpath <@ OLD.lpath
AND id != NEW.id;
END IF;
-- D6: cascade `drive_id` to every descendant folder + file when the
-- moved row's drive_id has changed (cross-drive move). The GiST
-- index covers the folder predicate; `storage.files.drive_id` is
-- updated through the folder→file FK relation since files only
-- carry `folder_id` directly (drive_id is a denormalised dual-write).
--
-- Triggered on the column-list `AFTER UPDATE OF path, lpath, drive_id`
-- registration below — so this branch only runs when the explicit
-- move statement on the moved row sets `drive_id` to a new value.
-- The descendant batch UPDATE that fires from the path/lpath branch
-- above doesn't touch drive_id, so the trigger doesn't recurse on
-- the per-descendant rewrite.
IF OLD.drive_id IS DISTINCT FROM NEW.drive_id THEN
UPDATE storage.folders
SET drive_id = NEW.drive_id
WHERE lpath <@ NEW.lpath
AND drive_id = OLD.drive_id;
UPDATE storage.files f
SET drive_id = NEW.drive_id
FROM storage.folders fo
WHERE f.folder_id = fo.id
AND fo.lpath <@ NEW.lpath
AND f.drive_id = OLD.drive_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Re-register the trigger with `drive_id` added to the column list so the
-- trigger fires when a move sets a new drive_id on the moved row. (CREATE
-- OR REPLACE TRIGGER replaces the same name in place; no DROP needed.)
CREATE OR REPLACE TRIGGER trg_folders_cascade_path
AFTER UPDATE OF path, lpath, drive_id ON storage.folders
FOR EACH ROW EXECUTE FUNCTION storage.cascade_folder_path();
@@ -249,18 +249,25 @@ impl DriveManagementService {
.set_role(caller_id, subject, role, resource, expires_at)
.await?;
if caller_is_admin {
tracing::info!(
target: "audit",
event = "drive_membership.set_via_admin",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
role = role.as_str(),
by = %caller_id,
"👮🏻‍♂️ admin set drive member role bypassing Manage check",
);
}
// D6 §11: canonical `drive.member_added` audit event covers
// every successful membership write (add + role-refresh, since
// the underlying `set_role` is UPSERT — distinguishing the two
// would require an additional read and bring no extra ops
// value). `via_admin` carries the bypass signal that used to
// live in a separate `drive_membership.set_via_admin` event;
// log aggregators now have one canonical name per operation.
tracing::info!(
target: "audit",
event = "drive.member_added",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
role = role.as_str(),
via_admin = caller_is_admin,
by = %caller_id,
expires_at = ?expires_at,
"🤝 drive member added",
);
Ok(grant)
}
@@ -305,17 +312,21 @@ impl DriveManagementService {
self.authz.clear_role(subject, resource).await?;
if caller_is_admin {
tracing::info!(
target: "audit",
event = "drive_membership.removed_via_admin",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
by = %caller_id,
"👮🏻‍♂️ admin removed drive member bypassing Manage check",
);
}
// D6 §11: canonical `drive.member_removed` audit event covers
// every successful removal (owner-driven or admin bypass).
// `via_admin` replaces the separate
// `drive_membership.removed_via_admin` event — single name,
// one boolean field for the bypass signal.
tracing::info!(
target: "audit",
event = "drive.member_removed",
drive_id = %drive_id,
subject_type = subject.type_str(),
subject_id = %subject.id(),
via_admin = caller_is_admin,
by = %caller_id,
"👋 drive member removed",
);
Ok(())
}
@@ -302,13 +302,13 @@ impl FileManagementUseCase for FileManagementService {
self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id)
.await?;
// D5 `forbid_cross_drive_move`: refuse when the destination
// folder belongs to a different drive than the source file and
// the source drive's policy is on. Silently skipped if the
// drive repo isn't wired (stub builders) or the move target is
// None (root namespace — same-drive semantics). Source policy
// is canonical per §8: the drive that owns the content
// controls outbound moves.
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives` audit
// share the same src/dst drive_id lookup: the gate refuses
// before the move; the audit fires after a successful move
// when the two drives differ. Silently skipped if the drive
// repo isn't wired (stub builders) or the move target is None
// (root namespace — same-drive semantics).
let mut cross_drive: Option<(Uuid, Uuid)> = None;
if let Some(drive_repo) = &self.drive_repo
&& let Some(target_folder_id) = folder_id.as_deref()
{
@@ -338,10 +338,29 @@ impl FileManagementUseCase for FileManagementService {
dst_drive_id,
},
)?;
cross_drive = Some((src_drive_id, dst_drive_id));
}
}
self.move_file(file_id, folder_id, caller_id).await
let dto = self.move_file(file_id, folder_id, caller_id).await?;
// D6 §11 audit: emit only when the move actually crossed a
// drive boundary. Same-drive moves are too noisy to audit at
// info — operators care about the cross-drive case for
// exfiltration / quota tracking.
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
tracing::info!(
target: "audit",
event = "resource.moved_between_drives",
resource_type = "file",
resource_id = %dto.id,
src_drive_id = %src_drive_id,
dst_drive_id = %dst_drive_id,
by = %caller_id,
"📦 file moved between drives",
);
}
Ok(dto)
}
async fn copy_file_with_perms(
+22 -2
View File
@@ -558,11 +558,13 @@ impl FolderUseCase for FolderService {
// TODO: full descendant-cycle check (moving a folder into one of its own descendants)
}
// D5 `forbid_cross_drive_move`: refuse when src and dst sit in
// different drives and the source drive's policy is on.
// D5 `forbid_cross_drive_move` + D6 `resource.moved_between_drives`
// audit share the same src/dst lookup. Gate before the move,
// audit after a successful move when the two drives differ.
// Skipped for parent_id=None (root namespace, same-drive
// semantics) and when drive_repo isn't wired (stubs/tests) —
// same shape as `move_file_with_perms`.
let mut cross_drive: Option<(Uuid, Uuid)> = None;
if let Some(drive_repo) = &self.drive_repo
&& let Some(parent_id) = &dto.parent_id
{
@@ -592,6 +594,7 @@ impl FolderUseCase for FolderService {
dst_drive_id,
},
)?;
cross_drive = Some((src_drive_id, dst_drive_id));
}
}
@@ -607,6 +610,23 @@ impl FolderUseCase for FolderService {
)
})?;
// D6 audit: only emit when the move crossed a drive boundary.
// The cascade trigger has already propagated drive_id to the
// subtree at this point (see migration
// `20260807000000_cascade_drive_id_on_folder_move.sql`).
if let Some((src_drive_id, dst_drive_id)) = cross_drive {
tracing::info!(
target: "audit",
event = "resource.moved_between_drives",
resource_type = "folder",
resource_id = %folder.id(),
src_drive_id = %src_drive_id,
dst_drive_id = %dst_drive_id,
by = %caller_id,
"📦 folder moved between drives",
);
}
Ok(FolderDto::from(folder))
}
@@ -657,17 +657,32 @@ impl FolderRepository for FolderDbRepository {
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
//
// §14: `updated_by = $3` (caller_id), see rename_folder.
//
// D6: also sync `drive_id` from the destination parent on
// cross-drive moves. The CTE-derived `dest.drive_id` is
// assigned via COALESCE so a root-level move (no destination —
// `new_parent_id = NULL`) keeps the existing drive_id, mirroring
// the file move path. The cascade trigger
// (`cascade_folder_path`) then propagates the new drive_id to
// every descendant folder + file in the subtree — see
// `migrations/20260807000000_cascade_drive_id_on_folder_move.sql`.
let row = retry_on_deadlock("folders.move", || {
sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
created_by, updated_by
WITH dest AS (
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
)
UPDATE storage.folders f
SET parent_id = $1::uuid,
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
updated_at = NOW(),
updated_by = $3
WHERE f.id = $2::uuid AND NOT f.is_trashed
RETURNING f.id::text, f.name, f.path, f.parent_id::text, f.user_id, f.drive_id,
EXTRACT(EPOCH FROM f.created_at)::bigint,
EXTRACT(EPOCH FROM f.updated_at)::bigint,
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
f.created_by, f.updated_by
"#,
)
.bind(new_parent_id)
+325
View File
@@ -0,0 +1,325 @@
# =============================================================
# OxiCloud — D6 cross-drive move + drive_id cascade
# =============================================================
# Run:
# hurl --variables-file tests/api/test.env --file-root tests \
# --test tests/api/cross_drive_move.hurl
#
# Verifies:
# 1. File moved across drives lands in the destination drive's
# subtree AND the file row's `drive_id` syncs to the
# destination (observed via the per-drive quota sweep:
# source `used_bytes` drops, target rises).
# 2. Folder moved across drives ALSO syncs `drive_id` on every
# descendant — the cascade trigger added by migration
# `20260807000000_cascade_drive_id_on_folder_move.sql` is
# the load-bearing piece. Verified by moving a folder with
# a file inside and watching the destination drive's
# `used_bytes` jump by the descendant's size (not 0).
#
# Sweep convergence: `/api/admin/internal/trigger-sweep` is the
# deterministic synchronisation point — it recomputes every
# drive's cached `used_bytes` from `SUM(file.size) WHERE
# drive_id = d.id`. If the file/folder move didn't update
# `drive_id`, the sweep would re-attribute size to the WRONG
# drive (or none), and the assertion below would fail.
#
# `forbid_cross_drive_move` policy refusal is covered by
# `tests/api/drive_policies.hurl` Step 11b — this scenario uses
# the policy OFF (the default) to exercise the happy path.
#
# Self-contained: provisions `dm_owner` and a fresh shared drive
# so it can run alongside the rest of the suite.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin login.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Provision `dm_owner`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "dm_owner",
"password": "DmOwnerPwd1!",
"email": "dm_owner@example.com",
"role": "user"
}
HTTP 201
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "dm_owner", "password": "DmOwnerPwd1!" }
HTTP 200
[Captures]
owner_token: jsonpath "$.access_token"
owner_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Capture the user's default Personal drive + root.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{owner_token}}
HTTP 200
[Captures]
personal_root_id: jsonpath "$[0].id"
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Captures]
personal_drive_id: jsonpath "$[0].id"
[Asserts]
jsonpath "$[0].kind" == "personal"
# ─────────────────────────────────────────────────────────────
# Step 4 — Admin creates a shared drive owned by dm_owner.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "dm-shared",
"owner": { "type": "user", "id": "{{owner_user_id}}" }
}
HTTP 201
[Captures]
shared_drive_id: jsonpath "$.id"
shared_root_id: jsonpath "$.root_folder_id"
# ─────────────────────────────────────────────────────────────
# Step 5 — Upload hello.txt (32 B) into the personal drive root.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{owner_token}}
[MultipartFormData]
folder_id: {{personal_root_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
file_id: jsonpath "$.id"
# Baseline used_bytes after the upload settles. Trigger-sweep is
# the deterministic sync point — without it the fire-and-forget
# delta hook may not yet have landed in the row when we read it.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
# ─────────────────────────────────────────────────────────────
# Step 6 — Move hello.txt across drives → shared root.
#
# Observable behaviour: after the sweep, the source drive's
# used_bytes drops to 0 and the destination's rises to 32. The
# only way this happens is if `storage.files.drive_id` was
# updated on the move (the sweep recomputes from
# `SUM(size) WHERE drive_id = d.id`). The move_file SQL already
# syncs drive_id from the destination — this asserts it still
# does post-D6.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/files/{{file_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_id": "{{shared_root_id}}"
}
HTTP 200
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 0
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
# Confirm the file is now visible under the shared drive's root
# (cross-drive Read is fine — dm_owner is Owner on both).
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.items[?(@.resource.id=='{{file_id}}')].resource_type" == "file"
# ─────────────────────────────────────────────────────────────
# Step 7 — Folder move across drives, with a child file inside.
# The cascade trigger MUST propagate the new drive_id
# to the moved folder AND every descendant (folder +
# file). Verified by moving the folder, then sweeping —
# if the trigger doesn't fire, the descendant file's
# drive_id stays at the source drive and the sweep
# attributes its size to the wrong drive.
#
# First, move hello.txt back to the personal drive so the
# baseline for the next case is clean (and so the source-drive
# `used_bytes` reflects only what we're about to nest below).
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/files/{{file_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_id": "{{personal_root_id}}"
}
HTTP 200
# Create a folder under personal root, with hello-copy.txt inside.
POST {{base_url}}/api/folders
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{ "name": "dm-subtree", "parent_id": "{{personal_root_id}}" }
HTTP 201
[Captures]
subtree_id: jsonpath "$.id"
POST {{base_url}}/api/files/upload
Authorization: Bearer {{owner_token}}
[MultipartFormData]
folder_id: {{subtree_id}}
file: file,fixtures/hello-copy.txt; text/plain
HTTP 201
[Captures]
nested_file_id: jsonpath "$.id"
# Baseline post-creation. Personal holds both hello.txt (32 B) +
# nested hello-copy.txt (32 B) = 64. Shared is empty.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 64
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 0
# Move the SUBTREE FOLDER (with its nested file) into the shared
# drive's root.
PUT {{base_url}}/api/folders/{{subtree_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"parent_id": "{{shared_root_id}}"
}
HTTP 200
# The load-bearing assertion. After sweep:
# personal: hello.txt remains (32)
# shared: nested hello-copy.txt now charged here (32)
# Anything other than (32, 32) means the descendant file's
# drive_id wasn't cascaded by the trigger.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{personal_drive_id}}')].used_bytes" == 32
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
# Folder is visible in shared's listing.
GET {{base_url}}/api/folders/{{shared_root_id}}/resources?limit=50
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.items[?(@.resource.id=='{{subtree_id}}')].resource_type" == "folder"
# Descendant file is still inside the moved subtree (subtree
# integrity preserved). Drive_id sync is invisible at this
# endpoint, but the used_bytes assertion above already
# established it.
GET {{base_url}}/api/folders/{{subtree_id}}/resources?limit=50
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
jsonpath "$.items[?(@.resource.id=='{{nested_file_id}}')].resource_type" == "file"
# ─────────────────────────────────────────────────────────────
# Step 8 — Cleanup. Move both files back to the personal drive's
# root + delete the subtree folder + delete the shared
# drive (must be empty), then the test user.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/files/{{nested_file_id}}/move
Authorization: Bearer {{owner_token}}
Content-Type: application/json
{
"folder_id": "{{personal_root_id}}"
}
HTTP 200
DELETE {{base_url}}/api/folders/{{subtree_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
Authorization: Bearer {{owner_token}}
HTTP 204
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
Authorization: Bearer {{admin_token}}
HTTP 200
+2 -1
View File
@@ -164,7 +164,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/trash_per_drive.hurl" \
"$API_DIR/drive_quota.hurl" \
"$API_DIR/user_envelope_quota.hurl" \
"$API_DIR/drive_policies.hurl"
"$API_DIR/drive_policies.hurl" \
"$API_DIR/cross_drive_move.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"