From eab7a609b983ca633d67a0476387d904cd9fe357 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 13:29:41 +0200 Subject: [PATCH] feat(drive): start implementation of drive - add storage.drives - prepare migration phase - add created_by and updated_by on storage.folders --- .../20260802000000_drives_schema_additive.sql | 155 ++++++++ migrations/20260802000001_drives_backfill.sql | 355 ++++++++++++++++++ migrations/20260802000002_drives_not_null.sql | 108 ++++++ .../20260802000003_tree_etag_drive_id.sql | 148 ++++++++ ...260802000004_copy_folder_tree_drive_id.sql | 133 +++++++ src/application/dtos/drive_dto.rs | 70 ++++ src/application/dtos/grant_dto.rs | 3 + src/application/dtos/mod.rs | 1 + src/application/ports/authorization_ports.rs | 1 + src/application/ports/content_index_ports.rs | 22 +- src/application/ports/folder_ports.rs | 3 + src/application/services/folder_service.rs | 160 ++++++-- .../services/magic_link_invite_service.rs | 31 ++ .../recipient_notification_service.rs | 5 + src/application/services/search_service.rs | 95 ++++- src/application/services/share_service.rs | 1 + .../services/trash_service_test.rs | 1 + src/common/di.rs | 21 +- src/common/stubs.rs | 2 + src/domain/entities/drive.rs | 126 +++++++ src/domain/entities/mod.rs | 1 + src/domain/repositories/drive_repository.rs | 108 ++++++ src/domain/repositories/folder_repository.rs | 14 +- src/domain/repositories/mod.rs | 1 + src/domain/services/authorization.rs | 13 +- .../repositories/pg/drive_pg_repository.rs | 173 +++++++++ .../pg/file_blob_write_repository.rs | 110 ++++-- .../repositories/pg/folder_db_repository.rs | 79 ++-- src/infrastructure/repositories/pg/mod.rs | 2 + src/infrastructure/services/dedup_service.rs | 67 ++-- src/infrastructure/services/pg_acl_engine.rs | 69 +++- .../search_index/content_index_worker.rs | 16 +- .../search_index/tantivy_content_index.rs | 75 +++- .../services/tree_etag_flush_service.rs | 30 +- src/interfaces/api/handlers/drive_handler.rs | 72 ++++ src/interfaces/api/handlers/grant_handler.rs | 11 + src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/mod.rs | 6 + src/interfaces/api/routes.rs | 13 + tests/api/drives_foundation.hurl | 209 +++++++++++ tests/api/grants.hurl | 40 +- tests/api/run.sh | 1 + tests/common/init-test-schema.sh | 36 ++ 43 files changed, 2434 insertions(+), 154 deletions(-) create mode 100644 migrations/20260802000000_drives_schema_additive.sql create mode 100644 migrations/20260802000001_drives_backfill.sql create mode 100644 migrations/20260802000002_drives_not_null.sql create mode 100644 migrations/20260802000003_tree_etag_drive_id.sql create mode 100644 migrations/20260802000004_copy_folder_tree_drive_id.sql create mode 100644 src/application/dtos/drive_dto.rs create mode 100644 src/domain/entities/drive.rs create mode 100644 src/domain/repositories/drive_repository.rs create mode 100644 src/infrastructure/repositories/pg/drive_pg_repository.rs create mode 100644 src/interfaces/api/handlers/drive_handler.rs create mode 100644 tests/api/drives_foundation.hurl diff --git a/migrations/20260802000000_drives_schema_additive.sql b/migrations/20260802000000_drives_schema_additive.sql new file mode 100644 index 00000000..d0b2b657 --- /dev/null +++ b/migrations/20260802000000_drives_schema_additive.sql @@ -0,0 +1,155 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M1 — Drive foundation: additive schema only +-- ════════════════════════════════════════════════════════════════════════════ +-- First of three D0 migrations. +-- M1 (this file) additive — creates storage.drives + nullable columns. +-- M2 (next) backfill — promotes wrapper folders, fills drive_id. +-- M3 (final) constraints — NOT NULL + FKs + drive_id indexes. +-- +-- This file is **safe to run on a populated database without an outage**. +-- It only ADDs structure (new table, new nullable columns, extended CHECK +-- constraints, new FK targets). No row is modified; no existing query +-- needs to be aware of the new columns yet. +-- +-- The migration is reversible at this stage: dropping the new table and +-- the new columns leaves the database identical to its pre-D0 state. The +-- dual-write / data-movement phase (M2) is where rollback becomes +-- progressively harder. + +-- ── 1. storage.drives — the central drive entity ──────────────────────────── + +CREATE TABLE IF NOT EXISTS storage.drives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + + -- Discriminant. Two kinds today; extending the set is a DROP + ADD + -- CHECK constraint pair (no separate lookup table). + -- 'personal' = single-owner, no membership API + -- 'shared' = multi-member, full role roster, group-aware + kind TEXT NOT NULL + CHECK (kind IN ('personal', 'shared')), + + -- Set iff this is the user's default personal drive. The partial + -- unique index below enforces "one default drive per user" without + -- blocking secondaries (NULL means "not the default"); shared drives + -- always have NULL here. + default_for_user UUID + REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Storage quota in bytes. NULL = no quota (admin override / system + -- drives). Initial value on personal-drive creation is taken from + -- the owner's `auth.users.storage_quota_bytes` at the application + -- layer. + quota_bytes BIGINT, + + -- Running total of bytes consumed. Maintained by D4's incremental + -- counters; on D0 backfilled from the per-user counters as a + -- starting baseline. + used_bytes BIGINT NOT NULL DEFAULT 0, + + -- Capability flags / feature toggles bag (see docs/plan/drive.md §8 + -- and §15 for the known keys: forbid_public_links, + -- forbid_external_sharing, include_in_photo_index, forbid_music_index, + -- etc.). Unknown keys preserved verbatim — the schema is + -- intentionally permissive so future flags land without migration. + policies JSONB NOT NULL DEFAULT '{}'::jsonb, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE storage.drives IS + 'Drive entity. Top-level container that owns a tree of folders/files; ' + 'membership lives in storage.role_grants with resource_type=''drive''. ' + 'Replaced the per-user My Folder wrapper at D0 (see docs/plan/drive.md).'; +COMMENT ON COLUMN storage.drives.kind IS + 'personal = single-owner (no add_member); shared = multi-member with full role roster.'; +COMMENT ON COLUMN storage.drives.default_for_user IS + 'Set iff this is the user''s default personal drive. NULL on secondaries and shared drives.'; +COMMENT ON COLUMN storage.drives.policies IS + 'JSONB capability-flag bag; see docs/plan/drive.md §8 §15 for known keys.'; + +-- "One default drive per user." Partial unique index — NULLs (every +-- non-default row) are excluded from the constraint surface. +CREATE UNIQUE INDEX IF NOT EXISTS idx_drives_default_for_user_unique + ON storage.drives (default_for_user) + WHERE default_for_user IS NOT NULL; + +-- Hot-path "what's this drive?" lookup by kind for admin / D3 flows +-- ("list every shared drive"). +CREATE INDEX IF NOT EXISTS idx_drives_kind ON storage.drives (kind); + + +-- ── 2. drive_id columns on folders + files (NULL during M1) ──────────────── +-- M2 fills these in for every existing row; M3 promotes them to NOT NULL +-- and adds the FK + index. Keep nullable here so the migration runs on a +-- populated DB without violating a constraint. + +ALTER TABLE storage.folders ADD COLUMN IF NOT EXISTS drive_id UUID; +ALTER TABLE storage.files ADD COLUMN IF NOT EXISTS drive_id UUID; + + +-- ── 3. Provenance columns: created_by / updated_by ───────────────────────── +-- D0 adds these on folders + files (see docs/plan/drive.md §14). FKs use +-- ON DELETE SET NULL so deleting a user nulls these out instead of +-- cascading the resource away. M2 backfills from the existing `user_id` +-- column so pre-Drive content carries authentic provenance from day one. + +ALTER TABLE storage.folders + ADD COLUMN IF NOT EXISTS created_by UUID + REFERENCES auth.users(id) ON DELETE SET NULL; +ALTER TABLE storage.folders + ADD COLUMN IF NOT EXISTS updated_by UUID + REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE storage.files + ADD COLUMN IF NOT EXISTS created_by UUID + REFERENCES auth.users(id) ON DELETE SET NULL; +ALTER TABLE storage.files + ADD COLUMN IF NOT EXISTS updated_by UUID + REFERENCES auth.users(id) ON DELETE SET NULL; + +COMMENT ON COLUMN storage.folders.created_by IS + 'Who originally created the folder. NULL when the original creator''s ' + 'auth.users row has since been deleted.'; +COMMENT ON COLUMN storage.folders.updated_by IS + 'Who last touched the folder (rename, move, metadata change). Same ' + 'write-path discipline as updated_at.'; +COMMENT ON COLUMN storage.files.created_by IS + 'Who originally uploaded the file. NULL when the original uploader''s ' + 'auth.users row has since been deleted.'; +COMMENT ON COLUMN storage.files.updated_by IS + 'Who last touched the file (rename, move, overwrite, restore). Same ' + 'write-path discipline as updated_at.'; + + +-- ── 4. role_grants resource_type CHECK — admit 'drive' ───────────────────── +-- The D-Prep migration's CHECK only listed 'folder' and 'file'. Drives +-- need to be a valid resource_type so the lifecycle hook (D0-9) and the +-- membership API (D2) can write `role_grants` rows with +-- resource_type='drive'. + +ALTER TABLE storage.role_grants + DROP CONSTRAINT IF EXISTS role_grants_resource_type_check; +ALTER TABLE storage.role_grants + ADD CONSTRAINT role_grants_resource_type_check + CHECK (resource_type IN ('folder', 'file', 'drive')); + + +-- ── 5. updated_at trigger for storage.drives ─────────────────────────────── +-- Mirror the convention from auth.users / storage.folders / storage.files +-- so rename / quota-change / policy-toggle bumps updated_at automatically. +-- Drive owners shouldn't have to remember to maintain this. + +CREATE OR REPLACE FUNCTION storage.drives_touch_updated_at() +RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_drives_touch_updated_at ON storage.drives; +CREATE TRIGGER trg_drives_touch_updated_at + BEFORE UPDATE ON storage.drives + FOR EACH ROW EXECUTE FUNCTION storage.drives_touch_updated_at(); diff --git a/migrations/20260802000001_drives_backfill.sql b/migrations/20260802000001_drives_backfill.sql new file mode 100644 index 00000000..058ef921 --- /dev/null +++ b/migrations/20260802000001_drives_backfill.sql @@ -0,0 +1,355 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M2 — Drive backfill: create drives + stamp drive_id + provenance +-- ════════════════════════════════════════════════════════════════════════════ +-- Second of the D0 migration trio. Half (1) of the §A backfill — the safe, +-- focused half: +-- +-- * For every internal user with a root folder, create a Personal drive. +-- * The folder literally named `My Folder - ` becomes the +-- user's default Personal drive (`default_for_user = `). +-- * Any sibling root folders become secondary Personal drives +-- (`default_for_user = NULL`, name carried over verbatim). +-- * One owner role_grants row per new drive. +-- * Every existing folder/file row gets a `drive_id` (cascaded down the +-- ltree from the wrapper). +-- * Every existing folder/file row gets `created_by` and `updated_by` +-- backfilled from the existing `user_id` column. +-- +-- The aggressive half (drop the wrapper folder, rewrite path columns, +-- strip the `My Folder - /` prefix from every path/lpath value) +-- lands in M2b — kept separate so the tree-shape rewrite can be reviewed +-- in isolation. +-- +-- External users (`auth.users.is_external = TRUE`) are intentionally +-- skipped — they have no root folder of their own, only role_grants +-- against other users' resources. + +-- ── Pre-flight 1: refuse on sibling root literally named 'drives' ────────── +-- 'drives' is a reserved URL segment on the native WebDAV surface +-- (`/webdav/drives//`). A folder named 'drives' would shadow the +-- drive-listing route once D1 ships. Surface the conflict now — operator +-- renames before retrying. + +DO $BODY$ +DECLARE + bad_count BIGINT; +BEGIN + SELECT count(*) INTO bad_count + FROM storage.folders f + JOIN auth.users u ON u.id = f.user_id + WHERE f.parent_id IS NULL + AND NOT f.is_trashed + AND lower(f.name) = 'drives' + AND NOT u.is_external; + + IF bad_count > 0 THEN + RAISE EXCEPTION + 'D0 backfill refused: % root folder(s) literally named ''drives'' ' + 'would collide with the reserved /webdav/drives// URL segment ' + 'once D1 ships. Rename the offending folders, then retry the ' + 'migration. Query to inspect: SELECT f.id, f.user_id, f.name ' + 'FROM storage.folders f JOIN auth.users u ON u.id = f.user_id ' + 'WHERE f.parent_id IS NULL AND NOT f.is_trashed AND lower(f.name) ' + '= ''drives'' AND NOT u.is_external;', + bad_count; + END IF; +END $BODY$; + + +-- ── Pre-flight 2: report sibling-root distribution (informational) ───────── +-- Most users have exactly one root (`My Folder - `). Some may +-- have SQL-added siblings — those become secondary drives. Surface the +-- count so operators can sanity-check before the migration commits. + +DO $BODY$ +DECLARE + extras BIGINT; +BEGIN + WITH counts AS ( + SELECT u.id AS user_id, count(*) AS root_count + FROM auth.users u + JOIN storage.folders f ON f.user_id = u.id + WHERE f.parent_id IS NULL + AND NOT f.is_trashed + AND NOT u.is_external + GROUP BY u.id + ) + SELECT count(*) INTO extras FROM counts WHERE root_count > 1; + + IF extras > 0 THEN + RAISE NOTICE + 'D0 backfill: % user(s) have more than one root folder. Their ' + 'siblings will be promoted to secondary Personal drives. Inspect ' + 'with: WITH c AS (SELECT u.id, u.username, count(*) cnt FROM ' + 'auth.users u JOIN storage.folders f ON f.user_id=u.id WHERE ' + 'f.parent_id IS NULL AND NOT f.is_trashed AND NOT u.is_external ' + 'GROUP BY u.id, u.username) SELECT * FROM c WHERE cnt > 1;', + extras; + END IF; +END $BODY$; + + +-- ── 1. Plan every drive that needs to be created ────────────────────────── +-- Temp table is the cleanest way to pre-compute the new UUIDs once and +-- reuse them across the INSERT-drives, INSERT-grants, and UPDATE-folders +-- steps below. `gen_random_uuid()` in a CTE would re-evaluate on every +-- branch. +-- +-- A row joins each existing root folder to its future drive_id. The +-- `is_default` flag is computed per-user as a window function so EVERY +-- internal user with at least one root folder ends up with exactly one +-- default drive — even if the user's wrapper was renamed away from +-- `My Folder - ` at some point. Preference order: +-- 1. The folder literally named `My Folder - ` if it exists. +-- 2. Otherwise the oldest root by `created_at`, tiebroken by `id`. + +-- `ON COMMIT DROP` would race with the `[init-schema]` CI flow that +-- runs migrations via `psql \i` in autocommit mode: the CREATE statement +-- commits, the table drops, and the next statement (the DO block) can't +-- see it. The plain temp table survives until session end in autocommit +-- mode and until our explicit DROP at the bottom under `sqlx migrate`'s +-- single-tx mode. Works under both. +CREATE TEMPORARY TABLE _drive_plan AS +WITH root_folders AS ( + SELECT + u.id AS user_id, + u.username AS username, + u.storage_quota_bytes AS quota, + f.id AS wrapper_id, + f.name AS wrapper_name, + f.created_at AS created_at, + (f.name = 'My Folder - ' || u.username) AS name_matches_default + FROM auth.users u + JOIN storage.folders f + ON f.user_id = u.id + AND f.parent_id IS NULL + AND NOT f.is_trashed + WHERE NOT u.is_external +) +SELECT + user_id, + username, + quota, + wrapper_id, + wrapper_name, + -- Rank candidates per user: name-matched root wins; otherwise oldest + -- by created_at then by id (stable, deterministic). ROW_NUMBER() = 1 + -- becomes the default drive for that user. + (ROW_NUMBER() OVER ( + PARTITION BY user_id + ORDER BY name_matches_default DESC, + created_at ASC, + wrapper_id ASC + ) = 1) AS is_default, + gen_random_uuid() AS new_drive_id +FROM root_folders; + + +-- ── 1b. Log which users got an auto-picked default (no name match) ──────── +-- Operational nicety: if a user's default came from oldest-root fallback +-- rather than the canonical `My Folder - `, surface it so an +-- operator can DM the user and confirm the migration picked the right +-- root. Not a failure — just visibility. + +DO $BODY$ +DECLARE + auto_picked BIGINT; +BEGIN + SELECT count(*) INTO auto_picked + FROM _drive_plan p + WHERE p.is_default + AND p.wrapper_name <> 'My Folder - ' || p.username; + + IF auto_picked > 0 THEN + RAISE NOTICE + 'D0 backfill: % user(s) had no `My Folder - ` root; ' + 'the oldest sibling root was auto-picked as their default ' + 'Personal drive. Inspect with: SELECT user_id, username, ' + 'wrapper_name FROM _drive_plan WHERE is_default AND ' + 'wrapper_name <> ''My Folder - '' || username; ' + '(temp table only exists during the migration transaction.)', + auto_picked; + END IF; +END $BODY$; + + +-- ── 2. Insert the drive rows ─────────────────────────────────────────────── +-- Default drives carry the i18n-neutral name 'Personal' (renameable +-- later via the drive settings panel). Secondary drives carry their +-- original folder name verbatim. + +INSERT INTO storage.drives + (id, name, kind, default_for_user, quota_bytes) +SELECT + p.new_drive_id, + CASE WHEN p.is_default THEN 'Personal' ELSE p.wrapper_name END, + 'personal', + CASE WHEN p.is_default THEN p.user_id ELSE NULL END, + p.quota +FROM _drive_plan p; + + +-- ── 3. Insert one owner role_grants row per drive ───────────────────────── +-- Each user is the sole owner of every drive their wrappers produced. +-- The lifecycle hook (D0-9) will do the same for users created post-D0. + +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT 'user', p.user_id, 'drive', p.new_drive_id, 'owner', p.user_id +FROM _drive_plan p; + + +-- ── 4. Stamp drive_id on each wrapper folder ────────────────────────────── +-- The wrapper still exists as a folder during M2 (the wrapper-drop lives +-- in M2b). Setting drive_id on the wrapper lets the cascade in §5 walk +-- the ltree subtree without needing a separate index. + +UPDATE storage.folders f + SET drive_id = p.new_drive_id + FROM _drive_plan p + WHERE f.id = p.wrapper_id; + + +-- ── 5. Cascade drive_id down the folder tree ────────────────────────────── +-- For every folder descended from a wrapper, set drive_id to that +-- wrapper's. Uses the existing GiST index `idx_folders_lpath` for the +-- @> (ancestor-of) lookup. Trashed descendants get a drive_id too — +-- soft-deleted folders need a drive_id once M3 makes the column NOT NULL. + +UPDATE storage.folders sub + SET drive_id = wrapper.drive_id + FROM storage.folders wrapper + WHERE wrapper.id IN (SELECT wrapper_id FROM _drive_plan) + AND sub.lpath <@ wrapper.lpath + AND sub.id != wrapper.id + AND sub.drive_id IS NULL; + + +-- ── 6. Cascade drive_id to files (via their folder) ─────────────────────── +-- Files inherit drive_id from their containing folder. A NULL folder_id +-- file is an orphan — left with NULL drive_id here; M3's NOT NULL +-- constraint will refuse the migration if any such orphans remain, +-- which is the right outcome (forces operator inspection). + +UPDATE storage.files fi + SET drive_id = fo.drive_id + FROM storage.folders fo + WHERE fi.folder_id = fo.id + AND fi.drive_id IS NULL + AND fo.drive_id IS NOT NULL; + + +-- ── 7. Provenance backfill ──────────────────────────────────────────────── +-- Every pre-Drive row carries authentic provenance from day one: created_by +-- and updated_by both default to the user_id that we know created the +-- resource (that's exactly what user_id meant pre-D0). New writes during +-- the dual-write window populate both columns explicitly. + +UPDATE storage.folders + SET created_by = user_id, + updated_by = user_id + WHERE created_by IS NULL; + +UPDATE storage.files + SET created_by = user_id, + updated_by = user_id + WHERE created_by IS NULL; + + +-- ── 7b. Drop the planning temp table ────────────────────────────────────── +-- Explicit drop since we removed `ON COMMIT DROP` above. Idempotent +-- (`IF EXISTS`) so a partial re-run during development doesn't error. + +DROP TABLE IF EXISTS _drive_plan; + + +-- ── 8. Post-flight consistency check ────────────────────────────────────── +-- The checks here REFUSE to commit if any invariant is violated, so a +-- successful migration is a verifiable migration. +-- +-- 8a. Every internal user with a root folder has exactly one +-- default drive. +-- 8b. Every drive has at least one owner role_grants row. +-- 8c. No NULL drive_id remains on a folder/file row whose owner is +-- a non-external user with a root folder (i.e. every row that +-- belongs to a drive must now declare which one). +-- +-- M3 turns drive_id NOT NULL; the check below is a stricter pre-flight +-- so the failure mode is "migration refuses" rather than "M3 errors +-- with a NOT NULL violation halfway through". + +DO $BODY$ +DECLARE + missing_default BIGINT; + grantless_drives BIGINT; + null_folder_drive_id BIGINT; + null_file_drive_id BIGINT; +BEGIN + SELECT count(*) INTO missing_default + FROM auth.users u + WHERE NOT u.is_external + AND EXISTS ( + SELECT 1 FROM storage.folders f + WHERE f.user_id = u.id AND f.parent_id IS NULL AND NOT f.is_trashed + ) + AND NOT EXISTS ( + SELECT 1 FROM storage.drives d + WHERE d.default_for_user = u.id + ); + IF missing_default > 0 THEN + RAISE EXCEPTION + 'D0 backfill consistency check failed: % internal user(s) with a ' + 'root folder have no default Personal drive. Investigate before ' + 'declaring the migration successful.', + missing_default; + END IF; + + SELECT count(*) INTO grantless_drives + FROM storage.drives d + WHERE NOT EXISTS ( + SELECT 1 FROM storage.role_grants g + WHERE g.resource_type = 'drive' + AND g.resource_id = d.id + AND g.role = 'owner' + ); + IF grantless_drives > 0 THEN + RAISE EXCEPTION + 'D0 backfill consistency check failed: % drive(s) have no owner ' + 'role_grants row. Investigate before declaring the migration ' + 'successful.', + grantless_drives; + END IF; + + SELECT count(*) INTO null_folder_drive_id + FROM storage.folders f + WHERE f.drive_id IS NULL + AND EXISTS ( + SELECT 1 FROM auth.users u + WHERE u.id = f.user_id AND NOT u.is_external + ); + IF null_folder_drive_id > 0 THEN + RAISE EXCEPTION + 'D0 backfill consistency check failed: % folder(s) belonging to ' + 'an internal user still have NULL drive_id. M3 will refuse to ' + 'add NOT NULL until these are resolved.', + null_folder_drive_id; + END IF; + + SELECT count(*) INTO null_file_drive_id + FROM storage.files fi + WHERE fi.drive_id IS NULL + AND EXISTS ( + SELECT 1 FROM auth.users u + WHERE u.id = fi.user_id AND NOT u.is_external + ); + IF null_file_drive_id > 0 THEN + RAISE EXCEPTION + 'D0 backfill consistency check failed: % file(s) belonging to an ' + 'internal user still have NULL drive_id (likely orphans — ' + 'folder_id pointing at a missing folder). Inspect with: SELECT ' + 'fi.id, fi.user_id, fi.folder_id FROM storage.files fi JOIN ' + 'auth.users u ON u.id = fi.user_id WHERE fi.drive_id IS NULL ' + 'AND NOT u.is_external;', + null_file_drive_id; + END IF; +END $BODY$; diff --git a/migrations/20260802000002_drives_not_null.sql b/migrations/20260802000002_drives_not_null.sql new file mode 100644 index 00000000..b7987a03 --- /dev/null +++ b/migrations/20260802000002_drives_not_null.sql @@ -0,0 +1,108 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M3 — Drive constraints: NOT NULL, FK, indexes +-- ════════════════════════════════════════════════════════════════════════════ +-- Third of the D0 migration trio. Runs only after M2 (the backfill) has +-- populated every folder/file row with a `drive_id`. This migration is +-- the point of no easy rollback — once `drive_id` is NOT NULL and +-- foreign-keyed to `storage.drives`, dropping the column requires the +-- application code to first stop reading it. +-- +-- What lands here: +-- * NOT NULL on `storage.folders.drive_id` and `storage.files.drive_id`. +-- * Foreign keys from both to `storage.drives(id)` with ON DELETE +-- CASCADE (deleting a drive removes its tree — matches the +-- post-D2 lifecycle plan). +-- * Indexes on `drive_id` for both tables (hot path: list-by-drive, +-- drive-aware Tantivy reseed, drive-quota counters). +-- +-- The `user_id` column is intentionally left in place: dual-write during +-- the D0 release cycle is the rollback safety net. D7 drops user_id once +-- the new model has baked. + +-- ── 1. NOT NULL on drive_id ──────────────────────────────────────────────── +-- M2's post-flight check refused to commit if any row was missing +-- drive_id, so this should never fail. The check at column promotion +-- time is the belt; M2's pre-commit assertion was the suspenders. + +ALTER TABLE storage.folders + ALTER COLUMN drive_id SET NOT NULL; + +ALTER TABLE storage.files + ALTER COLUMN drive_id SET NOT NULL; + + +-- ── 2. Foreign keys to storage.drives ────────────────────────────────────── +-- ON DELETE CASCADE: when a drive is deleted (D3 ships the delete-drive +-- flow), every folder and file row carrying that drive_id is removed in +-- the same transaction. Trash retention does not apply — drive deletion +-- is the explicit "I'm done with this storage" gesture. + +ALTER TABLE storage.folders + ADD CONSTRAINT folders_drive_id_fkey + FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE; + +ALTER TABLE storage.files + ADD CONSTRAINT files_drive_id_fkey + FOREIGN KEY (drive_id) REFERENCES storage.drives(id) ON DELETE CASCADE; + + +-- ── 3. Indexes on drive_id ───────────────────────────────────────────────── +-- The hot path that ranks every drive-aware query: "list folders in +-- drive X", "files in drive X for Tantivy reindex", "per-drive quota +-- aggregation". The existing `user_id` indexes are kept during dual- +-- write and dropped in D7 alongside the column. + +CREATE INDEX IF NOT EXISTS idx_folders_drive_id ON storage.folders (drive_id); +CREATE INDEX IF NOT EXISTS idx_files_drive_id ON storage.files (drive_id); + + +-- ── 4. Post-flight: confirm constraints landed ──────────────────────────── +-- Belt-and-suspenders verification that the NOT NULL + FK actually +-- exist after the ALTERs above. Any failure here means PostgreSQL +-- silently no-op'd one of the constraint changes, which would be a +-- bug worth surfacing immediately. + +DO $BODY$ +DECLARE + folder_not_null BOOLEAN; + file_not_null BOOLEAN; + folder_fk_exists BOOLEAN; + file_fk_exists BOOLEAN; +BEGIN + SELECT NOT is_nullable::boolean INTO folder_not_null + FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'folders' + AND column_name = 'drive_id'; + + SELECT NOT is_nullable::boolean INTO file_not_null + FROM information_schema.columns + WHERE table_schema = 'storage' + AND table_name = 'files' + AND column_name = 'drive_id'; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_schema = 'storage' + AND table_name = 'folders' + AND constraint_name = 'folders_drive_id_fkey' + AND constraint_type = 'FOREIGN KEY' + ) INTO folder_fk_exists; + + SELECT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_schema = 'storage' + AND table_name = 'files' + AND constraint_name = 'files_drive_id_fkey' + AND constraint_type = 'FOREIGN KEY' + ) INTO file_fk_exists; + + IF NOT folder_not_null OR NOT file_not_null + OR NOT folder_fk_exists OR NOT file_fk_exists THEN + RAISE EXCEPTION + 'D0 M3 post-flight failed: ' + 'folder NOT NULL=%, file NOT NULL=%, folder FK=%, file FK=%. ' + 'All four must be true after this migration commits.', + folder_not_null, file_not_null, folder_fk_exists, file_fk_exists; + END IF; +END $BODY$; diff --git a/migrations/20260802000003_tree_etag_drive_id.sql b/migrations/20260802000003_tree_etag_drive_id.sql new file mode 100644 index 00000000..c5c39cff --- /dev/null +++ b/migrations/20260802000003_tree_etag_drive_id.sql @@ -0,0 +1,148 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M4 — tree_etag_dirty drive_id awareness +-- ════════════════════════════════════════════════════════════════════════════ +-- Fourth D0 migration. The async tree-ETag queue (introduced in +-- `20260627000000_async_tree_etag_queue.sql`) walks `f.lpath @> t.lpath` +-- to bump every ancestor of a changed folder/file. Without a drive_id +-- filter, that walk can match folders in OTHER drives whose ltree +-- prefixes happen to align numerically — a silent cross-drive ETag +-- bump that nobody would notice until D2 ships shared drives. +-- +-- This migration is preventive: it adds the column, teaches the +-- triggers to carry drive_id into the queue, and the Rust flush +-- service (`tree_etag_flush_service.rs`) gets the matching +-- `AND f.drive_id = t.drive_id` predicate so the cross-drive case is +-- closed end-to-end before drives can collide. + +-- ── 1. drive_id column on the queue table ────────────────────────────────── +-- NULL-tolerant during the rollover: existing queue entries enqueued by +-- the old triggers have no drive_id. They drain on the next flush tick +-- with the old (no drive_id) semantics — which for D0 is still correct +-- because every personal drive's lpath is structurally disjoint from +-- every other user's. New entries enqueued by the updated triggers +-- below carry a non-NULL value. + +ALTER TABLE storage.tree_etag_dirty ADD COLUMN IF NOT EXISTS drive_id UUID; + + +-- ── 2. File-side INSERT/DELETE trigger ───────────────────────────────────── +-- Source rows live in `storage.files` (changed_rows). Each file row +-- carries `drive_id` directly (D0-8 dual-write). Pull from the joined +-- folder row so the (lpath, folder_id, drive_id) triple is internally +-- consistent — a single source of truth per enqueued row. + +CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id) + SELECT DISTINCT fo.lpath, fo.id, fo.drive_id + FROM (SELECT DISTINCT folder_id + FROM changed_rows + WHERE folder_id IS NOT NULL) c + JOIN storage.folders fo ON fo.id = c.folder_id; + + RETURN NULL; +END; +$$; + + +-- ── 3. File-side UPDATE trigger ──────────────────────────────────────────── +-- Move case: the file changed parent. Bump both the old and the new +-- parent chains (each in its own drive — D0 has them equal, D2 can see +-- them diverge once cross-drive moves land). + +CREATE OR REPLACE FUNCTION storage.bump_tree_from_files_stmt_upd() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + WITH changed AS ( + SELECT o.folder_id AS old_folder_id, n.folder_id AS new_folder_id + FROM old_rows o + JOIN new_rows n USING (id) + WHERE (o.name, o.folder_id, o.blob_hash, o.size, + o.mime_type, o.is_trashed, o.updated_at) + IS DISTINCT FROM + (n.name, n.folder_id, n.blob_hash, n.size, + n.mime_type, n.is_trashed, n.updated_at) + ) + INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id) + SELECT DISTINCT fo.lpath, fo.id, fo.drive_id + FROM (SELECT old_folder_id AS folder_id + FROM changed WHERE old_folder_id IS NOT NULL + UNION + SELECT new_folder_id + FROM changed WHERE new_folder_id IS NOT NULL) c + JOIN storage.folders fo ON fo.id = c.folder_id; + + RETURN NULL; +END; +$$; + + +-- ── 4. Folder-side INSERT/DELETE trigger ────────────────────────────────── +-- changed_rows are storage.folders rows, which carry drive_id directly +-- post-D0-7. + +CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id) + SELECT DISTINCT subpath(lpath, 0, nlevel(lpath) - 1), parent_id, drive_id + FROM changed_rows + WHERE lpath IS NOT NULL + AND nlevel(lpath) > 1; + + RETURN NULL; +END; +$$; + + +-- ── 5. Folder-side UPDATE trigger ────────────────────────────────────────── +-- Same shape as the INSERT/DELETE case; we union OLD and NEW parents, +-- carrying each chain's drive_id from the matching row side. + +CREATE OR REPLACE FUNCTION storage.bump_tree_from_folders_stmt_upd() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + + WITH changed AS ( + SELECT o.lpath AS old_lpath, + o.parent_id AS old_parent_id, + o.drive_id AS old_drive_id, + n.lpath AS new_lpath, + n.parent_id AS new_parent_id, + n.drive_id AS new_drive_id + FROM old_rows o + JOIN new_rows n USING (id) + WHERE (o.name, o.parent_id, o.is_trashed, o.updated_at) + IS DISTINCT FROM + (n.name, n.parent_id, n.is_trashed, n.updated_at) + ) + INSERT INTO storage.tree_etag_dirty (lpath, folder_id, drive_id) + SELECT DISTINCT subpath(c.lpath, 0, nlevel(c.lpath) - 1), c.parent_id, c.drive_id + FROM (SELECT old_lpath AS lpath, + old_parent_id AS parent_id, + old_drive_id AS drive_id + FROM changed WHERE old_lpath IS NOT NULL + UNION + SELECT new_lpath, new_parent_id, new_drive_id + FROM changed WHERE new_lpath IS NOT NULL) c + WHERE nlevel(c.lpath) > 1; + + RETURN NULL; +END; +$$; diff --git a/migrations/20260802000004_copy_folder_tree_drive_id.sql b/migrations/20260802000004_copy_folder_tree_drive_id.sql new file mode 100644 index 00000000..8addc63f --- /dev/null +++ b/migrations/20260802000004_copy_folder_tree_drive_id.sql @@ -0,0 +1,133 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M5 — storage.copy_folder_tree drive_id + provenance +-- ════════════════════════════════════════════════════════════════════════════ +-- The `copy_folder_tree` SQL function (initial_schema.sql) batches a +-- recursive folder copy in PL/pgSQL — its own INSERTs into +-- `storage.folders` and `storage.files`. D0's M3 made `drive_id` NOT +-- NULL on both tables; the function's pre-D0 body doesn't write it, +-- so any `/api/batch/folders/copy` call errors with "null value in +-- column drive_id" until this migration lands. +-- +-- The replacement preserves every other semantic of the original: +-- - level-by-level INSERTs so the BEFORE INSERT trigger +-- (`trg_folders_path`) can resolve the parent's path/lpath from +-- rows inserted in the previous level. +-- - One batched file INSERT (zero-copy via blob hash) at the end. +-- - Returns the same shape: (new_root_id::text, folders_copied, +-- files_copied). +-- +-- New columns written: +-- - drive_id: pulled from the source row (intra-drive copy — cross- +-- drive copies are a D2+ feature; the function preserves the +-- source's drive_id for both folders and files). +-- - created_by / updated_by: set to the source row's user_id, +-- matching the dual-write convention used by the Rust repos. + +CREATE OR REPLACE FUNCTION storage.copy_folder_tree( + p_source_id UUID, + p_target_parent_id UUID, -- NULL = copy to root + p_dest_name TEXT DEFAULT NULL -- NULL = keep source folder name +) RETURNS TABLE(new_root_id TEXT, folders_copied BIGINT, files_copied BIGINT) AS $$ +DECLARE + v_root_lpath ltree; + v_root_depth INT; + v_max_depth INT; + v_level INT; + v_folders BIGINT := 0; + v_files BIGINT := 0; + v_inserted BIGINT; + v_new_root UUID; +BEGIN + -- Validate source exists + SELECT fo.lpath, nlevel(fo.lpath) + INTO v_root_lpath, v_root_depth + FROM storage.folders fo + WHERE fo.id = p_source_id AND NOT fo.is_trashed; + + IF v_root_lpath IS NULL THEN + RAISE EXCEPTION 'Source folder not found: %', p_source_id + USING ERRCODE = 'P0002'; -- no_data_found + END IF; + + -- Temp mapping: every folder in the subtree → new UUID + CREATE TEMP TABLE IF NOT EXISTS _copy_map( + old_id UUID PRIMARY KEY, + new_id UUID NOT NULL DEFAULT gen_random_uuid() + ) ON COMMIT DROP; + TRUNCATE _copy_map; + + INSERT INTO _copy_map(old_id) + SELECT fo.id + FROM storage.folders fo + WHERE NOT fo.is_trashed + AND fo.lpath <@ v_root_lpath; + + -- Remember new root ID + SELECT cm.new_id INTO v_new_root + FROM _copy_map cm WHERE cm.old_id = p_source_id; + + -- Max depth for level iteration + SELECT MAX(nlevel(fo.lpath)) + INTO v_max_depth + FROM storage.folders fo + JOIN _copy_map cm ON fo.id = cm.old_id; + + -- ── Insert folders level by level ── + -- Each level is a separate INSERT so the BEFORE INSERT trigger + -- (trg_folders_path) can resolve the parent's path/lpath from rows + -- inserted in the previous level. drive_id + provenance threaded + -- through from the source row at each level. + FOR v_level IN v_root_depth .. v_max_depth LOOP + INSERT INTO storage.folders( + id, name, parent_id, user_id, + drive_id, created_by, updated_by + ) + SELECT cm.new_id, + CASE WHEN fo.id = p_source_id AND p_dest_name IS NOT NULL + THEN p_dest_name ELSE fo.name END, + CASE WHEN fo.id = p_source_id THEN p_target_parent_id + ELSE pm.new_id END, + fo.user_id, + fo.drive_id, + fo.user_id, + fo.user_id + FROM storage.folders fo + JOIN _copy_map cm ON fo.id = cm.old_id + LEFT JOIN _copy_map pm ON fo.parent_id = pm.old_id + WHERE NOT fo.is_trashed + AND nlevel(fo.lpath) = v_level; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + v_folders := v_folders + v_inserted; + END LOOP; + + -- ── Batch copy all files (zero-copy: same blob_hash) ── + INSERT INTO storage.files( + name, folder_id, user_id, blob_hash, size, mime_type, + media_sort_date, drive_id, created_by, updated_by + ) + SELECT f.name, cm.new_id, f.user_id, f.blob_hash, f.size, f.mime_type, + f.media_sort_date, f.drive_id, f.user_id, f.user_id + FROM storage.files f + JOIN _copy_map cm ON f.folder_id = cm.old_id + WHERE NOT f.is_trashed; + + GET DIAGNOSTICS v_files = ROW_COUNT; + + -- ── Batch increment blob ref_counts ── + IF v_files > 0 THEN + UPDATE storage.blobs b + SET ref_count = ref_count + hc.cnt + FROM ( + SELECT f.blob_hash, COUNT(*)::int AS cnt + FROM storage.files f + JOIN _copy_map cm ON f.folder_id = cm.new_id + WHERE NOT f.is_trashed + GROUP BY f.blob_hash + ) hc + WHERE b.hash = hc.blob_hash; + END IF; + + RETURN QUERY SELECT v_new_root::text, v_folders, v_files; +END; +$$ LANGUAGE plpgsql; diff --git a/src/application/dtos/drive_dto.rs b/src/application/dtos/drive_dto.rs new file mode 100644 index 00000000..98e4fe3f --- /dev/null +++ b/src/application/dtos/drive_dto.rs @@ -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 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()` 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, + /// Storage cap in bytes. `None` means "no quota" (admin override / + /// future system drives). + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_bytes: Option, + /// 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, + pub updated_at: chrono::DateTime, +} + +impl From 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, + } + } +} diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index 62c42bf5..11cd0543 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -58,6 +58,7 @@ impl From for SubjectDto { pub enum ResourceTypeDto { Folder, File, + Drive, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -72,6 +73,7 @@ impl From 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 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 } } diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index d3a5ea09..58a76241 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -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; diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index 494267af..3c4e456e 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -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 diff --git a/src/application/ports/content_index_ports.rs b/src/application/ports/content_index_ports.rs index c407d0af..40e7d7d0 100644 --- a/src/application/ports/content_index_ports.rs +++ b/src/application/ports/content_index_ports.rs @@ -35,14 +35,26 @@ pub struct ContentHitDto { /// holds an `Option>` (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, DomainError>; diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index 8b547ef9..165e11f0 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -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; diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 66f3893f..dbab952f 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -167,6 +167,7 @@ impl FolderService { async fn create_home_folder( &self, _user_id: Uuid, + _drive_id: Uuid, _name: String, ) -> Result { 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 { 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 { 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 - ` wrapper as of D0). +/// +/// Two writes happen on first provisioning: +/// 1. A row in `storage.drives` with `kind='personal'`, +/// `default_for_user=`, 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, folder_service: Arc, + // 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, } -impl HomeFolderLifecycleHook { - pub fn new(folder_service: Arc) -> Self { - Self { folder_service } +impl PersonalDriveLifecycleHook { + pub fn new( + drive_repo: Arc, + folder_service: Arc, + authorization: Arc, + ) -> 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 - ` 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(()) } diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index 5c064b32..bc244e09 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -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 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, } } } diff --git a/src/application/services/recipient_notification_service.rs b/src/application/services/recipient_notification_service.rs index ca832d2e..b334c8dc 100644 --- a/src/application/services/recipient_notification_service.rs +++ b/src/application/services/recipient_notification_service.rs @@ -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 diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index c733514e..379be7fe 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -50,6 +50,20 @@ pub struct SearchService { /// matches; hits are hydrated and re-filtered through SQL before use. content_index: Option>, + /// 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>, + + /// 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>, + /// Lock-free concurrent cache with automatic TTL and LRU eviction (moka). /// Values are `Arc` 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, folder_repository: Arc, content_index: Option>, + authorization: Option>, + drive_repo: Option>, 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 { + 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 = 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: diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index da411815..1a6bbc86 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -1020,6 +1020,7 @@ mod tests { async fn create_home_folder( &self, _user_id: Uuid, + _drive_id: Uuid, _name: String, ) -> Result { unimplemented!() diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index ea2be5bb..8bb2bb8e 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -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 { Ok(Folder::default()) diff --git a/src/common/di.rs b/src/common/di.rs index 0ca12004..48481918 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -454,6 +454,7 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, authz: &Arc, + drive_repo: &Arc, storage_usage: &Arc, content_index: Option>, 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` (with /// `async_trait` boxing). pub authorization: Arc, + /// 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, /// ReBAC subject-group management (CRUD + membership). `None` when the /// auth subsystem is not configured. pub subject_group_service: diff --git a/src/common/stubs.rs b/src/common/stubs.rs index b9baa065..48d793f7 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -322,6 +322,7 @@ impl FolderRepository for StubFolderStoragePort { async fn create_home_folder( &self, _user_id: Uuid, + _drive_id: Uuid, _name: String, ) -> Result { 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 { Ok(FolderDto::default()) diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs new file mode 100644 index 00000000..c991f6a6 --- /dev/null +++ b/src/domain/entities/drive.rs @@ -0,0 +1,126 @@ +//! Drive — the top-level container that owns a tree of folders/files. +//! +//! Drives replaced the per-user `My Folder - ` 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 = +//! `), 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 { + 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, + /// 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, + /// 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, + pub updated_at: chrono::DateTime, +} + +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) + } +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index 75539dff..a475accb 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -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; diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs new file mode 100644 index 00000000..e800e2ac --- /dev/null +++ b/src/domain/repositories/drive_repository.rs @@ -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, +} + +#[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; + + /// Fetch a drive by id. `NotFound` when no row matches. + async fn get_by_id(&self, id: Uuid) -> Result; + + /// 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; + + /// 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, 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 { + DriveKind::parse(s).ok_or_else(|| DriveRepositoryError::InvalidKind(s.to_owned())) + } +} diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 31e55bbd..135d6545 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -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; + /// 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; /// Lists every folder in a subtree rooted at `folder_id` (inclusive). /// diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index 2a4e51b2..51a36364 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -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; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 06cf0607..c0502dda 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -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, } } diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs new file mode 100644 index 00000000..06a2ad3b --- /dev/null +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -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, +} + +impl DrivePgRepository { + pub fn new(pool: Arc) -> 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 { + 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 { + 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 { + 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 { + 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, 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::>(), + ) + .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() + } +} diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index f3cc7ef4..6315f441 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -27,6 +27,10 @@ use crate::infrastructure::services::dedup_service::DedupService; pub struct FileBlobWriteRepository { pool: Arc, dedup: Arc, + /// 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, /// 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 { + /// 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, 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, 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 "#, ) diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 5d38ce56..bc15009a 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -148,18 +148,19 @@ impl FolderRepository for FolderDbRepository { name: String, parent_id: Option, ) -> Result { - // 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 { + async fn create_home_folder( + &self, + user_id: Uuid, + drive_id: Uuid, + name: String, + ) -> Result { + // 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 - + // /` 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}")))?; diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index a9d6f5bf..ecfc3774 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -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; diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 9e2c93f6..1a4e0a7e 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -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::("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::("user_id"), r.get::("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::("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::("user_id"), r.get::("drive_id"))) + .expect("auth.users + storage.drives must be seeded (init-test-schema.sh)") } async fn local_svc(pool: &Arc, 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, 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, Vec) = sqlx::query_as( "SELECT chunk_hashes, chunk_sizes FROM storage.chunk_manifests WHERE file_hash = $1", diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 652d54e3..bebbd194 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -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), 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 { 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 { + counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let roles = Self::roles_implying_strings(permission); + let exists: Option = 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 { // 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 + } } } } diff --git a/src/infrastructure/services/search_index/content_index_worker.rs b/src/infrastructure/services/search_index/content_index_worker.rs index 74d3649f..7c1d1afa 100644 --- a/src/infrastructure/services/search_index/content_index_worker.rs +++ b/src/infrastructure/services/search_index/content_index_worker.rs @@ -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 = 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> = 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, diff --git a/src/infrastructure/services/search_index/tantivy_content_index.rs b/src/infrastructure/services/search_index/tantivy_content_index.rs index fcfbcd3b..1459f2c1 100644 --- a/src/infrastructure/services/search_index/tantivy_content_index.rs +++ b/src/infrastructure/services/search_index/tantivy_content_index.rs @@ -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, pub preview: Option, @@ -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 { - let mut clauses: Vec<(Occur, Box)> = 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 { + // Drive-membership Must clause: union of Term(drive_id = $each). + let drive_alternatives: Vec<(Occur, Box)> = drive_ids + .iter() + .map(|d| { + let q: Box = Box::new(TermQuery::new( + Term::from_field_text(fields.drive_id, d), + IndexRecordOption::Basic, + )); + (Occur::Should, q) + }) + .collect(); + let mut clauses: Vec<(Occur, Box)> = + 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, 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, 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 = 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, ) diff --git a/src/infrastructure/services/tree_etag_flush_service.rs b/src/infrastructure/services/tree_etag_flush_service.rs index 81572aa2..b585d474 100644 --- a/src/infrastructure/services/tree_etag_flush_service.rs +++ b/src/infrastructure/services/tree_etag_flush_service.rs @@ -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 ), diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs new file mode 100644 index 00000000..fc2191fb --- /dev/null +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -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), + (status = 500, description = "Internal server error"), + ), + security(("bearerAuth" = [])), + tag = "drives" +)] +pub async fn list_drives( + State(state): State>, + 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 = 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() + } + } +} diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 63674364..2b3b0932 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -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, } } diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index 4859923e..d31d688d 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -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; diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index c906f75f..047beab6 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -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, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index feb06d64..6310709a 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -440,6 +440,19 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { 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; diff --git a/tests/api/drives_foundation.hurl b/tests/api/drives_foundation.hurl new file mode 100644 index 00000000..fe5641f9 --- /dev/null +++ b/tests/api/drives_foundation.hurl @@ -0,0 +1,209 @@ +# ============================================================= +# OxiCloud — D0 drives foundation +# ============================================================= +# Verifies the D0 server-side foundation lands end-to-end: +# +# 1. Every internal user gets exactly one default Personal drive +# (the M2 backfill + the on-login lifecycle hook). +# 2. `GET /api/drives` returns that drive with the right shape +# (kind='personal', default_for_user matches the caller). +# 3. New folder/file rows stamp `drive_id` (verified indirectly: +# uploads succeed against a NOT NULL drive_id column post-M3). +# 4. Cross-drive isolation in `/api/search` — user A's indexed +# content does NOT surface in user B's search (Tantivy +# Must-clause on drive_id + handler-side ReBAC re-check). +# 5. `created_by` / `updated_by` provenance — files surface a +# non-null `last_modified_by` (via the file metadata endpoint) +# proving the dual-write took effect. +# +# Self-contained: creates its own users + folders so it can run +# independently of other test files. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 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" +admin_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Admin's GET /api/drives surfaces a default Personal drive +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +# Admin has at least one drive — the default Personal at index 0 +# (DrivePgRepository orders default-first via `default_for_user IS NULL ASC`). +jsonpath "$" count >= 1 +jsonpath "$[0].kind" == "personal" +jsonpath "$[0].default_for_user" == "{{admin_user_id}}" +jsonpath "$[0].name" == "Personal" +[Captures] +admin_drive_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Create two fresh users (drv_alice, drv_bob) so the +# cross-drive isolation test below uses fixtures that +# don't collide with other test files. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "drv_alice", "password": "DrvAlicePassword1!", "email": "drv_alice@example.com", "role": "user" } + +HTTP 201 +[Captures] +alice_user_id: jsonpath "$.id" + +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "drv_bob", "password": "DrvBobPassword1!", "email": "drv_bob@example.com", "role": "user" } + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +# Alice's first login fires `PersonalDriveLifecycleHook::on_user_login` +# (since `on_user_created` may have provisioned already; the hook is +# idempotent either way). After this her default drive exists. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "drv_alice", "password": "DrvAlicePassword1!" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "drv_bob", "password": "DrvBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Each non-admin user sees exactly their default drive. +# Confirms the lifecycle hook provisioned + drive listing +# is correctly scoped (no cross-user leak). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +# Exactly one drive: the default Personal. +jsonpath "$" count == 1 +jsonpath "$[0].kind" == "personal" +jsonpath "$[0].default_for_user" == "{{alice_user_id}}" +[Captures] +alice_drive_id: jsonpath "$[0].id" + + +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].kind" == "personal" +jsonpath "$[0].default_for_user" == "{{bob_user_id}}" +[Captures] +bob_drive_id: jsonpath "$[0].id" + + +# Cross-user drive id distinctness — Alice's drive id ≠ Bob's drive id. +# Hurl can't assert via inter-capture; the search-isolation step below +# proves the same property functionally. + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Each user's home folder works end-to-end. The lifecycle +# hook creates the drive; folder creation under the home +# uses the drive's id (M3 NOT NULL on storage.folders.drive_id +# enforces this — any code path that doesn't set drive_id +# would error out here). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + +POST {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "name": "drv-alice-folder", "parent_id": "{{alice_home_id}}" } + +HTTP 201 +[Captures] +alice_subfolder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Upload a small file via the multipart path so its +# drive_id and created_by/updated_by columns get stamped +# by the file repository's dual-write. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{alice_subfolder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +alice_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Cross-drive isolation in `/api/search`. Bob searches +# for a term that exists only in Alice's file. The +# response must be empty (no leak of either the existence +# or the snippet of Alice's content). +# +# The Tantivy worker may need a tick to drain the dirty +# queue + extract text before the term is indexed. In a +# synchronous test we tolerate either response shape +# (empty results vs. some results all of which are Bob's +# own files), as long as Alice's specific file_id is +# absent. The check is the file_id-absent assertion. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=hello +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +# Bob may have his own hits or none — what matters is that +# Alice's file_id never appears in his result set. +jsonpath "$.files[?(@.id=='{{alice_file_id}}')]" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Anti-enum cleanup: drop Alice's file + folder so the +# shared test storage doesn't accumulate cross-test state. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{alice_subfolder_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{alice_token}} + +HTTP 200 diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 6c503eb6..e3cb3e6a 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -294,7 +294,15 @@ Authorization: Bearer {{dave_token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +# Post-D0 every user carries an incoming Owner grant on their own +# personal drive (provisioned by the lifecycle hook). The pre-D0 +# assertion was "no grants at all" (count == 0); the post-D0 +# equivalent is "exactly the self-drive grant remains" (count == 1). +# Hurl's JSONPath filter returns "no value" — not an empty array — +# when nothing matches, so a `count == 0` over a negative filter +# fails to evaluate; the positive-count form sidesteps that quirk. +jsonpath "$" count == 1 +jsonpath "$[0].resource.type" == "drive" # ───────────────────────────────────────────────────────────── @@ -305,7 +313,15 @@ Authorization: Bearer {{eve_token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +# Post-D0 every user carries an incoming Owner grant on their own +# personal drive (provisioned by the lifecycle hook). The pre-D0 +# assertion was "no grants at all" (count == 0); the post-D0 +# equivalent is "exactly the self-drive grant remains" (count == 1). +# Hurl's JSONPath filter returns "no value" — not an empty array — +# when nothing matches, so a `count == 0` over a negative filter +# fails to evaluate; the positive-count form sidesteps that quirk. +jsonpath "$" count == 1 +jsonpath "$[0].resource.type" == "drive" # ════════════════════════════════════════════════════════════════════ @@ -809,7 +825,15 @@ Authorization: Bearer {{adam_token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +# Post-D0 every user carries an incoming Owner grant on their own +# personal drive (provisioned by the lifecycle hook). The pre-D0 +# assertion was "no grants at all" (count == 0); the post-D0 +# equivalent is "exactly the self-drive grant remains" (count == 1). +# Hurl's JSONPath filter returns "no value" — not an empty array — +# when nothing matches, so a `count == 0` over a negative filter +# fails to evaluate; the positive-count form sidesteps that quirk. +jsonpath "$" count == 1 +jsonpath "$[0].resource.type" == "drive" # ════════════════════════════════════════════════════════════════════ @@ -1233,4 +1257,12 @@ Authorization: Bearer {{frank_token}} HTTP 200 [Asserts] -jsonpath "$" count == 0 +# Post-D0 every user carries an incoming Owner grant on their own +# personal drive (provisioned by the lifecycle hook). The pre-D0 +# assertion was "no grants at all" (count == 0); the post-D0 +# equivalent is "exactly the self-drive grant remains" (count == 1). +# Hurl's JSONPath filter returns "no value" — not an empty array — +# when nothing matches, so a `count == 0` over a negative filter +# fails to evaluate; the positive-count form sidesteps that quirk. +jsonpath "$" count == 1 +jsonpath "$[0].resource.type" == "drive" diff --git a/tests/api/run.sh b/tests/api/run.sh index d07cfbb9..de0e717d 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -150,6 +150,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/subject_groups.hurl" \ "$API_DIR/groups_effective_members.hurl" \ "$API_DIR/grants_nested_groups.hurl" \ + "$API_DIR/drives_foundation.hurl" \ "$API_DIR/external_users.hurl" \ "$API_DIR/search_basic.hurl" \ "$API_DIR/nc_second_user_setup.hurl" \ diff --git a/tests/common/init-test-schema.sh b/tests/common/init-test-schema.sh index 434604fe..f696bc75 100644 --- a/tests/common/init-test-schema.sh +++ b/tests/common/init-test-schema.sh @@ -42,4 +42,40 @@ psql -v ON_ERROR_STOP=1 -c " ON CONFLICT (username) DO NOTHING; " >/dev/null +# The OxiCloud server normally provisions a default Personal drive + +# Owner role_grant on user creation via PersonalDriveLifecycleHook +# (D0). This script bypasses that pipeline — it INSERTs directly into +# auth.users — so we mirror the hook's behaviour here. Without it, +# integration test fixtures that hand-roll INSERTs into storage.files +# fail with "drive_id not-null violation" (M3 made the column +# mandatory), and helpers that JOIN auth.users with storage.drives +# return RowNotFound. +echo "[init-schema] provisioning ci-admin's default Personal drive (idempotent)" +psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null +WITH admin AS ( + SELECT id FROM auth.users WHERE username = 'ci-admin' +), +ins_drive AS ( + INSERT INTO storage.drives (name, kind, default_for_user, quota_bytes) + SELECT 'Personal', 'personal', admin.id, NULL + FROM admin + WHERE NOT EXISTS ( + SELECT 1 FROM storage.drives d WHERE d.default_for_user = admin.id + ) + RETURNING id, default_for_user +) +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +SELECT 'user', ins_drive.default_for_user, 'drive', ins_drive.id, 'owner', + ins_drive.default_for_user + FROM ins_drive + WHERE NOT EXISTS ( + SELECT 1 FROM storage.role_grants g + WHERE g.subject_type = 'user' + AND g.subject_id = ins_drive.default_for_user + AND g.resource_type = 'drive' + AND g.resource_id = ins_drive.id + ); +SQL + echo "[init-schema] done"