diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 892da903..14233d08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,12 @@ env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" DATABASE_URL: "postgres://postgres:postgres@localhost/oxicloud_test" + # Re-enable the legacy build.rs static-dist + OUT_DIR HTML pipeline. + # Required while login_v2_handler.rs still uses + # `include_str!(concat!(env!("OUT_DIR"), "/nextcloud-login.html"))`; + # without this, every cargo job fails to compile that file. + # Remove once the Nextcloud login page moves off include_str! (askama). + OXICLOUD_RUST_ASSETS: "1" jobs: @@ -179,7 +185,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c4fe6272..4a302dc3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres diff --git a/.github/workflows/load-nightly.yml b/.github/workflows/load-nightly.yml index 31e16c73..0c09bcce 100644 --- a/.github/workflows/load-nightly.yml +++ b/.github/workflows/load-nightly.yml @@ -22,6 +22,8 @@ on: env: CARGO_TERM_COLOR: always + # See ci.yml — required while login_v2_handler.rs uses include_str! against OUT_DIR. + OXICLOUD_RUST_ASSETS: "1" jobs: load: diff --git a/.github/workflows/load-smoke.yml b/.github/workflows/load-smoke.yml index 330f5536..931bdeec 100644 --- a/.github/workflows/load-smoke.yml +++ b/.github/workflows/load-smoke.yml @@ -18,6 +18,8 @@ on: env: CARGO_TERM_COLOR: always + # See ci.yml — required while login_v2_handler.rs uses include_str! against OUT_DIR. + OXICLOUD_RUST_ASSETS: "1" jobs: smoke: diff --git a/docs/architecture/magic-link-auth.md b/docs/architecture/magic-link-auth.md index ba7c57c7..bfcb5d09 100644 --- a/docs/architecture/magic-link-auth.md +++ b/docs/architecture/magic-link-auth.md @@ -210,7 +210,7 @@ External users have no calendar, no address book, no home folder, and (by design 6. **`POST /api/auth/app-passwords` is closed.** App passwords are persistent credentials; the magic-link-eligibility rule (`has_login_credential`) assumes externals have **no other credential configured**. Letting an external mint an app password would break that invariant and would also be the only way to authenticate them on the NC surface. 403 + audit on rejection. 7. **`GET /api/groups/search` is closed.** Group names aren't strictly secret, but externals have no legitimate use for the share-dialog autocomplete (they can't be added to groups anyway). -Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `HomeFolderLifecycleHook` short-circuit that skips home-folder provisioning for externals. +Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `PersonalDriveLifecycleHook` short-circuit that skips drive provisioning for externals (they get no default drive, so `DriveRepository::home_root_folder_id_for(external_user_id)` returns `Ok(None)`). ### Why protocol-level instead of handler-level diff --git a/docs/architecture/user-lifecycle.md b/docs/architecture/user-lifecycle.md index c1d8bd16..ab81c57f 100644 --- a/docs/architecture/user-lifecycle.md +++ b/docs/architecture/user-lifecycle.md @@ -77,8 +77,7 @@ The asymmetry is deliberate. `on_user_created` and `on_user_login` must complete │ ┌────────────────┼────────────────┐ ▼ ▼ ▼ - AuditLifecycleHook HomeFolderHook AuthzCacheHook … - (PR 1 only) (PR 3) (PR 4) + AuditLifecycleHook PersonalDriveHook AuthzCacheHook … ``` ## Owner-located convention @@ -87,7 +86,7 @@ Each concrete hook impl lives **next to the service that owns the work**, not in Examples (PR plan): -- `HomeFolderLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-folder policy. +- `PersonalDriveLifecycleHook` lives in `src/application/services/folder_service.rs` — same module as `FolderService`, owner of home-drive provisioning policy. It calls `DrivePgRepository::create_personal_drive_atomic` to create the drive + root folder + Owner role-grant in one atomic transaction (see [docs/plan/drive.md §3](https://github.com/EdouardVanbelle/OxiCloud/blob/main/docs/plan/drive.md)). - `AuthzCacheLifecycleHook` lives in `src/infrastructure/services/pg_acl_engine.rs` — same module as the Moka cache it invalidates. - `AuditLifecycleHook` lives in `src/application/services/user_lifecycle_service.rs` (with the dispatcher) — cross-cutting, no domain owner. @@ -120,7 +119,7 @@ These are codified in the module-level docstring of `application/ports/user_life | Hook | Lives in | Responsibility | |---|---|---| | `AuditLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher) | All four events: emits one `tracing::info!(target: "audit", event = "user.*", ...)` per call, with `is_external` as a field. Co-located because audit is cross-cutting with no domain owner. | -| `HomeFolderLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision "My Folder - {username}" via `FolderService::ensure_home_folder`. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.folders.user_id` handles the actual row removal. Trash-with-retention is documented as future work. | +| `PersonalDriveLifecycleHook` | `src/application/services/folder_service.rs` (same module as `FolderService`) | `on_user_created` + `on_user_login`: idempotently provision the user's **default personal drive** + its root folder + Owner role-grant via `DrivePgRepository::create_personal_drive_atomic` — all four DB writes in one transaction (drive INSERT, folder INSERT, `drives.root_folder_id` UPDATE, `role_grants` INSERT). Idempotency: `find_default_for_user` short-circuits if the user already has a drive. Short-circuits when `user.is_external()`. `on_user_logout`: `Ok(())`. `on_user_deleted`: per-mode `tracing::info!` event so audit distinguishes AdminDelete from GdprPurge — the FK CASCADE on `storage.drives.default_for_user` handles the actual drive row removal, which cascades to folders/files via their `drive_id` FK. Trash-with-retention is documented as future work. | | `AuthzCacheLifecycleHook` | `src/infrastructure/services/pg_acl_engine.rs` (same module as the Moka cache it invalidates) | `on_user_logout` + `on_user_deleted`: `engine.invalidate_user_groups_cache(user.id())` — drops the cached transitive-group expansion immediately so a re-login (or a re-created account with the same id) sees fresh memberships without waiting for the 30 s TTL. `on_user_created` + `on_user_login`: `Ok(())` (no stale entry could exist for these). | | `SessionRevocationLifecycleHook` | `src/application/services/user_lifecycle_service.rs` (co-located with dispatcher; no dedicated session service module today) | `on_user_deleted`: explicit `session_storage.revoke_all_user_sessions(user.id())` + aggregate audit event (`event = "user.sessions_revoked_on_delete", count = N`). Replaces the silent FK CASCADE with an observable revocation. All other events: `Ok(())`. | | `DeletionMode` | enum on the trait | Distinguishes admin-initiated delete (`AdminDelete` — currently identical to GDPR but reserved for a future trash-with-retention policy) from GDPR right-to-erasure purge (`GdprPurge` — for a future sweeper). PR 4 ships the variants; future PRs may add per-mode behaviour. | @@ -138,7 +137,7 @@ BEGIN dispatch_deleted(user, AdminDelete, &mut tx) │ ├── AuditLifecycleHook → tracing::info!(event="user.deleted", mode=...) - ├── HomeFolderLifecycleHook → tracing::info!("home folder will be removed via FK CASCADE") + ├── PersonalDriveLifecycleHook → tracing::info!("default drive + its tree will be removed via FK CASCADE on storage.drives.default_for_user") ├── AuthzCacheLifecycleHook → engine.invalidate_user_groups_cache(user_id) └── SessionRevocationLifecycleHook → session_storage.revoke_all_user_sessions(user_id) @@ -163,13 +162,35 @@ If any hook returns `Err`, the dispatcher propagates it; `delete_user_admin` rol 2. `AuthApplicationService::login()` validates the password against the stored Argon2 hash. 3. **Before** `user.register_login()` is called, the dispatcher fires `dispatch_login(&user)`. The user's `last_login_at` is still `None` from creation time. 4. `AuditLifecycleHook::on_user_login` runs first (registration order): emits `event = "user.login", user_id = ..., username = ..., is_external = false, first_login = true`. -5. `HomeFolderLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `FolderService::ensure_home_folder(uid, username)`. The service checks `list_folders_by_owner(None, uid)` — empty → creates `"My Folder - alice"`. Returns `Ok(true)` (newly created). +5. `PersonalDriveLifecycleHook::on_user_login` runs next: sees `!user.is_external()`, calls `provision_if_needed`. The hook asks `DriveRepository::find_default_for_user(uid)` — returns `NotFound`. It then calls `DrivePgRepository::create_personal_drive_atomic(uid, quota)` which runs the four-write transaction: INSERT drive, INSERT folder, UPDATE `drives.root_folder_id`, INSERT `role_grants` row. All four commit together. 6. Dispatcher finishes. `user.register_login()` is now called, stamping `last_login_at` to the current time. 7. The session row is INSERTed; access + refresh tokens generated; response returned to the client. -On the user's **second** login: same flow up through step 5, but `ensure_home_folder` finds the existing folder, returns `Ok(false)`, no-op. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time. +On the user's **second** login: same flow up through step 5, but `find_default_for_user` returns `Ok(drive)` (the drive already exists). The hook re-emits the Owner `role_grant` via `set_role` (UPSERT-safe) as belt-and-suspenders against partial provisioning and returns. The `AuditLifecycleHook` still emits an event, but `first_login = false` this time. -If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders WHERE user_id = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns. +If the drive gets deleted manually (e.g., SQL `DELETE FROM storage.drives WHERE default_for_user = $1`), the user's **next** login will re-create it — that's the safety-net behaviour the lifecycle hook contractually owns. The atomic four-write transaction ensures no partial state can leak through a half-deleted drive. + +### Identifying the home — never by name + +Code that needs to ask "is this the user's home?" must compare ids, not names. Users rename their home folder. Secondary personal drives keep their original sibling-root names. The single source of truth is **drive ownership**: the drive where `default_for_user = user_id` owns the user's home root folder via `root_folder_id`. + +Two helpers in `src/domain/repositories/drive_repository.rs` encapsulate the lookup: + +```rust +// "Give me this user's home root folder id (or None for external)." +drive_repo.home_root_folder_id_for(user_id).await + // → Result, DriveRepositoryError> + +// "Where in this list of items is the user's home?" — generic over +// the item shape; the caller passes an id-extractor closure. +position_of_user_home_root_folder( + drive_repo, user_id, &items, + |item| Uuid::parse_str(&item.id).ok(), +).await + // → Option +``` + +`home_root_folder_id_for` returns `Ok(None)` (not an error) for external users — they have no default drive. The position helper is a free function (not a trait method) so `DriveRepository` stays `dyn`-compatible. Use these everywhere; do not write new code that pattern-matches folder names like `"Personal"` or `"My Folder - "`. ### State of the art: @@ -177,7 +198,7 @@ If the home folder gets deleted manually (e.g., SQL `DELETE FROM storage.folders DI builds: UserLifecycleService ├── AuditLifecycleHook (in user_lifecycle_service.rs) - ├── HomeFolderLifecycleHook (in folder_service.rs) + ├── PersonalDriveLifecycleHook (in folder_service.rs) ├── AuthzCacheLifecycleHook (in pg_acl_engine.rs) ├── SessionRevocationLifecycleHook (in user_lifecycle_service.rs) └── ExternalIdentityLifecycleHook (in external_identity_service.rs, stubbed) diff --git a/docs/plan/drive.md b/docs/plan/drive.md index e567c00f..4947f6f4 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -176,17 +176,35 @@ layer (`DriveService` enforces the personal-drive invariants, etc.) - A shared drive can have **0 viewers** and **0 editors** — only the ≥1-owner invariant matters. -### 3. Drive entity +### 3. Drive entity — pure metadata + a 1:1 root folder + +A drive is a **metadata-only holder** (quota, kind, policies, default flag) +paired 1:1 with a *root folder* that owns the drive's visible identity +(name, path materialisation, ltree anchor). The drive itself has no +`name` column — every property the user thinks of as "the drive" +(its display name, its containing children, its location in the +ltree) lives on the root folder row. + +This is the Unix-philosophy split: the *filesystem volume* is the drive +(quota, policies, ownership metadata); the *mount point* is the root +folder (name, hierarchy, paths). Clients interact with the root folder +through the standard folder API — no special "drive root" endpoint, no +polymorphic creation surface, no "create at drive vs in folder" duality. ```sql storage.drives id uuid PRIMARY KEY DEFAULT gen_random_uuid() - name text NOT NULL -- "Personal" or user-chosen kind text NOT NULL CHECK (kind IN ('personal','shared')) default_for_user uuid NULL FK → auth.users(id) ON DELETE CASCADE quota_bytes bigint NULL -- NULL = unlimited used_bytes bigint NOT NULL DEFAULT 0 policies jsonb NOT NULL DEFAULT '{}' + -- The drive's mount-point folder. Nullable AT THE COLUMN TYPE LEVEL + -- only because the column is set mid-statement during atomic + -- creation (see "Atomic creation" below) — invariant: after any + -- successful create_personal_drive() call, this is non-NULL. Code + -- that reads drives can treat it as Uuid in Rust. + root_folder_id uuid NULL FK → storage.folders(id) ON DELETE CASCADE created_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now() -- `default_for_user` may ONLY be set on personal drives. @@ -199,6 +217,35 @@ CREATE UNIQUE INDEX drives_default_for_user_idx WHERE default_for_user IS NOT NULL; -- one DEFAULT personal drive per user ``` +#### Drive name lives on the root folder + +`storage.drives` has no `name` column. The drive's display name is +`SELECT f.name FROM storage.drives d JOIN storage.folders f +ON f.id = d.root_folder_id WHERE d.id = $drive_id`. + +Why this is the right shape: + +- **Single source of truth.** No duplication between `drives.name` and + `folders.name`, no drift risk, no decision about which side to + serve when they differ. +- **Renaming is the standard folder API.** `PATCH /api/folders/` + with a new name renames the drive. No separate + `PATCH /api/drives//name` endpoint. The lifecycle / cascade + / search-index behaviour that already exists on folder rename + applies automatically. +- **No "rename the drive but not its mount point" footgun.** They're + always in sync because they're the same column. + +Identity is still carried by `kind` + `default_for_user` — *not* by +the name. UI and NC default-drive resolution query on `kind` / +`default_for_user`, never on `name = 'Personal'`. Renaming "Personal" +→ "Ed's space" preserves identity; only the label changes. + +The migration-time default for personal drive root folder names is +`'Personal'`. Secondary personal drives (sibling roots from the M2 +backfill — see §10) carry over whatever name the original sibling +root folder had. + #### Two orthogonal properties: `kind` and `default_for_user` - **`kind`** = drive capability shape (see §2 for the rules): @@ -229,24 +276,164 @@ There is no constraint to write — externals simply have no row in `storage.drives` with `default_for_user = `, and nothing tries to create one. -#### Drive naming — `name` is a label, identity lives in `kind` + `default_for_user` +#### Atomic creation — single transaction, four writes -`name` is owner-editable for every drive (personal or shared). A -user who renames their drive from "Personal" → "Ed's space" does -**not** stop having a personal drive, and does not stop having a -default. The `kind` flag + `default_for_user` pointer carry the -identity; the name is purely a display label. +A drive and its root folder reference each other circularly: +`storage.drives.root_folder_id` points at `storage.folders.id`, and +`storage.folders.drive_id` points at `storage.drives.id`. Creating +them naively could leave inconsistent half-state on a server crash +mid-sequence: drive without folder, folder without drive, or either +without an owner role_grant. -Why this matters: -- UI and NC default-drive resolution MUST query on `kind` / - `default_for_user`, never on `name = 'Personal'`. The latter - would silently break the moment the user renames. -- The initial migration sets `name = 'Personal'` on the default - personal drive for back-compat with the label users see today; - further renames go through the normal drive-rename endpoint - and persist on the same row. Secondary personal drives carry - whatever name the sibling root folder had (e.g. `Archive`, - `2024 Projects`). +The repo's `create_personal_drive_atomic` wraps the four writes in +a single transaction so they commit together or not at all: + +1. INSERT drive (with `root_folder_id = NULL`) → returns drive id. +2. INSERT folder (with `drive_id` = the drive's id) → returns folder id. +3. UPDATE drive SET `root_folder_id` = the folder id. +4. INSERT role_grant (owner, subject = caller, resource = drive). +5. COMMIT. + +Why a transaction rather than one CTE statement: PostgreSQL's CTE +sub-statements all read the target tables from the *same snapshot* +— a later sub-statement's `UPDATE storage.drives WHERE id = …` +cannot match a row inserted by an earlier sub-statement, even if +the earlier statement returned the new id via `RETURNING`. The +documented escape hatch (`DEFERRABLE INITIALLY DEFERRED` FKs + +pre-generated UUIDs) is the alternative but adds constraint +plumbing to support a single uncommon code path. A transaction is +boring and correct. + +Crash safety: any failure between steps 1 and 4 rolls back — no +drive without folder, no folder without drive, no drive without +owner. Once step 5 commits, the invariant holds. + +For reference, the equivalent (broken) one-CTE form looks like: + +```sql +WITH new_drive AS ( + INSERT INTO storage.drives + (kind, default_for_user, quota_bytes, policies) + VALUES ('personal', $user_id, $quota, '{}'::jsonb) + RETURNING id +), +new_root AS ( + INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + SELECT 'Personal', -- root folder name + NULL, -- parent_id (this IS the root) + $user_id, + new_drive.id, -- forward-ref to the drive's id + $user_id, $user_id + FROM new_drive + RETURNING id, drive_id +), +drive_updated AS ( + UPDATE storage.drives d + SET root_folder_id = new_root.id + FROM new_root + WHERE d.id = new_root.drive_id + RETURNING d.id +), +new_grant AS ( + INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + SELECT 'user', $user_id, 'drive', du.id, 'owner', $user_id + FROM drive_updated du + RETURNING resource_id +) +SELECT d.id, d.root_folder_id, 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 new_grant g ON g.resource_id = d.id; +``` + +Why the one-CTE form above does NOT work — and what we ship instead: + +The shared-snapshot rule (`postgresql.org/docs/current/queries-with.html` +§7.8.2: "they cannot 'see' one another's effects on the target tables") +breaks the `drive_updated` sub-statement. Its `UPDATE storage.drives d +… WHERE d.id = new_root.drive_id` evaluates `WHERE d.id = …` against +the snapshot, which doesn't contain the drive inserted by `new_drive`. +The UPDATE matches zero rows; `RETURNING` returns zero rows; +`new_grant` (which feeds off `drive_updated`) inserts zero role_grants; +the final SELECT joins on an empty CTE branch and returns nothing. +Symptoms in tests: drives exist with `root_folder_id IS NULL`, owners +have no `role_grants` row, `/api/drives` returns `[]`. + +The fix is the four-step transaction described above. Rust: + +```rust +let mut tx = pool.begin().await?; +let drive_id: Uuid = sqlx::query_scalar( + r#"INSERT INTO storage.drives (kind, default_for_user, quota_bytes) + VALUES ('personal', $1, $2) RETURNING id"#, +).bind(owner).bind(quota).fetch_one(&mut *tx).await?; + +let folder_id: Uuid = sqlx::query_scalar( + r#"INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + VALUES ('Personal', NULL, $1, $2, $1, $1) RETURNING id"#, +).bind(owner).bind(drive_id).fetch_one(&mut *tx).await?; + +sqlx::query("UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2") + .bind(folder_id).bind(drive_id).execute(&mut *tx).await?; + +sqlx::query( + r#"INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', $1, 'drive', $2, 'owner', $1)"#, +).bind(owner).bind(drive_id).execute(&mut *tx).await?; + +tx.commit().await?; +``` + +Each statement sees the prior statements' writes (transaction-local +visibility, not the CTE shared snapshot). FK timing works without +`DEFERRABLE`: each FK is satisfied at the moment its row is written +because the referenced rows already exist. + +#### DB-level invariant: no orphan root folder + +`storage.drives.root_folder_id` is NULLable at the column level +(required by the four-write transaction above — the drive INSERT +can't reference a folder that doesn't exist yet). The "every drive +has a root folder" invariant is application-enforced by the atomic +creation transaction being the only mint path. But the symmetric +invariant — "every root folder belongs to a drive" — is *not* free +either: a `storage.folders` row with `parent_id IS NULL` whose +`drive_id` doesn't point at a drive whose `root_folder_id` equals +its `id` would be an orphan that the resolver can't reach (the +chroot can't land on it) but that still occupies the +`(parent_id IS NULL, name, drive_id)` unique slot. + +D0 lands a DB-level guard for this case, as a defence-in-depth +layer behind the application invariant. Implementation: an +`AFTER INSERT OR UPDATE` constraint trigger on +`storage.folders` that fires when `NEW.parent_id IS NULL` and +refuses unless: + +```sql +EXISTS ( + SELECT 1 FROM storage.drives + WHERE id = NEW.drive_id + AND root_folder_id = NEW.id +) +``` + +Declared `DEFERRABLE INITIALLY DEFERRED` so the four-write +transaction's order (folder INSERTed at step 2, drive's +`root_folder_id` UPDATEd at step 3) doesn't trip the trigger +mid-transaction — the check fires at COMMIT, by which point both +sides of the cycle are wired. + +Test coverage: a Hurl/integration test that hand-rolls +`INSERT INTO storage.folders (… parent_id=NULL, drive_id=)` +*without* the matching drives.root_folder_id update — asserts the +trigger raises and the row is rejected. The atomic transaction +remains the only legitimate creation path; arbitrary SQL writes +that try to bypass it now hit a DB-level wall. #### Capabilities matrix @@ -258,11 +445,12 @@ Why this matters: | Rename | allowed (by the owner) | allowed (by the owner) | allowed (by any owner) | | Delete via API | **refused** — deleting this loses all the user's files; the only path is user-delete cascade | allowed (it's just a silo) | allowed (by an owner; CASCADEs the drive's contents) | | Default-drive lookup result | this drive | never | never | -| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via drive_members and delete | member rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner | +| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via `role_grants` (`subject_id= AND resource_type='drive' AND role='owner'`) and delete | role_grants rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner | | Group ownership | no | no | yes | | Per-resource grant outward | yes (subject to drive policies) | yes | yes | | Cross-drive move | yes (subject to `forbid_cross_drive_move`) | yes | yes | | Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no | +| Change `quota_bytes` | **OxiCloud admin only** (not the drive owner — §7) | **OxiCloud admin only** | **OxiCloud admin only** | ### 4. Roles → permission bundles @@ -273,7 +461,7 @@ expansion: |---|---| | `viewer` | `Read` | | `editor` | `Read`, `Create`, `Update`, `Comment` | -| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members, change quota) | +| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members) | ### 5. Permission resolution — additive over `role_grants` @@ -305,16 +493,16 @@ against the same table. | Event | Behaviour | |---|---| -| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `name='Personal'`, `default_for_user=`, `quota_bytes=`), insert the single `drive_members (drive_id, user, , owner)` row. | +| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=`, `quota_bytes=`) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=, resource_type='drive', resource_id=, role='owner')`) — **all four writes in one CTE statement** (§3), atomic against server crash. | | External user invited (magic-link only) | **No personal drive created.** External users are grant-only recipients with no storage. | | External user converts to internal (future flow) | Default personal drive created at conversion time. | -| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole `drive_members` row points at the user) are deleted by an application-layer pass in the same transaction. Member rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. | +| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole owner `role_grants` row points at the user) are deleted by an application-layer pass in the same transaction. `role_grants` rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. | | Group deleted | Refuse if the group is a member of any shared drive that would lose its last owner. Admin must transfer or remove the group's role from those drives first. (Groups can't be members of personal drives.) | | Add member to personal drive | Refuse. Personal drives are single-user — collaborate via per-resource grants or by moving content into a shared drive. | | Remove sole owner of personal drive | Refuse. The only deletion path for a personal drive is user-deletion via cascade. | | Delete personal drive | Refuse from the API. Only ON DELETE CASCADE (user deletion) drops it. | -| Rename personal or shared drive | Allowed for any owner-role caller. `name` is a label only. | -| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM drive_members`. | +| Rename personal or shared drive | Allowed for any owner-role caller. The drive's display name lives on its root folder (§3) — rename via `PATCH /api/folders/`, not a drive-specific endpoint. | +| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM role_grants WHERE resource_type='drive' AND resource_id=$drive AND role='owner'`. | ### 7. Quota model @@ -336,6 +524,38 @@ After the cutover: (plus a periodic reconciliation job to fix drift, similar to the existing per-user accounting). +#### Quota mutation is OxiCloud-admin only + +Changing `drives.quota_bytes` is **not** in the drive `owner` role +bundle (§4). It requires the tenant-level OxiCloud admin role +(`auth.users.role = 'admin'`), checked at +`PATCH /api/admin/drives/{id}/quota` — the only callsite that +mutates the column. Drive owners can rename, edit policies, and +manage members; they cannot self-grant capacity. + +Why this seam matters: + +- **Resource allocation is a tenant concern, not a drive + concern.** Storage bytes are a finite system resource the + operator pays for. The drive owner is empowered over the + drive's *use*; the admin is empowered over its *budget*. Same + separation that exists today between a user and the operator + who set `OXICLOUD_DEFAULT_QUOTA_BYTES`. +- **Privilege-escalation seam closed.** Without this carve-out, + any user with a personal drive (= every internal user) could + raise their own quota by virtue of being its sole owner — + trivially defeating the quota system. +- **Shared-drive coherence.** A shared drive's quota is set by + the operator at provisioning; subsequent capacity requests go + through the admin, not the drive's group owners. Keeps the + capacity decision auditable and out of intra-team politics. + +The admin endpoint is the same surface the operator uses today to +change `auth.users.storage_quota_bytes`; D4 simply re-targets the +write at `storage.drives.quota_bytes`. Audit log emits +`drive.quota_changed` with `granted_by=` and the +old/new values, mirroring the existing user-quota change event. + **Chunk dedup vs per-drive quota.** With the CDC chunk store landed in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant upload by hash), a single chunk can be referenced by files in @@ -479,34 +699,57 @@ discriminator is the literal segment (`files` vs `drives`), never the value of ``. A user happening to have a UUID-shaped username is no longer a problem. -### 10. Storage paths — wrapper folder retired +### 10. Storage paths — wrapper folder becomes the drive's root folder Today `storage.folders.path` is e.g. `My Folder - admin/Docs`. The "My Folder - admin" wrapper is the user's home folder, created at registration via `format!("My Folder - {}", username)`. -Post-drives, **the wrapper goes away**. The drive itself is the -root; folders and files that used to live inside the wrapper sit -directly under the drive with no intermediate folder: +Post-drives, **the wrapper isn't deleted — it's *adopted* as the +drive's root folder** (§3). The drive row is created alongside it +and points at it via `drives.root_folder_id`. The wrapper's row +survives the migration; only its `name` is updated. ``` -Drive "Personal" (uuid=…, kind=personal, owner=admin) ← was the "My Folder - admin" wrapper -├── Docs/ -└── aa.pdf +Drive (uuid=…, kind=personal, default_for_user=admin) +└── root folder (parent_id=NULL, drive_id=, name="Personal") ← was "My Folder - admin" + ├── Docs/ + └── aa.pdf ``` -Same for shared drives — they already had no wrapper: +Shared drives follow the same shape — drive + root folder + content +underneath: ``` -Drive "Engineering" (uuid=…, kind=shared, owners=group:engineering) -├── Specs/ -├── Roadmap.md -└── archive/ +Drive (uuid=…, kind=shared, owners=group:engineering) +└── root folder (parent_id=NULL, drive_id=, name="Engineering") + ├── Specs/ + ├── Roadmap.md + └── archive/ ``` -The two surfaces share one rule: **drive root = `parent_id IS NULL` -within the drive's `drive_id`**. The "personal vs shared" branch -disappears from path resolution — both kinds resolve the same way. +One rule, one model: **every drive has exactly one folder where +`parent_id IS NULL` AND `drive_id = `**. + +#### Why this is a better model + +Three things converge: + +1. **No API duality.** Folder creation is always `POST /api/folders + { name, parent_id: }`. There's no polymorphic "create at + drive vs in folder" branch — the drive's root folder is just + another folder id from the client's perspective. The `parent_id` + field that exists today carries over unchanged. +2. **No path-prefix rewrite migration.** The wrapper row stays; it's + renamed to its drive's canonical name (`"Personal"` for the + default, the original sibling-root name for secondaries). The + BEFORE-UPDATE path trigger fires on the rename and the cascade + trigger automatically rewrites every descendant's `path` / + `lpath` — no per-row UPDATE in the migration. The net cost is + one UPDATE per drive plus the trigger's cascade. +3. **No "this user lost their drive" failure mode.** Migration is + safe even mid-flight — the wrapper row never disappears, just + gains a drive_id pointer above it. #### Why this is client-safe @@ -519,22 +762,48 @@ The wrapper was already invisible to WebDAV / NC clients pre-drive: - Native `/webdav/` was implicitly chrooted to the user's home by `resolve_webdav_path`. Same story. -So URL-level back-compat is preserved trivially — clients keep -asking for `/remote.php/dav/files/admin/Docs/foo.pdf`, the -resolver no longer prepends the wrapper, and the storage row's -path is now `Docs/foo.pdf` instead of `My Folder - admin/Docs/foo.pdf`. -Net effect on the wire: zero. +Post-migration the resolver chroot becomes `/` +instead of `My Folder - /`. The drive's root folder name +(e.g. `Personal`) replaces the wrapper name in the materialised +`path` column; the client's URL still doesn't carry it +because the resolver still chroots before talking to storage. Net +effect on the wire: zero. -#### Why this is a better model +#### Uniqueness constraints become drive-scoped -The original plan kept the wrapper "for back-compat" but it has -no value beyond the storage layer (clients never see it, the -filesystem mirror is happy either way). Keeping it forced path -resolution to always know whether the caller is in a personal or -shared drive and conditionally prepend a segment. Dropping it -collapses that branch and makes the personal-vs-shared distinction -purely a metadata concern (kind, quota source, member shape) — -**not** a path-shape concern. +Pre-drive, two indexes enforce "no duplicate folder names under the +same parent for the same user": + +```sql +CREATE UNIQUE INDEX idx_folders_unique_name + ON storage.folders(parent_id, name, user_id) + WHERE NOT is_trashed AND parent_id IS NOT NULL; +CREATE UNIQUE INDEX idx_folders_unique_name_root + ON storage.folders(name, user_id) + WHERE NOT is_trashed AND parent_id IS NULL; +``` + +Both move from `user_id`-scoped to `drive_id`-scoped: + +```sql +CREATE UNIQUE INDEX idx_folders_unique_name + ON storage.folders(parent_id, name, drive_id) + WHERE NOT is_trashed AND parent_id IS NOT NULL; +CREATE UNIQUE INDEX idx_folders_unique_name_root + ON storage.folders(name, drive_id) + WHERE NOT is_trashed AND parent_id IS NULL; +``` + +This is a **correctness improvement**, not just a migration concession. +The semantic users expect is "no duplicate names *within a drive*" +— a folder named "Reports" in your Personal drive shouldn't preclude +another "Reports" in a shared "Team" drive. The user-scoped +constraint forbade that. The drive-scoped constraint allows it. + +For the root variant: post-migration each drive has exactly one +`parent_id IS NULL` row (its root folder), so `(name, drive_id)` is +trivially unique. The index is still worth keeping as +defence-in-depth. #### Sibling root folders also become drives @@ -551,23 +820,26 @@ Post-migration, the model has to absorb them. Rule: - For each user, find every row with `parent_id IS NULL AND user_id = `. - The one named `My Folder - ` becomes the **default - personal drive**: `kind='personal'`, - `default_for_user=`, sole member = the user. + personal drive's root folder**: a new drive row is created with + `kind='personal'`, `default_for_user=`, the existing folder + row gets a `drive_id` pointer (and the wrapper's name is updated + to `"Personal"`), and `drives.root_folder_id` points back at it. + Sole `role_grants` row: Owner, subject=``. - Every **other** sibling becomes a fresh **secondary personal - drive**: `kind='personal'`, `default_for_user=NULL`, sole - member = the user, name carried over from the folder's `name` - column, quota initialised from the user's quota - (`auth.users.storage_quota_bytes`) — same default as the - user's primary personal drive. Membership rules from §2 apply: - the user cannot invite co-owners while the drive remains - personal. To open the silo up, the user can later **convert** - the secondary personal to `kind='shared'` (an - application-layer operation that flips the kind and lifts the - single-user restriction so the membership API can add other - users / groups). -- The folder row is deleted (the drive replaces it as the root). - Its children get their `parent_id` set to `NULL` *within their - new `drive_id`*. + drive's root folder**: a new drive row with `kind='personal'`, + `default_for_user=NULL`, the existing folder row gets its + `drive_id` set and *keeps its name* (`Archive`, `2024 Projects`, + whatever), quota initialised from the user's quota + (`auth.users.storage_quota_bytes`) — same default as the user's + primary personal drive. Membership rules from §2 apply: the user + cannot invite co-owners while the drive remains personal. To open + the silo up, the user can later **convert** the secondary + personal to `kind='shared'` (an application-layer operation that + flips the kind and lifts the single-user restriction so the + membership API can add other users / groups). +- **No folder row is deleted.** The wrapper rows survive the + migration as drive root folders; only their `drive_id` is set and + (for the default-Personal case) their `name` is updated. The chroot POC's "pick a drive at login" picker on `feat/nextcloud-drive` already produces the right shape for this: @@ -575,7 +847,7 @@ users with one drive auto-select Personal silently, users with N drives get a real picker. No POC change needed — it just sees real drive rows instead of folder UUIDs. -### 11. Content search index — drive-aware filtering +### 11. Content search index — cross-drive with anti-enum filtering v0.7.0 added an embedded Tantivy full-text content index (see `infrastructure/services/search_index/tantivy_content_index.rs` @@ -583,24 +855,98 @@ and migration `20260701000000_content_search_index.sql`). Today every indexed document carries the owning user as a filter field and queries restrict by that field at query time. -When ownership pivots to `drive_id`, the index has to follow — -otherwise search leaks content across drives the moment D7 drops -`user_id`: +The pivot to drives keeps **one global endpoint, `/api/search`, +that aggregates across every drive the caller can read**. There +is no per-drive search route in D0; the URL/picker UI in D1 may +add an optional `?drive_id=` narrowing parameter, but the +default surface stays cross-drive. + +The security primitive that makes cross-drive search safe is the +**`Occur::Must` clause applied at query time**, not after. +Tantivy's collector only sees documents that satisfy the Must +clause — stored fields (`preview`), counts, and pagination +cursors all reflect the filtered set. This is the same shape the +existing `user_id` filter uses today; we keep it and swap the +field. 1. **Schema update**: every indexed document gains a `drive_id` - field stamped at ingest time. Existing documents need a - one-shot reindex pass during the D0 migration (read each row, - look up its new `drive_id`, update the index entry). Cheap on - small instances; the migration script should report a progress - count for larger ones. -2. **Query path**: instead of filtering by `user_id = caller`, - expand `caller → set of drive_ids the caller can read` - (personal + every shared-drive membership) and filter by - `drive_id ∈ that set`. Expansion reuses the drive-role check - already required by `PgAclEngine`. -3. **Treat as a blocking step of D0**, not a D4/D5-era polish - item — otherwise the index is the silent leak path during the - dual-write window. + STRING field stamped at ingest time. The existing `user_id` + field is kept during the dual-write window for rollback + safety and dropped in D7. +2. **Query path**: replace the `Must user_id = caller` clause + with a `Must drive_id ∈ accessible_drives` set-membership + clause. `accessible_drives` is computed fresh per query + (personal + every shared-drive membership), reusing the + `expand_user` cache that `PgAclEngine` already maintains and + `AuthzCacheLifecycleHook` already invalidates on membership + change. +3. **Handler-side ReBAC re-verification** (defense in depth): + after Tantivy returns hits, the `/api/search` handler + re-checks each `file_id` with the engine. The re-check is + *subtractive only* — it can drop a Tantivy hit, never add + one. It catches: + - **Index staleness** — file just moved to a drive the caller + can't access; the indexer hasn't caught up so Tantivy still + returns the doc under its old drive_id (a false positive + from the caller's perspective). The re-check denies and the + hit drops. + + #### Known D0 scope limit — per-resource grants invisible in search + + The drive-only Must clause makes the inverse case structurally + invisible: a file or folder shared *directly* with the caller + via ReBAC, living in a drive the caller has no membership in, + never reaches the re-check because Tantivy already filtered the + doc out at index-query time. Concretely: + + - Alice grants Bob a per-file Read on `report.pdf` inside her + Personal drive. Bob has no drive grant on Alice's Personal. + - Bob's `accessible_drives` set doesn't include Alice's drive. + - Tantivy's `Must drive_id ∈ accessible_drives` rejects every + doc with Alice's drive_id, including `report.pdf`. + - Bob's search misses `report.pdf` even though `authz.check(Bob, + Read, File(report))` would say yes. + + This is **not a security issue** — Bob still can't access + anything he isn't entitled to. The trade-off is *discovery + only*: search doesn't surface directly-shared resources. The + "Shared with me" UI is the canonical surface for that workflow + (resources someone explicitly shared with you live in the + notification / inbox / share-listing flow, not in global search). + + Closing this gap is deferred. Two future moves, in order of cost: + - **Per-file grants** (low cost): one extra `role_grants` query + for `resource_type='file' AND subject ∈ caller_set`, widen + the Must clause to `drive_id ∈ A OR file_id ∈ F`. ~1 day of + work; deliverable when the "Shared with me" search surface is + prioritised. + - **Per-folder grants with cascade** (high cost): the ltree + subtree expansion is what makes it gnarly — a grant on folder + `F` should surface every descendant in search. Two viable + shapes: (a) reindex with a multi-valued `ancestor_folder_ids` + STRING field on each doc (schema v3, full reindex), or (b) + expand each granted folder to its subtree at query time + (per-grant recursive SQL on the hot path). Probably a dedicated + PR alongside the D1 URL-routing work. +4. **Token subjects cannot search**. `/api/search` returns 401 + for token-authenticated callers (anonymous link tokens have + access to one resource, not a drive — there is no meaningful + "search my drives" surface for them). The 401 is consistent; + "empty results" would leak nothing but would be operationally + confusing. +5. **Anti-enumeration response shape**: no "you have N hidden + matches" anywhere. The count, the cursor, and the snippet + list all reflect the filtered set and nothing else. +6. **Treat the reindex as a blocking step of D0**, not a D4/D5- + era polish item — otherwise the index is the silent leak + path during the dual-write window. The reindex re-runs the + existing `content_index_worker::drain_once()` loop after + `TantivyContentIndex::open_or_rebuild()` detects the schema + version bump; no separate CLI command needed (per the audit). + +Hurl coverage in D0 includes a concrete anti-enumeration test: +user A indexes a file in a drive user B can't see, user B searches +the indexed term, response is empty + no hidden-count leak. Filtering on a low-cardinality `drive_id` is something Tantivy handles natively; this is bookkeeping, not a query-plan risk. @@ -809,6 +1155,72 @@ ancestry but **does NOT** propagate `updated_by` — ETags are fingerprints of structure, not authorship. Only the direct mutation site updates `updated_by`. +### 15. Global sections — scope mapping + +"Global sections" are the user-facing views that aggregate across +the filesystem rather than browsing a single folder: Photos, Music +library, Favorites, Recent items, Search, Trash. With drives +landing, each of these needs an explicit scope decision. The +table below locks the choices; the rationale is **noise risk by +file type**, not a uniform rule. + +| Section | Scope | Capability flag (per-drive policy) | Why | +|---|---|---|---| +| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Shared drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for shared drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). | +| **Music** — library view (future) + playlists | Cross-drive (all accessible drives) | `policies.forbid_music_index = true` to opt a drive out | Audio files in shared drives are almost always intentional content (band collaboration, family music, podcast archive). Defaulting cross-drive matches user intent. Owner opts a drive out for the rare case it shouldn't be indexed. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. | +| **Music playlists** (`audio.playlists`) | User-scoped, cross-drive curation | n/a | Playlists are a curation tool. `owner_id` stays on `auth.users(id)`; tracks reference files via `playlist_items.file_id` and may live in any drive the user has access to. At list time, `list_playlist_tracks` filters out tracks in drives the caller can no longer reach (see §11's defense-in-depth pattern). | +| **Favorites** (`/api/favorites/resources`) | Cross-drive (all accessible drives) | n/a | Personal organisation tool. Star a PDF from the work drive AND a photo from Personal — the whole point is cross-drive curation. ReBAC visibility check at list time drops rows the user can no longer reach. | +| **Recent items** (`/api/recent/*`) | Cross-drive (all accessible drives) | n/a | Personal history. Same shape as Favorites — you touched files across drives; the timeline reflects that. ReBAC visibility check at list time. | +| **Search** (`/api/search`) | Cross-drive (all accessible drives) | n/a | Discovery tool. See §11 for the Must-clause filter + handler-side ReBAC re-verification + anti-enum response shape. | +| **Trash** (`/api/trash/resources`) | Per-drive (owner-actioned) | n/a | Already specified in §12 — trash listing filters by drive(s) the caller can read; mutations require the owner role on the drive. | + +#### Capability flag mechanism + +Both `policies.include_in_photo_index` and +`policies.forbid_music_index` live under the same JSONB +`policies` column on `storage.drives` (see §8) — no new schema. +The default values reflect the table above: omitted = "off" for +photos (so non-default drives don't show photos unless the owner +opts in), omitted = "off" for music (so all accessible drives +*are* indexed unless the owner opts out). + +The owner-only UI in the drive settings panel toggles these +flags. The query layer reads them at request time; flipping +either flag is instant — no reindex required because the filter +applies in the query Must-clause, the index itself is unchanged. + +#### The Photos/Music asymmetry — defensible, not a smell + +Photos defaulting to "default-drive only" while Music defaults to +"cross-drive" is the one case where two similar surfaces have +different defaults. The justification is the noise-risk argument +above: image content in shared drives is heterogeneous (often +not "photos" in the gallery sense), audio content in shared +drives is usually intentional. The capability flags let owners +fix either case, but the defaults match what the typical user +will want without configuration. + +If a uniform rule is ever preferred, the cheapest move is to +flip Photos to cross-drive with `forbid_photo_index` as the +opt-out (mirroring Music). That can land later without a schema +change — just a behaviour change. + +#### Verification sketch + +The D0 Hurl suite (`tests/api/drives_foundation.hurl`) covers +the scope decisions concretely: + +- Photos: file uploaded in Personal appears in `/api/photos`; same + file uploaded into a secondary personal drive does NOT appear + unless `include_in_photo_index` is set on that drive. +- Music: track uploaded in any accessible drive appears in the + library / sweeper output; setting `forbid_music_index` on a + drive removes its tracks from the next library response. +- Favorites: star a file in drive A and a file in drive B (both + accessible to caller); list returns both. Lose access to drive + B → next list omits the B file (no error, just absent). +- Search: see §11 anti-enum test. + ## Migration strategy A drive-id column on every resource is a database surgery touching @@ -816,79 +1228,114 @@ every storage query. We phase it for safety: ### Phase A — additive (PR D0) -1. Create `storage.drives` and `storage.drive_members`. +1. Create `storage.drives` (no `name` column — see §3; has + `root_folder_id uuid NULL` populated in step 3). **No + `storage.drive_members` table** — membership lives in + `storage.role_grants` (created in D-Prep) as + `resource_type='drive'` rows. 2. Add `drive_id uuid NULL` to `storage.folders` and `storage.files`. -3. **Per-user root-folder sweep**: for each internal user, list - every `storage.folders` row where `parent_id IS NULL AND +3. **Per-user root-folder adoption sweep**: for each internal user, + list every `storage.folders` row where `parent_id IS NULL AND user_id = `. Exactly one is expected to be `My Folder - `; any extras are SQL-created siblings - (see §10). + (see §10). Each row is **adopted in place** as a drive's root + folder — no row is deleted, no descendant `parent_id` changes, + no path-prefix strip across the whole tree. - The `My Folder - ` row → becomes the **default - personal drive**: `INSERT INTO storage.drives (name='Personal', - kind='personal', default_for_user=, quota_bytes=)` - and insert one `(drive_id, user=, role='owner')` - member row. + personal drive's root folder**. In one CTE statement per + user (same shape as §3's `create_personal_drive`): + - INSERT into `storage.drives` with `kind='personal'`, + `default_for_user=`, `quota_bytes=` + (no `name` column). + - UPDATE the wrapper folder row: set `drive_id=` + and `name='Personal'` (renames the wrapper to the canonical + default name; the BEFORE-UPDATE `path` trigger fires and + cascades the new name down every descendant via the + existing AFTER-UPDATE cascade trigger — no per-row UPDATE + in the migration script). + - UPDATE `storage.drives` to set `root_folder_id=`. + - INSERT one `role_grants` row: subject=``, + `resource_type='drive'`, `resource_id=`, + `role='owner'`. - Every other sibling row → becomes a fresh **secondary - personal drive**: `kind='personal'`, `default_for_user=NULL`, - name carried over from the folder's `name`, - `quota_bytes=`, and one - `(drive_id, user=, role='owner')` member row. - Membership rules from §2 apply (single-owner, no `add_member`); - the user can later promote one to `kind='shared'` to invite - collaborators. -4. **Promote children, drop the wrapper**: for every folder/file - row that has `parent_id = `, set - `drive_id = ` and `parent_id = NULL` - (the drive itself is the new root, not a folder). Then DELETE - the root folder rows from step 3 — they no longer exist as - folders, the drive replaces them. -5. **Cascade `drive_id` down the tree** — for every remaining - folder/file row, set `drive_id` by walking the ancestry to - whichever root the row descends from. After this step every - row has the same `drive_id` as its `parent_id`'s row, which - chains up to a drive set in step 3/4. -6. **Full path-metadata reconstruction**. The `path` column on - **every** row in `storage.folders` and `storage.files` gets - rewritten. For rows that descended from `My Folder - `, - strip that prefix; for rows that descended from a sibling root, - strip that sibling's `name`. The path column now contains only - the in-drive path (e.g. `Docs/foo.pdf`, never - `My Folder - admin/Docs/foo.pdf`). - - **This is the bulk of D0's runtime cost.** Personal-drive - scope = every folder/file the user owns. A 100k-file user - gets 100k UPDATEs. Use a single `UPDATE … WHERE drive_id = - ` per drive, not a row-at-a-time loop. The ltree-path - change is what every downstream subsystem keys off, so doing - this in one transaction per drive (not per row) is also a - correctness boundary. - - **Downstream caches and indexes** — audit each for path or - path-derived keys: + personal drive's root folder**, same four-write CTE shape + except: `default_for_user=NULL`, no `name` change (the + sibling keeps its original name), and the Owner grant points + at the same user. Membership rules from §2 apply + (single-owner, no `add_member`); the user can later promote + one to `kind='shared'` to invite collaborators. +4. **Cascade `drive_id` down the tree** — for every folder/file + row, set `drive_id` by walking the ancestry up to whichever + adopted root the row descends from. After this step every row + has the same `drive_id` as its `parent_id`'s row, which chains + up to a root folder whose `drive_id` was set in step 3. + Reuse the existing ltree-aware recursive helper + (`storage.copy_folder_tree`-style descent) — single + `UPDATE … WHERE` per drive, not a row-at-a-time loop. +5. **No bulk path rewrite.** The `path` column on descendants is + untouched by this migration. The wrapper rename in step 3 + (`My Folder - admin` → `Personal`) is the only path-affecting + change; the BEFORE-UPDATE folder trigger rewrites the wrapper + row's own `path` / `lpath`, and the AFTER-UPDATE cascade + trigger propagates the new path prefix to every descendant + automatically. + - **Downstream caches and indexes** — most are unaffected + because path *content* changes only inside the renamed + wrapper segment (descendants reflect "Personal/…" instead of + "My Folder - admin/…"). Audit: - **Tantivy content index (§11)** — the index does NOT store paths (see `tantivy_content_index.rs`: indexed fields are `file_id`, `user_id`, `name` (basename only), - `content`. No `path` field, the wrapper folder name was - never a term). So the wrapper removal alone requires no - reindex. The reindex §11 calls for is driven by the - schema gaining `drive_id` and the query filter pivoting - from `user_id` to `drive_id` — NOT by the path rewrite. - Same migration window, but for a different reason. - - **Thumbnail cache** — if keyed by path rather than - file_id, invalidate; preferably switch to file_id-keyed - during this migration so the issue doesn't recur. Audit - before D0 starts. + `content`. No `path` field). Reindex IS still required — + not because of paths but because the schema gains + `drive_id` and the query filter pivots from `user_id` to + `drive_id`. Same migration window, different reason. + - **Thumbnail cache** — file_id-keyed: unaffected by the + wrapper rename. Path-keyed entries (if any) invalidate on + any path change in the wrapper; flush as a precaution and + switch to file_id keying during this migration if not + already done. - **Folder ETag queue (`async_tree_etag_queue`,** see Open - Question 8) — flush or recompute; ETags derived from old - paths are stale. + Question 8) — recompute. The wrapper rename touches the + wrapper's own ETag at minimum; ancestors-of-ancestors + below the wrapper are structurally unchanged. - **Recent-items / favorites** — referenced by file_id, not - path; probably fine. Verify. + path; unaffected. - **On-disk storage mirror** — see Open Question 10. If the - filesystem layout is path-mirrored, every file moves on - disk too; if content-addressable, the FS is untouched. - Audit before D0 starts. -7. Verify: every row has `drive_id IS NOT NULL`, no row has - `parent_id` pointing at a non-existent folder, no `path` value - contains the legacy `My Folder - ` prefix. -8. Add `NOT NULL` constraint on `drive_id`. + filesystem layout mirrors `path`, the wrapper directory + itself is renamed (one `mv`) and the descendant directories + don't move; the rename is atomic on the filesystem. If + content-addressable, the FS is untouched. +6. Verify: every row has `drive_id IS NOT NULL`; every drive has + `root_folder_id IS NOT NULL` and pointing at a real folder row + whose `parent_id IS NULL` and whose `drive_id` matches the + drive's id (the 1:1 invariant from §3); no row has `parent_id` + pointing at a non-existent folder. +7. Add `NOT NULL` constraints: `drive_id` on `storage.folders` + and `storage.files`. `root_folder_id` on `storage.drives` + stays NULLable at the column level (§3 explains why — the + atomic CTE writes NULL on the drive INSERT and populates the + column with an UPDATE later in the same statement; a column- + level `NOT NULL` would refuse the initial INSERT). The + invariant "every drive has a root folder" is enforced by the + CTE being the only creation path, not by a constraint. + Verification step 6 checks the invariant on the populated + dataset; ongoing enforcement is application-layer. +8. Land the **no-orphan-root-folder constraint trigger** (§3, + "DB-level invariant"): `AFTER INSERT OR UPDATE` on + `storage.folders`, fires when `NEW.parent_id IS NULL`, refuses + unless the matching drive has its `root_folder_id` pointing at + the row. `DEFERRABLE INITIALLY DEFERRED` so the atomic + four-write transaction commits cleanly. Includes a pre-flight + `DO`-block that refuses the migration if any existing orphan + root folder is present (catches historical bad data at migration + time). **Direct test coverage is deferred to D3** — writing the + positive-path test today would require reimplementing the + atomic create flow in bash, which Ed rejected as duplication. + D0 ships with transitive coverage via `drives_foundation.hurl` + (the lifecycle hook exercises the trigger end-to-end through + the production path); the dedicated test rides alongside D3's + create-shared-drive API. **Keep `user_id`** on resources alongside `drive_id` for the entire Phase A release cycle. Code is updated to read `drive_id` everywhere; @@ -1027,18 +1474,26 @@ place on `storage.files` / `storage.folders`, which already carry not. 10. **On-disk storage mirror — does the file path under - `OXICLOUD_STORAGE_PATH` change too?** Phase A step 6 strips - the `My Folder - /` prefix from `storage.folders.path` - / `storage.files.path` columns. If the on-disk layout mirrors - these paths (`//My Folder - admin/Docs/foo.pdf`), - the migration also has to `mv` every file on disk. If on-disk - is content-addressable (BLAKE3-keyed), the columns can be - rewritten without touching the filesystem. **Audit the - storage adapter before starting D0** and decide whether the - migration script: - - just renames the path columns (CAS layout — cheap), or - - renames the path columns AND issues a `mv` per file - (path-mirrored layout — expensive on big instances). + `OXICLOUD_STORAGE_PATH` change too?** Phase A step 3 renames + the wrapper folder row (`My Folder - admin` → `Personal`) for + each default personal drive; the AFTER-UPDATE trigger + rewrites descendant `storage.folders.path` / + `storage.files.path` values automatically (no bulk UPDATE in + the migration script). If the on-disk layout mirrors these + paths (`//My Folder - admin/Docs/foo.pdf`), + the migration ALSO has to rename the wrapper directory on + disk — **but only the wrapper directory itself**, one `mv` + per drive, atomic on the filesystem; no descendant `mv` + needed. If on-disk is content-addressable (BLAKE3-keyed), + the columns can be rewritten without touching the filesystem + at all. **Audit the storage adapter before starting D0** and + decide whether the migration script: + - just lets the trigger rewrite the path columns (CAS layout + — cheap), or + - rewrites the path columns AND issues a single `mv` per + drive on disk (path-mirrored layout — still cheap; only + the wrapper directory moves, the subtree comes along for + free). The blob store is content-addressable as of v0.7.0 so most file content lives under `.blobs//` and is already wrapper-agnostic; the concern is only the @@ -1084,11 +1539,13 @@ place on `storage.files` / `storage.folders`, which already carry check uses this to resolve group-owner subjects. - **`folder_service::create_home_folder`** at `src/application/services/folder_service.rs:644` is where the - per-user wrapper folder is created today. Post-migration this - function **goes away** — there is no wrapper folder anymore. The - user-create lifecycle hook now creates a Drive row directly and - inserts the owner-role member row. The lifecycle path is the same; - the work it does shrinks. + per-user wrapper folder is created today. Post-migration the + function is **replaced** by a single `create_personal_drive` + call against `DriveRepository` that runs the §3 atomic CTE: + drive + root folder (named "Personal", `parent_id=NULL`, + `drive_id` pinned) + Owner `role_grants` row, all in one SQL + statement. The lifecycle path is the same; the work moves to + the drive repository. - **NC path resolver `nc_to_internal_path`** at `src/interfaces/nextcloud/webdav_handler.rs:51` and the native resolver `resolve_webdav_path` at @@ -1096,11 +1553,12 @@ place on `storage.files` / `storage.folders`, which already carry callsites that learn about drives. Both gain a "drive context" parameter resolved from the URL prefix (`/files//` or `{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives//` - for native). **Neither resolver prepends `My Folder - /` - anymore** — the storage path IS the in-drive path. Both - functions also get simpler, not more complex, despite gaining - the drive parameter (the personal-vs-shared branch is now a - metadata lookup, not a path-shape decision). + for native). Each resolves to the drive's root folder via + `drives.root_folder_id` and prepends that folder's `path` + (after the migration this is `Personal/…` for default personal + drives, the original sibling-root name for secondaries, the + shared-drive root name for shared drives). The personal-vs-shared + branch is now a single metadata lookup, not a path-shape decision. - **`MagicLinkInviteService`** and the share-notification pipeline (`RecipientNotificationService`) need the new policy checks (`forbid_external_sharing`, `forbid_sharing`) wired in at their @@ -1156,12 +1614,19 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features cleanly with the expected `DomainError`. - **Migration round-trip**: roll forward against a populated DB → every existing folder/file row has `drive_id` set (no NULLs); - every user has exactly one drive with `default_for_user` set; - sibling root folders became secondary `kind='personal'` drives; - every `storage.folders.path` and `storage.files.path` value has - the `My Folder - /` prefix stripped → roll back via - `sqlx migrate revert` → `drive_id` column gone, `user_id` intact - thanks to dual-write, original paths recovered. + every drive has `root_folder_id IS NOT NULL` and the row it + points at has `parent_id IS NULL AND drive_id = ` (the + 1:1 invariant from §3); every user has exactly one drive with + `default_for_user` set; sibling root folders became secondary + `kind='personal'` drives whose root folders kept their original + names; default-personal wrapper folders were renamed from + `My Folder - ` to `Personal` and the AFTER-UPDATE + trigger cascaded the rename down the descendant `path` values + → roll back via `sqlx migrate revert` → `drive_id` / + `root_folder_id` columns gone, `user_id` intact thanks to + dual-write, wrapper folder names restored to + `My Folder - ` (and the trigger cascade restores + descendant paths). - **Storage check**: post-migration `bash tests/api/storage_cleanup_check.sh` still reports a clean tree (no orphans). - **Tantivy reindex**: every indexed doc carries a `drive_id`; @@ -1396,6 +1861,13 @@ static/css/components/driveSwitcher.css ← D1 the `auth.app_passwords.drive_id` binding — see §9). - **Wrapper folder** — historical name for `My Folder - `, the folder created at registration - via `format!("My Folder - {}", username)`. **Retired** in the - Drive migration: drive root replaces it. Every reference to + via `format!("My Folder - {}", username)`. **Adopted** in the + Drive migration: the same folder row is renamed to `Personal` + and reused as the default personal drive's root folder + (`drives.root_folder_id`). No row is deleted; the wrapper IS + the root folder under the new model. Every reference to "wrapper" in older comments / docs is by definition pre-Drive. +- **Drive's root folder** — the folder row pointed at by + `storage.drives.root_folder_id`. `parent_id IS NULL`, + `drive_id` = the drive. Every drive has exactly one (§3); the + drive's display name lives on this folder's `name` column. diff --git a/justfile b/justfile index 893b6070..496ea08e 100644 --- a/justfile +++ b/justfile @@ -41,6 +41,7 @@ test-integration: DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \ RUSTFLAGS='--cfg integration_tests' \ cargo test --workspace --tests + bash tests/common/stop-db.sh test-one name: cargo test {{name}} diff --git a/migrations/20260802100000_drives_schema_additive.sql b/migrations/20260802100000_drives_schema_additive.sql new file mode 100644 index 00000000..18744d69 --- /dev/null +++ b/migrations/20260802100000_drives_schema_additive.sql @@ -0,0 +1,174 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- 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(), + + -- 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, + + -- The drive's mount-point folder. The display name lives here (drives + -- have no `name` column — see docs/plan/drive.md §3). NULL at the + -- column type level so the atomic creation CTE can INSERT the drive + -- row before the root folder exists, then UPDATE this column from + -- a later CTE branch within the same statement (a column-level + -- NOT NULL would refuse that initial INSERT). The invariant + -- "every drive has a root folder" is enforced by the CTE being + -- the only creation path, plus the M2 backfill populating this + -- column for migrated drives. Code reading this column may treat + -- it as Uuid (not Option); a NULL here is a bug. + root_folder_id UUID + REFERENCES storage.folders(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. **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) — + -- not in the drive `owner` role bundle. + 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 — pure metadata. The display name and mount point live ' + 'on the root folder (root_folder_id). Membership lives in ' + 'storage.role_grants with resource_type=''drive''. Replaces the ' + 'per-user My Folder wrapper at D0 (see docs/plan/drive.md §3).'; +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.root_folder_id IS + 'Drive''s root folder. NULLable at the column level only so the ' + 'atomic creation CTE can write it mid-statement; populated invariant ' + 'enforced by application. Display name = SELECT name FROM ' + 'storage.folders WHERE id = root_folder_id.'; +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/20260802100001_drives_backfill.sql b/migrations/20260802100001_drives_backfill.sql new file mode 100644 index 00000000..24811b5c --- /dev/null +++ b/migrations/20260802100001_drives_backfill.sql @@ -0,0 +1,438 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / M2 — Drive backfill: adopt wrappers + stamp drive_id + provenance +-- ════════════════════════════════════════════════════════════════════════════ +-- Second of the D0 migration trio. Implements §A of the migration plan in +-- docs/plan/drive.md — the "rename-and-adopt" model: +-- +-- * For every internal user with a root folder, create a Personal drive +-- (metadata only — no `name` column; the display name lives on the +-- root folder). +-- * The folder literally named `My Folder - ` is **adopted +-- in place** as the user's default Personal drive's root folder: +-- `drives.root_folder_id` points at it, its `drive_id` is stamped, +-- and it is renamed to `Personal`. The wrapper row is NOT deleted; +-- descendants are NOT promoted. The AFTER-UPDATE folder cascade +-- trigger rewrites descendant `path`/`lpath` automatically when the +-- wrapper rename fires — no bulk path UPDATE in this migration. +-- * Any sibling root folders become secondary Personal drives' +-- root folders (`default_for_user = NULL`, original folder name +-- preserved). Same adoption pattern: drive_id stamped, drives.root_folder_id +-- wired, no rename. +-- * 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. +-- +-- 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 1b: refuse on rename collision with sibling root 'Personal' ─ +-- The default-wrapper rename in step 4 changes `My Folder - ` → +-- `Personal`. The pre-M3 folder unique index is user_id-scoped +-- (`(name, user_id) WHERE parent_id IS NULL`), so a user who already has +-- a SQL-created sibling root literally named `Personal` would trip the +-- index when M2 tries to rename the wrapper. Surface the collision now — +-- operator renames the offending sibling before retrying, then it gets +-- adopted as a secondary drive with whatever new name it carries. + +DO $BODY$ +DECLARE + collisions BIGINT; +BEGIN + SELECT count(*) INTO collisions + FROM auth.users u + JOIN storage.folders wrapper + ON wrapper.user_id = u.id + AND wrapper.parent_id IS NULL + AND NOT wrapper.is_trashed + AND wrapper.name = 'My Folder - ' || u.username + JOIN storage.folders sibling + ON sibling.user_id = u.id + AND sibling.parent_id IS NULL + AND NOT sibling.is_trashed + AND sibling.id != wrapper.id + AND sibling.name = 'Personal' + WHERE NOT u.is_external; + + IF collisions > 0 THEN + RAISE EXCEPTION + 'D0 backfill refused: % user(s) have both a `My Folder - ` ' + 'wrapper AND a sibling root named ''Personal''. The wrapper rename ' + 'step would collide on the user_id-scoped folder unique index. ' + 'Rename the offending sibling first. Query to inspect: SELECT u.id, ' + 'u.username FROM auth.users u JOIN storage.folders w ON w.user_id=u.id ' + 'AND w.parent_id IS NULL AND w.name=''My Folder - ''||u.username ' + 'JOIN storage.folders s ON s.user_id=u.id AND s.parent_id IS NULL ' + 'AND s.id!=w.id AND s.name=''Personal'' WHERE NOT u.is_external;', + collisions; + 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 (metadata only — no `name` column) ─────────── +-- Drives are pure metadata under the new design (docs/plan/drive.md §3). +-- The display name lives on the root folder; the wrapper is renamed in +-- step 4b for default drives and kept as-is for secondaries. + +INSERT INTO storage.drives + (id, kind, default_for_user, quota_bytes) +SELECT + p.new_drive_id, + '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. Adopt the wrapper as the drive's root folder ─────────────────────── +-- 4a. Stamp drive_id on each wrapper so the cascade in §5 can walk the +-- ltree subtree without a separate index. +-- 4b. Rename the default-drive wrapper from `My Folder - ` to +-- `Personal` (the canonical default name; renameable via the folder +-- API later). The BEFORE-UPDATE folder path trigger fires on the +-- rename and the AFTER-UPDATE cascade trigger rewrites every +-- descendant `path` / `lpath` automatically — no per-row UPDATE here. +-- 4c. Wire drives.root_folder_id to the wrapper. This is the adoption +-- step: the wrapper row IS the drive's root folder after M2 (no +-- wrapper-deletion, no descendant promotion). + +UPDATE storage.folders f + SET drive_id = p.new_drive_id, + name = CASE WHEN p.is_default THEN 'Personal' ELSE f.name END + FROM _drive_plan p + WHERE f.id = p.wrapper_id; + +UPDATE storage.drives d + SET root_folder_id = p.wrapper_id + FROM _drive_plan p + WHERE d.id = p.new_drive_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; + rootless_drives 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; + + -- Root-folder adoption invariant (docs/plan/drive.md §3): every + -- drive must point at a real folder row whose drive_id closes the + -- cycle. The column is NULLable at the type level so the atomic + -- CTE can write it mid-statement; this check enforces the data + -- invariant after the migration. + SELECT count(*) INTO rootless_drives + FROM storage.drives d + WHERE d.root_folder_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM storage.folders f + WHERE f.id = d.root_folder_id + AND f.drive_id = d.id + AND f.parent_id IS NULL + ); + IF rootless_drives > 0 THEN + RAISE EXCEPTION + 'D0 backfill consistency check failed: % drive(s) have no ' + 'valid root_folder_id (NULL, or pointing at a folder that ' + 'isn''t a root in this drive). Investigate before declaring ' + 'the migration successful.', + rootless_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/20260802100002_drives_not_null.sql b/migrations/20260802100002_drives_not_null.sql new file mode 100644 index 00000000..2cc3ce3b --- /dev/null +++ b/migrations/20260802100002_drives_not_null.sql @@ -0,0 +1,130 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- 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); + + +-- ── 3b. Drive-scoped folder uniqueness indexes ───────────────────────────── +-- Pre-D0 the "no duplicate folder name under the same parent for the same +-- user" constraint was user_id-scoped (docs/plan/drive.md §10). The +-- semantics users actually want is "no duplicate names *within a drive*" +-- — a folder named "Reports" in your Personal drive shouldn't preclude +-- another "Reports" in a shared "Team" drive. Flip the scope here, now +-- that every row has a drive_id. +-- +-- Same partial predicate as the originals (NOT is_trashed, plus the +-- root-vs-non-root split via parent_id IS NULL). + +DROP INDEX IF EXISTS storage.idx_folders_unique_name; +DROP INDEX IF EXISTS storage.idx_folders_unique_name_root; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name + ON storage.folders(parent_id, name, drive_id) + WHERE NOT is_trashed AND parent_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_unique_name_root + ON storage.folders(name, drive_id) + WHERE NOT is_trashed AND parent_id IS NULL; + + +-- ── 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/20260802100003_tree_etag_drive_id.sql b/migrations/20260802100003_tree_etag_drive_id.sql new file mode 100644 index 00000000..c5c39cff --- /dev/null +++ b/migrations/20260802100003_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/20260802100004_copy_folder_tree_drive_id.sql b/migrations/20260802100004_copy_folder_tree_drive_id.sql new file mode 100644 index 00000000..8addc63f --- /dev/null +++ b/migrations/20260802100004_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/migrations/20260803000000_no_orphan_root_folder_trigger.sql b/migrations/20260803000000_no_orphan_root_folder_trigger.sql new file mode 100644 index 00000000..588a8632 --- /dev/null +++ b/migrations/20260803000000_no_orphan_root_folder_trigger.sql @@ -0,0 +1,164 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D0 / Step 8 — No-orphan-root-folder constraint trigger +-- ════════════════════════════════════════════════════════════════════════════ +-- Closes the "every root folder must belong to a drive" invariant at the DB +-- level (docs/plan/drive.md §3 "DB-level invariant: no orphan root folder" +-- and §10 Phase A step 8). +-- +-- The application-layer guarantee is the atomic four-write transaction in +-- DrivePgRepository::create_personal_drive_atomic — drive + root folder + +-- drives.root_folder_id wire-up + Owner role_grant, all-or-nothing. This +-- migration adds the DB-level belt for the suspenders: a CONSTRAINT TRIGGER +-- that refuses any `storage.folders` row with `parent_id IS NULL` unless +-- some drive's `root_folder_id` points back at it. +-- +-- DEFERRABLE INITIALLY DEFERRED is mandatory because the atomic creation +-- order is folder-INSERTed → drive-UPDATEd → COMMIT; an immediate trigger +-- would fire after the folder INSERT (before the drive UPDATE) and refuse +-- the row even though the transaction would close cleanly. Deferred to +-- COMMIT, the check sees the wired state. +-- +-- What this migration does NOT cover: +-- * The reverse direction — "drives.root_folder_id must point at a folder +-- whose parent_id IS NULL". A follow-up trigger on storage.drives can +-- close that seam; today the cascade FKs and the application invariant +-- keep it correct. +-- * DELETE handling — folder DELETE cascades to drive DELETE via the +-- drives.root_folder_id ON DELETE CASCADE FK from M1, so the +-- "deleting the root would leave the drive dangling" path is +-- structurally prevented. + + +-- ── Pre-flight: refuse if any existing orphan root folders are present ──── +-- Same pattern as M2: scan current data, RAISE EXCEPTION on any violation +-- so the trigger doesn't land into an inconsistent dataset that would +-- silently break later as soon as one of those rows gets UPDATEd. +-- +-- A row is an "orphan root folder" iff `parent_id IS NULL` and no drive's +-- `root_folder_id` equals its id. Trashed rows are exempt (they're soft- +-- deleted in place and the chroot resolver never lands on them). + +DO $BODY$ +DECLARE + orphan_count BIGINT; +BEGIN + SELECT count(*) INTO orphan_count + FROM storage.folders f + WHERE f.parent_id IS NULL + AND NOT f.is_trashed + AND NOT EXISTS ( + SELECT 1 FROM storage.drives d + WHERE d.root_folder_id = f.id + ); + + IF orphan_count > 0 THEN + RAISE EXCEPTION + 'D0 step-8 migration refused: % root folder(s) (parent_id IS NULL, ' + 'not trashed) have no drive pointing at them via root_folder_id. ' + 'These would silently fail the constraint trigger on their next ' + 'UPDATE. Inspect with: SELECT f.id, f.user_id, f.drive_id, f.name ' + 'FROM storage.folders f WHERE f.parent_id IS NULL AND NOT f.is_trashed ' + 'AND NOT EXISTS (SELECT 1 FROM storage.drives d WHERE d.root_folder_id ' + '= f.id); — then either wire each row to a drive or trash it before ' + 'retrying.', + orphan_count; + END IF; +END $BODY$; + + +-- ── 1. The check function ───────────────────────────────────────────────── +-- AFTER trigger so the row is already in the snapshot — the lookup against +-- storage.drives correctly sees the UPDATE that closed the cycle (when +-- called from the atomic transaction, that UPDATE happens later in the +-- same tx; the DEFERRED firing time waits for COMMIT so visibility is +-- correct). + +CREATE OR REPLACE FUNCTION storage.check_no_orphan_root_folder() +RETURNS trigger AS $$ +BEGIN + -- The trigger fires for every INSERT/UPDATE on storage.folders. We + -- only need to enforce the invariant on root rows; non-root folders + -- are guaranteed correct by their parent_id FK. + IF NEW.parent_id IS NOT NULL THEN + RETURN NULL; + END IF; + + -- Trashed root folders are soft-deleted in place — the resolver + -- never lands on them, and they were valid roots before they got + -- trashed. Skip enforcement; the row's history is preserved. + IF NEW.is_trashed THEN + RETURN NULL; + END IF; + + -- The core check: some drive must be pointing at this row as its + -- root_folder_id, AND that drive must be the same one carrying our + -- drive_id (the 1:1 bidirectional invariant from §3). + IF NOT EXISTS ( + SELECT 1 FROM storage.drives d + WHERE d.id = NEW.drive_id + AND d.root_folder_id = NEW.id + ) THEN + RAISE EXCEPTION + 'Orphan root folder rejected: storage.folders id=% has ' + 'parent_id IS NULL and drive_id=%, but no drive has ' + 'root_folder_id pointing at it. Root folders must be ' + 'created via the atomic four-write transaction (see ' + 'docs/plan/drive.md §3 and DrivePgRepository::' + 'create_personal_drive_atomic); direct SQL is not ' + 'supported.', + NEW.id, NEW.drive_id; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION storage.check_no_orphan_root_folder() IS + 'DB-level guard for the "every root folder belongs to a drive" ' + 'invariant. Wired as a DEFERRABLE INITIALLY DEFERRED constraint ' + 'trigger so the atomic create transaction (folder INSERTed before ' + 'drive UPDATEd) commits cleanly. See docs/plan/drive.md §3.'; + + +-- ── 2. The constraint trigger ───────────────────────────────────────────── +-- CONSTRAINT TRIGGER (vs regular trigger) is what lets us declare +-- DEFERRABLE INITIALLY DEFERRED. Without it the row-level fire happens +-- immediately after the INSERT and the atomic transaction can't possibly +-- have UPDATEd drives.root_folder_id yet — every legitimate create would +-- be rejected. +-- +-- CONSTRAINT TRIGGERs don't support a WHEN clause; the parent_id / +-- is_trashed filtering lives inside the function above. + +DROP TRIGGER IF EXISTS trg_no_orphan_root_folder ON storage.folders; +CREATE CONSTRAINT TRIGGER trg_no_orphan_root_folder + AFTER INSERT OR UPDATE ON storage.folders + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION storage.check_no_orphan_root_folder(); + + +-- ── 3. Post-flight: confirm the trigger landed ──────────────────────────── +-- Belt-and-suspenders: PostgreSQL silently does nothing if CREATE TRIGGER +-- fails to attach (unlikely, but a cosmic-ray check). Refusing the +-- migration here surfaces the bug rather than letting it commit silently. + +DO $BODY$ +DECLARE + trigger_exists BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'storage' + AND c.relname = 'folders' + AND t.tgname = 'trg_no_orphan_root_folder' + AND NOT t.tgisinternal + ) INTO trigger_exists; + + IF NOT trigger_exists THEN + RAISE EXCEPTION + 'D0 step-8 migration post-flight failed: trigger ' + 'trg_no_orphan_root_folder did not attach to storage.folders.'; + END IF; +END $BODY$; diff --git a/src/application/dtos/drive_dto.rs b/src/application/dtos/drive_dto.rs new file mode 100644 index 00000000..42fbfd01 --- /dev/null +++ b/src/application/dtos/drive_dto.rs @@ -0,0 +1,82 @@ +//! 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::DriveKind; +use crate::domain::repositories::drive_repository::DriveWithRootName; + +#[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, + /// Display name. Sourced from `storage.folders.name` of the row + /// pointed at by `root_folder_id` (drives have no `name` column — + /// see docs/plan/drive.md §3). The wire shape is unchanged from + /// the client's perspective. + pub name: String, + pub kind: DriveKindDto, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_for_user: Option, + /// The drive's mount-point folder. Folder API calls + /// (`POST /api/folders { parent_id: }`, + /// `PATCH /api/folders/` to rename) use this id — + /// no polymorphic "create at drive root" surface needed. + pub root_folder_id: Uuid, + /// Storage cap in bytes. `None` means "no quota" (admin override / + /// future system drives). Mutation is OxiCloud-admin only — drive + /// owners cannot self-grant capacity. + #[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: DriveWithRootName) -> Self { + Self { + id: d.drive.id, + name: d.root_folder_name, + kind: d.drive.kind.into(), + default_for_user: d.drive.default_for_user, + root_folder_id: d.drive.root_folder_id, + quota_bytes: d.drive.quota_bytes, + used_bytes: d.drive.used_bytes, + policies: d.drive.policies, + created_at: d.drive.created_at, + updated_at: d.drive.updated_at, + } + } +} diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index b55031f7..00829344 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::domain::entities::file::File; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use uuid::Uuid; use super::display_helpers::{ category_for, format_file_size, icon_class_for, icon_special_class_for, @@ -75,6 +76,19 @@ pub struct FileDto { /// through `If-Match` / `If-None-Match` on download / mutation /// endpoints without a separate HEAD round-trip. pub etag: String, + + /// §14 provenance: user that originally created this file. + /// `None` when the referenced user has been deleted (FK is + /// `ON DELETE SET NULL`) or for stub/legacy files. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + + /// §14 provenance: user that performed the most recent mutation + /// that bumped `updated_at`. Authorship signal — distinct from + /// `owner_id`. `None` when the referenced user is deleted or for + /// stub/legacy files. + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, } impl From for FileDto { @@ -114,6 +128,8 @@ impl From for FileDto { sort_date: None, content_hash, etag, + created_by: parts.created_by, + updated_by: parts.updated_by, } } } @@ -171,6 +187,8 @@ impl FileDto { content_hash: String::new(), etag: String::new(), sort_date: None, + created_by: None, + updated_by: None, } } } diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 1cd5ca3a..929215f4 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -52,6 +52,12 @@ pub struct FolderDto { #[serde(skip_serializing_if = "Option::is_none")] pub owner_id: Option, + /// Drive that owns this folder. The scope axis for path-based + /// lookups across REST / WebDAV / NextCloud / CalDAV / CardDAV. + /// Post-D0 `storage.folders.drive_id` is `NOT NULL`; stub / + /// DTO-reconstructed folders carry `Uuid::nil()`. + pub drive_id: Uuid, + /// Creation timestamp pub created_at: u64, @@ -80,6 +86,19 @@ pub struct FolderDto { /// pass it back through `If-Match` on rename / move endpoints /// without a separate HEAD round-trip. pub etag: String, + + /// §14 provenance: user that originally created this folder. + /// `None` when the referenced user has been deleted (FK is + /// `ON DELETE SET NULL`) or for stub/legacy folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + + /// §14 provenance: user that performed the most recent mutation + /// that bumped `updated_at`. Authorship signal — distinct from + /// `owner_id`. `None` when the referenced user is deleted or for + /// stub/legacy folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, } impl From for FolderDto { @@ -93,6 +112,7 @@ impl From for FolderDto { path: folder.path_string().to_string(), parent_id: folder.parent_id().map(String::from), owner_id: folder.owner_id().map(|u| u.to_string()), + drive_id: folder.drive_id(), created_at: folder.created_at(), modified_at: folder.modified_at(), is_root, @@ -100,6 +120,8 @@ impl From for FolderDto { icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), etag, + created_by: folder.created_by(), + updated_by: folder.updated_by(), } } } @@ -144,6 +166,7 @@ impl FolderDto { path: "/stub/path".to_string(), parent_id: None, owner_id: None, + drive_id: Uuid::nil(), created_at: 0, modified_at: 0, is_root: true, @@ -151,6 +174,8 @@ impl FolderDto { icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), etag: String::new(), + created_by: None, + updated_by: None, } } } 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/file_ports.rs b/src/application/ports/file_ports.rs index cdfec8ce..fa68438c 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -44,24 +44,45 @@ pub trait FileUploadUseCase: Send + Sync + 'static { /// Register a new file row pointing at an already-ingested blob. /// /// Takes ownership of the blob's reference (released on failure). + /// + /// `caller_id` is plumbed down into + /// `FileWritePort::save_file_with_blob` so the §14 `created_by` / + /// `updated_by` columns record the principal performing the upload — + /// not the parent folder's owner. D2 shared drives surface this + /// most clearly: Adam upload into Alice's folder must record + /// `created_by = adam.id`. async fn upload_file_streaming( &self, name: String, folder_id: Option, content_type: String, blob: StoredBlob, + caller_id: Uuid, ) -> Result; /// Replace the content of the file at `path` with an already-ingested /// blob, or create the file when it doesn't exist (WebDAV/WOPI PUT). /// /// Takes ownership of the blob's reference (released on failure). + /// + /// `drive_id` scopes both the existence probe (`find_file_by_path`) + /// and the parent-folder resolution (`get_parent_folder_id`) — the + /// handler is responsible for deriving it from its protocol context + /// (NC chroot, native default-drive lookup, WOPI default-drive). + /// + /// `caller_id` is plumbed down into + /// `FileWritePort::update_file_content_with_blob` so the §14 + /// `updated_by` column reflects the principal that performed the + /// PUT — not the file's existing owner (D2 shared drives let + /// non-owners overwrite content). async fn update_file_streaming( &self, path: &str, + drive_id: Uuid, blob: StoredBlob, content_type: &str, modified_at: Option, + caller_id: Uuid, ) -> Result; } @@ -104,8 +125,13 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static { caller_id: Uuid, ) -> Result; - /// Gets a file by its path (for WebDAV) - async fn get_file_by_path(&self, path: &str) -> Result; + /// Gets a file by its path (for WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.files.path` is unique only within a single + /// drive. The `drive_id` filter scopes the lookup to a specific + /// drive (caller derives it from its protocol context: NC chroot, + /// native default-drive lookup, WOPI default-drive lookup). + async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result; /// Lists files in a folder async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError>; diff --git a/src/application/ports/folder_ports.rs b/src/application/ports/folder_ports.rs index 8b547ef9..ac043caa 100644 --- a/src/application/ports/folder_ports.rs +++ b/src/application/ports/folder_ports.rs @@ -36,8 +36,20 @@ pub trait FolderUseCase: Send + Sync + 'static { caller_id: Uuid, ) -> Result; - /// Gets a folder by its path - async fn get_folder_by_path(&self, path: &str) -> Result; + /// Gets a folder by its path within the caller's tree. + /// + /// Scoped by `drive_id` because `storage.folders.path` is unique + /// only within a single drive after D0 — multiple drives (whether + /// owned by the same user or different users) share names like + /// `"Personal"` for their root folder (docs/plan/drive.md §10). + /// Pre-D0 the wrapper name embedded the username; post-D0 the + /// caller derives a `drive_id` from its protocol context (NC + /// chroot, native default-drive lookup, WOPI default-drive). + async fn get_folder_by_path( + &self, + path: &str, + drive_id: Uuid, + ) -> Result; /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; @@ -84,13 +96,6 @@ pub trait FolderUseCase: Send + Sync + 'static { /// Deletes a folder (ownership verified against caller_id) 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. - async fn create_home_folder( - &self, - user_id: Uuid, - name: String, - ) -> Result; - /// Lists every folder in a subtree rooted at `folder_id` (inclusive), /// ordered by path. Uses ltree `<@` — single GiST-indexed query. /// diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 41fb559c..bda99dac 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -82,11 +82,25 @@ pub trait FileReadPort: Send + Sync + 'static { /// Gets the logical storage path of a file. async fn get_file_path(&self, id: &str) -> Result; - /// Gets the parent folder ID from a path (WebDAV). - async fn get_parent_folder_id(&self, path: &str) -> Result; + /// Gets the parent folder ID from a path (WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive. The `drive_id` filter scopes the lookup to a specific + /// drive (caller derives it from its protocol context: NC chroot, + /// native default-drive lookup, WOPI default-drive lookup). + async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid) + -> Result; - /// Gets a folder ID by its path. - async fn get_folder_id_by_path(&self, folder_path: &str) -> Result; + /// Gets a folder ID by its path, scoped to a drive. + /// + /// Post-D0 same scoping rule as `get_parent_folder_id` — names like + /// `"Personal"` repeat across drives, so the `drive_id` filter is + /// required to disambiguate. + async fn get_folder_id_by_path( + &self, + folder_path: &str, + drive_id: Uuid, + ) -> Result; /// Gets the content-addressable blob hash for a file (O(1) DB lookup). /// @@ -94,11 +108,22 @@ pub trait FileReadPort: Send + Sync + 'static { /// Used for dedup reference tracking without loading file content. async fn get_blob_hash(&self, file_id: &str) -> Result; - /// Find a file by its logical path (folder_name/.../file_name). + /// Find a file by its logical path (folder_name/.../file_name), + /// scoped to a drive. + /// + /// Post-D0 `storage.files.path` is unique only within a single + /// drive. The `drive_id` filter prevents non-deterministic + /// resolution when the same path exists in multiple drives. /// /// The default implementation falls back to `list_files(None)` + linear - /// scan (O(N)). Repositories should override with a direct SQL query. - async fn find_file_by_path(&self, path: &str) -> Result, DomainError> { + /// scan (O(N)) and ignores the drive filter — only used by stubs. + /// Repositories should override with a direct SQL query that applies + /// the filter. + async fn find_file_by_path( + &self, + path: &str, + _drive_id: Uuid, + ) -> Result, DomainError> { let path = path.trim_start_matches('/').trim_end_matches('/'); let all_files = self.list_files(None).await?; for file in all_files { @@ -259,6 +284,13 @@ pub trait FileWritePort: Send + Sync + 'static { /// /// Takes ownership of one blob reference: on any failure the reference /// is released before the error is returned. + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// (§14 provenance — authorship belongs to the caller, not to the + /// parent folder's owner). In D2 shared drives, a non-owner member + /// can upload into a folder owned by someone else; the previous + /// `created_by = parent.user_id` would have silently recorded the + /// wrong principal. async fn save_file_with_blob( &self, name: String, @@ -266,17 +298,29 @@ pub trait FileWritePort: Send + Sync + 'static { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result; - /// Moves a file to another folder. + /// Moves a file to another folder. `caller_id` is stamped into + /// `updated_by` alongside the `updated_at = NOW()` bump + /// (§14 provenance — authorship belongs to the caller, not to + /// the destination folder's owner). async fn move_file( &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result; - /// Renames a file (same folder, different name). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; + /// Renames a file (same folder, different name). `caller_id` is + /// stamped into `updated_by` alongside the `updated_at = NOW()` + /// bump (§14 provenance). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; @@ -290,24 +334,32 @@ pub trait FileWritePort: Send + Sync + 'static { /// Returns `(new_blob_hash, updated_at_epoch)` — everything a caller /// needs to rebuild the fresh entity/ETag from a `File` it already /// holds, without re-reading the row it just updated. + /// + /// `caller_id` is stamped into `updated_by` alongside the + /// `updated_at` bump (§14 provenance). async fn update_file_content_with_blob( &self, file_id: &str, blob_hash: &str, size: u64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError>; /// Registers file metadata WITHOUT writing content to disk (write-behind). /// /// Returns `(File, PathBuf)` where `PathBuf` is the destination path for the /// deferred write that the `WriteBehindCache` will perform. + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// (§14 provenance — see `save_file_with_blob`). async fn register_file_deferred( &self, name: String, folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError>; /// Copies a file to a (possibly different) folder. @@ -319,11 +371,16 @@ pub trait FileWritePort: Send + Sync + 'static { /// the same folder always collides on the source's filename. WebDAV /// COPY uses this for the "same folder, different name" case (the /// classic `COPY /a.txt → /b.txt` pattern). + /// + /// `caller_id` is stamped into both `created_by` and `updated_by` + /// on the new row (§14 provenance — the caller authored this copy, + /// not the destination folder's owner). async fn copy_file( &self, file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result; /// Copies an entire folder subtree atomically using ltree. @@ -350,14 +407,17 @@ pub trait FileWritePort: Send + Sync + 'static { // ── Trash operations ── - /// Moves a file to the trash - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + /// Moves a file to the trash. `caller_id` is stamped into + /// `updated_by` (§14 provenance). + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a file from the trash to its original location + /// Restores a file from the trash to its original location. + /// `caller_id` is stamped into `updated_by` (§14 provenance). async fn restore_from_trash( &self, file_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) diff --git a/src/application/ports/user_lifecycle.rs b/src/application/ports/user_lifecycle.rs index d5cf5b89..e927a3cf 100644 --- a/src/application/ports/user_lifecycle.rs +++ b/src/application/ports/user_lifecycle.rs @@ -127,7 +127,7 @@ pub enum LogoutReason { /// How aggressively `on_user_deleted` cleanup should run. Today both /// variants are equivalent (only `AuditLifecycleHook` exists, and it logs -/// regardless). The split exists so PR 4's `HomeFolderLifecycleHook` can +/// regardless). The split exists so PR 4's `PersonalDriveLifecycleHook` can /// trash on `AdminDelete` but hard-delete on `GdprPurge`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeletionMode { diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index a77e7125..8f94bfd6 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -124,7 +124,7 @@ pub struct AuthApplicationService { token_service: Arc, /// Dispatcher for user-lifecycle events. `None` only in tests that don't /// exercise the lifecycle path; production DI always wires this. - /// HomeFolderLifecycleHook (registered on this dispatcher) owns the + /// PersonalDriveLifecycleHook (registered on this dispatcher) owns the /// per-user folder provisioning that AuthApplicationService used to do /// inline pre-PR 3. user_lifecycle: Option>, @@ -404,7 +404,7 @@ impl AuthApplicationService { // Save user let created_user = self.user_storage.create_user(user).await?; - // Lifecycle: HomeFolderLifecycleHook handles personal-folder + // Lifecycle: PersonalDriveLifecycleHook handles personal-folder // creation (was inlined here pre-PR 3); audit log + future // provisioning steps land here too. if let Some(lc) = &self.user_lifecycle { @@ -506,8 +506,8 @@ impl AuthApplicationService { let created_user = self.user_storage.create_user(user).await?; // Lifecycle: notify hooks. PR 3 moves home-folder creation into - // HomeFolderLifecycleHook fired here. - // Lifecycle: HomeFolderLifecycleHook provisions the admin's + // PersonalDriveLifecycleHook fired here. + // Lifecycle: PersonalDriveLifecycleHook provisions the admin's // home folder. Audit logs the creation event. if let Some(lc) = &self.user_lifecycle { lc.dispatch_created(&created_user).await; @@ -654,7 +654,7 @@ impl AuthApplicationService { /// A second redemption attempt receives `Ok(false)` and is rejected /// as `AccessDenied`. /// 3. Load the user, verify they're active. - /// 4. Dispatch `on_user_login` (so HomeFolderLifecycleHook can + /// 4. Dispatch `on_user_login` (so PersonalDriveLifecycleHook can /// safety-net any internal user whose first credential happens /// to be a magic link — externals short-circuit by `is_external()`). /// 5. Register login + persist + issue session in the same pipeline @@ -1722,7 +1722,7 @@ impl AuthApplicationService { .await?; } - // Lifecycle: HomeFolderLifecycleHook handles the home-folder + // Lifecycle: PersonalDriveLifecycleHook handles the home-folder // provisioning (idempotent + short-circuits on is_external). // Audit logs the creation event. if let Some(lc) = &self.user_lifecycle { @@ -1785,7 +1785,7 @@ impl AuthApplicationService { /// Runs the whole flow in a single transaction so the lifecycle /// hooks (`SessionRevocationLifecycleHook` revoking sessions with /// audit, `AuthzCacheLifecycleHook` invalidating the Moka cache, - /// `HomeFolderLifecycleHook` for future trash policy, …) can do + /// `PersonalDriveLifecycleHook` for future trash policy, …) can do /// their work atomically with the user DELETE. If any hook returns /// `Err`, the transaction rolls back and the user remains intact. pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> { @@ -2260,7 +2260,7 @@ impl AuthApplicationService { // Lifecycle: created (audit + home-folder provisioning) + // login (no register_login() for a fresh OIDC user means // `last_login_at` is naturally None → first-login detection - // works). HomeFolderLifecycleHook creates the home folder. + // works). PersonalDriveLifecycleHook creates the home folder. if let Some(lc) = &self.user_lifecycle { lc.dispatch_created(&created_user).await; lc.dispatch_login(&created_user).await; @@ -2362,7 +2362,7 @@ impl AuthApplicationService { // `create_personal_folder` was removed in PR 3 of the // UserLifecycleHook migration — home-folder provisioning is now - // owned by `HomeFolderLifecycleHook` in folder_service.rs and runs + // owned by `PersonalDriveLifecycleHook` in folder_service.rs and runs // via `dispatch_created` / `dispatch_login`. } diff --git a/src/application/services/delta_upload_service.rs b/src/application/services/delta_upload_service.rs index caf2238a..f67dc015 100644 --- a/src/application/services/delta_upload_service.rs +++ b/src/application/services/delta_upload_service.rs @@ -623,6 +623,7 @@ impl DeltaUploadService { Some(folder_id.clone()), content_type, blob, + caller_id, ) .await } diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 4386284b..9292833b 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -98,6 +98,7 @@ impl FileManagementService { &self, file_id: &str, folder_id: Option, + caller_id: Uuid, ) -> Result { info!( "Moving file with ID: {} to folder: {:?}", @@ -106,7 +107,7 @@ impl FileManagementService { let moved_file = self .file_repository - .move_file(file_id, folder_id) + .move_file(file_id, folder_id, caller_id) .await .map_err(|e| { error!("Error moving file (ID: {}): {}", file_id, e); @@ -128,6 +129,7 @@ impl FileManagementService { file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result { info!( "Copying file with ID: {} to folder: {:?} as {:?}", @@ -136,7 +138,7 @@ impl FileManagementService { let copied_file = self .file_repository - .copy_file(file_id, target_folder_id, new_name) + .copy_file(file_id, target_folder_id, new_name, caller_id) .await .map_err(|e| { error!("Error copying file (ID: {}): {}", file_id, e); @@ -157,7 +159,12 @@ impl FileManagementService { Ok(dto) } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result { if let Err(reason) = validate_storage_name(new_name) { return Err(DomainError::validation_error(format!( "Invalid file name '{new_name}': {reason}" @@ -168,7 +175,7 @@ impl FileManagementService { let renamed_file = self .file_repository - .rename_file(file_id, new_name) + .rename_file(file_id, new_name, caller_id) .await .map_err(|e| { error!("Error renaming file (ID: {}): {}", file_id, e); @@ -253,7 +260,7 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(folder_id.as_deref(), Permission::Create, caller_id) .await?; - self.move_file(file_id, folder_id).await + self.move_file(file_id, folder_id, caller_id).await } async fn copy_file_with_perms( @@ -268,7 +275,7 @@ impl FileManagementUseCase for FileManagementService { .await?; self.require_target_folder_perm(target_folder_id.as_deref(), Permission::Create, caller_id) .await?; - self.copy_file(file_id, target_folder_id, new_name.as_deref()) + self.copy_file(file_id, target_folder_id, new_name.as_deref(), caller_id) .await } @@ -280,7 +287,7 @@ impl FileManagementUseCase for FileManagementService { ) -> Result { self.require_file_perm(file_id, Permission::Update, caller_id) .await?; - self.rename_file(file_id, new_name).await + self.rename_file(file_id, new_name, caller_id).await } async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> { diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 27ae48e7..afe4d5c1 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -286,13 +286,16 @@ impl FileRetrievalUseCase for FileRetrievalService { } // FIXME no authorisation at all - async fn get_file_by_path(&self, path: &str) -> Result { + async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result { // Direct SQL lookup — O(folder_depth) queries instead of O(total_files) // NOTE: This method does NOT perform any authorization check. Callers // that surface its result to a user-driven request MUST resolve the // file via get_file_owned afterwards, or call authz.require directly. // (Tracked in the audit punch-list under "path-based lookups".) - if let Some(file) = self.file_read.find_file_by_path(path).await? { + // `drive_id` scope axis prevents cross-drive resolution — without + // it, `find_file_by_path` would return a non-deterministic row + // when the same path exists in multiple drives. + if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? { return Ok(FileDto::from(file)); } diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 824883ec..612c1e0c 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -209,6 +209,7 @@ impl FileUploadService { size: metadata.size, is_new_blob: false, }, + caller_id, ) .await?; @@ -255,7 +256,7 @@ impl FileUploadService { let file = file_read.get_file(file_id).await?; let (new_hash, updated_at) = self .file_write - .update_file_content_with_blob(file_id, &blob.hash, blob.size, None) + .update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id) .await?; // The file maps to a different blob now — stale cached content must // never be served for the rest of its TTI window. @@ -326,10 +327,18 @@ impl FileUploadUseCase for FileUploadService { folder_id: Option, content_type: String, blob: StoredBlob, + caller_id: Uuid, ) -> Result { let file = self .file_write - .save_file_with_blob(name.clone(), folder_id, content_type, &blob.hash, blob.size) + .save_file_with_blob( + name.clone(), + folder_id, + content_type, + &blob.hash, + blob.size, + caller_id, + ) .await?; let dto = FileDto::from(file); info!( @@ -348,18 +357,26 @@ impl FileUploadUseCase for FileUploadService { async fn update_file_streaming( &self, path: &str, + drive_id: Uuid, blob: StoredBlob, content_type: &str, modified_at: Option, + caller_id: Uuid, ) -> Result { // Try to find the existing file first if let Some(file_read) = &self.file_read - && let Some(file) = file_read.find_file_by_path(path).await? + && let Some(file) = file_read.find_file_by_path(path, drive_id).await? { let file_id = file.id().to_string(); let (new_hash, updated_at) = self .file_write - .update_file_content_with_blob(&file_id, &blob.hash, blob.size, modified_at) + .update_file_content_with_blob( + &file_id, + &blob.hash, + blob.size, + modified_at, + caller_id, + ) .await?; // Invalidate content cache — file content has changed. if let Some(cc) = &self.content_cache { @@ -402,9 +419,15 @@ impl FileUploadUseCase for FileUploadService { // get_parent_folder_id expects the full file path — it strips the // last segment (filename) internally to find the parent folder. + // `drive_id` scopes the parent lookup to the same drive as the + // incoming write (post-D0 `storage.folders.path` repeats across + // drives). let parent_id = if path_normalized.contains('/') { if let Some(file_read) = &self.file_read { - file_read.get_parent_folder_id(path_normalized).await.ok() + file_read + .get_parent_folder_id(path_normalized, drive_id) + .await + .ok() } else { None } @@ -421,6 +444,7 @@ impl FileUploadUseCase for FileUploadService { content_type.to_string(), &blob.hash, blob.size, + caller_id, ) .await?; let dto = FileDto::from(created); diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 66f3893f..8ec34bf7 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -82,7 +82,11 @@ impl FolderService { Ok(FolderDto::empty()) } - async fn get_folder_by_path(&self, _path: &str) -> Result { + async fn get_folder_by_path( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { Ok(FolderDto::empty()) } @@ -163,14 +167,6 @@ impl FolderService { ) -> Result<(), DomainError> { Ok(()) } - - async fn create_home_folder( - &self, - _user_id: Uuid, - _name: String, - ) -> Result { - Ok(FolderDto::empty()) - } } FolderServiceStub @@ -229,31 +225,11 @@ impl FolderUseCase for FolderService { let folder = self .folder_storage - .create_folder(dto.name, dto.parent_id) + .create_folder(dto.name, dto.parent_id, caller_id) .await?; Ok(FolderDto::from(folder)) } - /// Creates a root-level home folder for a user during registration. - async fn create_home_folder( - &self, - user_id: Uuid, - name: String, - ) -> Result { - let folder = self - .folder_storage - .create_home_folder(user_id, name) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("Failed to create home folder: {}", e), - ) - })?; - - Ok(FolderDto::from(folder)) - } - async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { let folders = self.folder_storage.list_subtree_folders(folder_id).await?; Ok(folders.into_iter().map(FolderDto::from).collect()) @@ -288,14 +264,17 @@ impl FolderUseCase for FolderService { self.get_folder(id).await } - /// Gets a folder by its path - async fn get_folder_by_path(&self, path: &str) -> Result { - // Convert the string path to StoragePath + /// Gets a folder by its path, scoped to a drive. + async fn get_folder_by_path( + &self, + path: &str, + drive_id: Uuid, + ) -> Result { let storage_path = StoragePath::from_string(path); let folder = self .folder_storage - .get_folder_by_path(&storage_path) + .get_folder_by_path(&storage_path, drive_id) .await .map_err(|e| { DomainError::internal_error( @@ -329,7 +308,7 @@ impl FolderUseCase for FolderService { /// /// **Note (post PR 3):** the self-heal block that auto-created a /// home folder when listing returned empty has been removed. - /// `HomeFolderLifecycleHook` (registered on `UserLifecycleService`) + /// `PersonalDriveLifecycleHook` (registered on `UserLifecycleService`) /// now provisions the folder on `on_user_created` / `on_user_login`, /// idempotently, so the listing path no longer needs to self-heal. async fn list_folders_with_perms( @@ -477,7 +456,7 @@ impl FolderUseCase for FolderService { let folder = self .folder_storage - .rename_folder(id, dto.name) + .rename_folder(id, dto.name, caller_id) .await .map_err(|e| { DomainError::internal_error( @@ -529,7 +508,7 @@ impl FolderUseCase for FolderService { let parent_ref = dto.parent_id.as_deref(); let folder = self .folder_storage - .move_folder(id, parent_ref) + .move_folder(id, parent_ref, caller_id) .await .map_err(|e| { DomainError::internal_error( @@ -614,62 +593,6 @@ impl FolderService { Ok((rows, next_cursor)) } - - /// Idempotently provision a home folder for a user. - /// - /// Returns `Ok(true)` if a folder was newly created, `Ok(false)` if the - /// user already had at least one root folder. - /// - /// **System-level operation** — bypasses authz because this runs on - /// the user's own behalf (during creation or login provisioning) at a - /// point where the caller may be the engine itself, not an HTTP user. - /// Callers must be inside trusted code paths (lifecycle hooks). - /// - /// Used by [`HomeFolderLifecycleHook`] on `on_user_created` and - /// `on_user_login`. Replaces the old self-heal at the listing path - /// and the four eager `create_personal_folder` calls in - /// `AuthApplicationService` (removed in the same PR). - pub async fn ensure_home_folder( - &self, - user_id: Uuid, - username: Option<&str>, - ) -> Result { - let existing = self - .folder_storage - .list_folders_by_owner(None, user_id) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("ensure_home_folder: list root folders: {}", e), - ) - })?; - if !existing.is_empty() { - return Ok(false); - } - - let folder_name = match username { - Some(u) => format!("My Folder - {}", u), - None => format!("My Folder - {}", user_id), - }; - self.folder_storage - .create_home_folder(user_id, folder_name.clone()) - .await - .map_err(|e| { - DomainError::internal_error( - "FolderStorage", - format!("ensure_home_folder: create: {}", e), - ) - })?; - tracing::info!( - target: "user_lifecycle", - hook = "home_folder", - user_id = %user_id, - folder_name = %folder_name, - "Home folder provisioned" - ); - Ok(true) - } } /// Build the next-page cursor from the last row of the current page. @@ -725,7 +648,7 @@ fn build_folder_resource_cursor( } // ───────────────────────────────────────────────────────────────────────────── -// HomeFolderLifecycleHook +// PersonalDriveLifecycleHook // // Owns home-folder provisioning policy. Replaces: // - the 4 eager `create_personal_folder` calls in AuthApplicationService @@ -743,36 +666,115 @@ 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 { - folder_service: Arc, +/// 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, + // 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`. Only the idempotent-rerun path uses it + // now; the create path goes through the repo's atomic CTE which + // writes the role_grant inline. + authorization: Arc, } -impl HomeFolderLifecycleHook { - pub fn new(folder_service: Arc) -> Self { - Self { folder_service } +impl PersonalDriveLifecycleHook { + pub fn new( + drive_repo: Arc, + authorization: Arc, + ) -> Self { + Self { + drive_repo, + 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::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, + // the atomic CTE already ran on a prior turn. The CTE writes + // the Owner role_grant inline, so there's nothing to repair — + // but we still re-emit the grant via `set_role` (UPSERT-safe) + // to cover the historical case where a pre-CTE provisioning + // path partially completed (drive created, grant missing). + match self.drive_repo.find_default_for_user(user.id()).await { + Ok(drive_with_name) => { + self.authorization + .set_role( + user.id(), + Subject::User(user.id()), + Role::Owner, + Resource::Drive(drive_with_name.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}"), + )); + } + } + + // One atomic CTE — drive row + root folder ("Personal", + // parent_id=NULL, drive_id pinned) + drives.root_folder_id + // wire-up + Owner role_grant. Single SQL statement, atomic + // against server crash mid-sequence (docs/plan/drive.md §3). + let drive_with_name = self + .drive_repo + .create_personal_drive_atomic(user.id(), Some(user.storage_quota_bytes())) .await - .map(|_created| ()) + .map_err(|e| { + DomainError::internal_error( + "PersonalDriveHook", + format!("create_personal_drive_atomic: {e}"), + ) + })?; + + tracing::info!( + target: "user_lifecycle", + hook = "personal_drive", + user_id = %user.id(), + drive_id = %drive_with_name.drive.id, + root_folder_id = %drive_with_name.drive.root_folder_id, + "Default personal drive + root folder + owner grant provisioned (atomic CTE)" + ); + 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 +789,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 +800,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/idor_protection_test.rs b/src/application/services/idor_protection_test.rs index 5277a873..70a6909b 100644 --- a/src/application/services/idor_protection_test.rs +++ b/src/application/services/idor_protection_test.rs @@ -101,7 +101,11 @@ impl FileReadPort for MockFileReadPort { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { unimplemented!() } @@ -127,7 +131,11 @@ impl FileReadPort for MockFileReadPort { Ok(0) } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: Uuid, + ) -> Result { unimplemented!() } @@ -176,6 +184,7 @@ impl FileWritePort for MockFileWritePort { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -184,6 +193,7 @@ impl FileWritePort for MockFileWritePort { &self, file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> Result { let files = self.files.lock().unwrap(); files @@ -192,7 +202,12 @@ impl FileWritePort for MockFileWritePort { .ok_or_else(|| DomainError::not_found("File", file_id.to_string())) } - async fn rename_file(&self, file_id: &str, _new_name: &str) -> Result { + async fn rename_file( + &self, + file_id: &str, + _new_name: &str, + _caller_id: Uuid, + ) -> Result { let files = self.files.lock().unwrap(); files .get(file_id) @@ -210,6 +225,7 @@ impl FileWritePort for MockFileWritePort { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -220,6 +236,7 @@ impl FileWritePort for MockFileWritePort { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError> { unimplemented!() } @@ -229,11 +246,12 @@ impl FileWritePort for MockFileWritePort { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> Result { unimplemented!() } - async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -241,6 +259,7 @@ impl FileWritePort for MockFileWritePort { &self, _file_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { 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/nextcloud_login_flow_service.rs b/src/application/services/nextcloud_login_flow_service.rs index 0e943931..147c1db2 100644 --- a/src/application/services/nextcloud_login_flow_service.rs +++ b/src/application/services/nextcloud_login_flow_service.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use rand_core::RngCore; +use uuid::Uuid; /// Maximum number of concurrent pending login flows to prevent memory exhaustion. const MAX_PENDING_FLOWS: usize = 1000; @@ -30,6 +31,13 @@ pub struct LoginResult { struct PendingFlow { created_at: Instant, poll_token: String, + /// Set after the user authenticates on the login page **and** has more + /// than one root drive — the flow is paused until the user picks a + /// drive on the picker page. Consumed by `take_pending_user` when the + /// picker submission arrives, so the second step is single-use even + /// if the flow token leaks. `None` for single-drive accounts (legacy + /// path goes straight to `completed`). + pending_user_id: Option, completed: Option, } @@ -80,6 +88,7 @@ impl NextcloudLoginFlowService { PendingFlow { created_at: Instant::now(), poll_token: poll_token.clone(), + pending_user_id: None, completed: None, }, ); @@ -101,6 +110,39 @@ impl NextcloudLoginFlowService { state.flows.contains_key(flow_token) } + /// Stash a verified user_id on the flow so a follow-up drive-pick + /// request can prove "this browser just authenticated" without + /// asking for the password again. Returns `false` if the flow + /// token is unknown or expired. + /// + /// Only used on multi-drive accounts — single-drive logins go + /// straight to [`complete`](Self::complete). + pub fn mark_awaiting_drive(&self, flow_token: &str, user_id: Uuid) -> bool { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + match state.flows.get_mut(flow_token) { + Some(pending) => { + pending.pending_user_id = Some(user_id); + true + } + None => false, + } + } + + /// Consume the stashed user_id (single-use). Returns the user_id + /// when the flow is in "awaiting drive choice" state, or `None` + /// when the flow is unknown, expired, or was never marked. Single- + /// use semantics make this safe even if the flow token leaks: the + /// second drive-pick attempt finds nothing to consume. + pub fn take_pending_user(&self, flow_token: &str) -> Option { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + state + .flows + .get_mut(flow_token) + .and_then(|pending| pending.pending_user_id.take()) + } + pub fn complete( &self, flow_token: &str, @@ -257,6 +299,35 @@ mod tests { assert!(svc.poll(&info.poll_token).is_none()); } + #[test] + fn test_mark_awaiting_drive_then_take_pending_user() { + let svc = service(); + let info = svc.initiate("https://cloud.example.com").unwrap(); + let flow_token = info.login_url.rsplit('/').next().unwrap(); + let uid = Uuid::new_v4(); + + assert!(svc.mark_awaiting_drive(flow_token, uid)); + // First take consumes the slot. + assert_eq!(svc.take_pending_user(flow_token), Some(uid)); + // Second take must return None (single-use). + assert_eq!(svc.take_pending_user(flow_token), None); + } + + #[test] + fn test_mark_awaiting_drive_unknown_flow_returns_false() { + let svc = service(); + assert!(!svc.mark_awaiting_drive("nonexistent", Uuid::new_v4())); + } + + #[test] + fn test_take_pending_user_without_mark_returns_none() { + let svc = service(); + let info = svc.initiate("https://cloud.example.com").unwrap(); + let flow_token = info.login_url.rsplit('/').next().unwrap(); + // Flow exists but mark_awaiting_drive was never called. + assert_eq!(svc.take_pending_user(flow_token), None); + } + #[test] fn test_max_pending_flows_cap() { let svc = NextcloudLoginFlowService::new(Duration::from_secs(600)); diff --git a/src/application/services/people_service.rs b/src/application/services/people_service.rs index f03d9ad0..7dd260b8 100644 --- a/src/application/services/people_service.rs +++ b/src/application/services/people_service.rs @@ -199,7 +199,7 @@ impl PeopleService { }) }) .collect(); - out.sort_by(|a, b| b.face_count.cmp(&a.face_count)); + out.sort_by_key(|p| std::cmp::Reverse(p.face_count)); Ok(out) } 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..e0ed4602 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.drive.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..06bb64f0 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -838,11 +838,19 @@ mod tests { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: uuid::Uuid, + ) -> Result { unimplemented!() } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: uuid::Uuid, + ) -> Result { unimplemented!() } @@ -898,6 +906,7 @@ mod tests { &self, _name: String, _parent_id: Option, + _caller_id: uuid::Uuid, ) -> Result { unimplemented!() } @@ -925,6 +934,7 @@ mod tests { async fn get_folder_by_path( &self, _storage_path: &crate::domain::services::path_service::StoragePath, + _drive_id: uuid::Uuid, ) -> Result { unimplemented!() } @@ -971,6 +981,7 @@ mod tests { &self, _id: &str, _new_name: String, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -979,6 +990,7 @@ mod tests { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> Result { unimplemented!() } @@ -990,6 +1002,7 @@ mod tests { async fn folder_exists( &self, _storage_path: &crate::domain::services::path_service::StoragePath, + _drive_id: uuid::Uuid, ) -> Result { unimplemented!() } @@ -1001,7 +1014,11 @@ mod tests { unimplemented!() } - async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash( + &self, + _folder_id: &str, + _caller_id: Uuid, + ) -> Result<(), DomainError> { unimplemented!() } @@ -1009,6 +1026,7 @@ mod tests { &self, _folder_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { unimplemented!() } @@ -1016,14 +1034,6 @@ mod tests { async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { unimplemented!() } - - async fn create_home_folder( - &self, - _user_id: Uuid, - _name: String, - ) -> Result { - unimplemented!() - } } struct MockShareRepository { diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 589f4ae9..aa6a85d3 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -253,9 +253,10 @@ impl TrashUseCase for TrashService { } }; - // Then physically move the file to trash + // Then physically move the file to trash. + // §14: caller_id stamps `updated_by` on the trashed row. info!("Physically moving file to trash: {}", item_id); - match self.file_write_port.move_to_trash(item_id).await { + match self.file_write_port.move_to_trash(item_id, user_id).await { Ok(_) => { debug!("File physically moved to trash successfully: {}", item_id); } @@ -320,9 +321,10 @@ impl TrashUseCase for TrashService { } }; - // Then physically move the folder to trash + // Then physically move the folder to trash. + // §14: caller_id stamps `updated_by` on every cascade-trashed row. self.folder_storage_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -391,7 +393,7 @@ impl TrashUseCase for TrashService { ); match self .file_write_port - .restore_from_trash(&file_id, &original_path) + .restore_from_trash(&file_id, &original_path, user_id) .await { Ok(_) => { @@ -431,7 +433,7 @@ impl TrashUseCase for TrashService { ); match self .folder_storage_port - .restore_from_trash(&folder_id, &original_path) + .restore_from_trash(&folder_id, &original_path, user_id) .await { Ok(_) => { @@ -821,12 +823,19 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Trash listing — drive_id is informational and the trash + // row doesn't currently SELECT it. Path-based lookups + // never enter this code path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the trash listing query. + created_by: None, + updated_by: None, }; TrashResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -867,6 +876,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { sort_date: None, content_hash, etag, + // §14 provenance not selected by the trash listing query. + created_by: None, + updated_by: None, }; TrashResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index ea2be5bb..74621d9b 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -139,7 +139,7 @@ where ) })?; self.file_write_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -181,7 +181,7 @@ where ) })?; self.folder_storage_port - .move_to_trash(item_id) + .move_to_trash(item_id, user_id) .await .map_err(|e| { DomainError::new( @@ -215,7 +215,7 @@ where let original_path = item.original_path().to_string(); let result = self .file_write_port - .restore_from_trash(&file_id, &original_path) + .restore_from_trash(&file_id, &original_path, user_id) .await; if let Err(e) = result && !format!("{}", e).contains("not found") @@ -232,7 +232,7 @@ where let original_path = item.original_path().to_string(); let result = self .folder_storage_port - .restore_from_trash(&folder_id, &original_path) + .restore_from_trash(&folder_id, &original_path, user_id) .await; if let Err(e) = result && !format!("{}", e).contains("not found") @@ -500,13 +500,18 @@ impl FileReadPort for MockFileRepository { unimplemented!() } - async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> std::result::Result { unimplemented!() } async fn get_folder_id_by_path( &self, _folder_path: &str, + _drive_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -561,6 +566,7 @@ impl FileWritePort for MockFileRepository { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -569,6 +575,7 @@ impl FileWritePort for MockFileRepository { &self, _file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -577,6 +584,7 @@ impl FileWritePort for MockFileRepository { &self, _file_id: &str, _new_name: &str, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -591,6 +599,7 @@ impl FileWritePort for MockFileRepository { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> std::result::Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -601,6 +610,7 @@ impl FileWritePort for MockFileRepository { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> std::result::Result<(File, PathBuf), DomainError> { unimplemented!() } @@ -610,11 +620,16 @@ impl FileWritePort for MockFileRepository { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } - async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { + async fn move_to_trash( + &self, + id: &str, + _caller_id: Uuid, + ) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -630,6 +645,7 @@ impl FileWritePort for MockFileRepository { &self, id: &str, _original_path: &str, + _caller_id: Uuid, ) -> std::result::Result<(), DomainError> { let mut files = self.files.lock().unwrap(); let mut trashed = self.trashed_files.lock().unwrap(); @@ -693,6 +709,7 @@ impl FolderRepository for MockFolderRepository { &self, _name: String, _parent_id: Option, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -709,6 +726,7 @@ impl FolderRepository for MockFolderRepository { async fn get_folder_by_path( &self, _storage_path: &StoragePath, + _drive_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -753,6 +771,7 @@ impl FolderRepository for MockFolderRepository { &self, _id: &str, _new_name: String, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -761,6 +780,7 @@ impl FolderRepository for MockFolderRepository { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> std::result::Result { unimplemented!() } @@ -772,6 +792,7 @@ impl FolderRepository for MockFolderRepository { async fn folder_exists( &self, _storage_path: &StoragePath, + _drive_id: Uuid, ) -> std::result::Result { Ok(false) } @@ -780,7 +801,11 @@ impl FolderRepository for MockFolderRepository { Ok(StoragePath::from_string("/")) } - async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> { + async fn move_to_trash( + &self, + id: &str, + _caller_id: Uuid, + ) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); @@ -796,6 +821,7 @@ impl FolderRepository for MockFolderRepository { &self, id: &str, _original_path: &str, + _caller_id: Uuid, ) -> std::result::Result<(), DomainError> { let mut folders = self.folders.lock().unwrap(); let mut trashed = self.trashed_folders.lock().unwrap(); @@ -822,14 +848,6 @@ impl FolderRepository for MockFolderRepository { )) } } - - async fn create_home_folder( - &self, - _user_id: Uuid, - _name: String, - ) -> std::result::Result { - Ok(Folder::default()) - } } #[cfg(integration_tests)] diff --git a/src/application/services/user_lifecycle_service.rs b/src/application/services/user_lifecycle_service.rs index 0a1793f4..de1db8b4 100644 --- a/src/application/services/user_lifecycle_service.rs +++ b/src/application/services/user_lifecycle_service.rs @@ -132,7 +132,7 @@ impl UserLifecycleService { // // Always-on observer. Emits one structured `tracing::info!(target: "audit", // ...)` line per event. The only hook registered in PR 1; subsequent PRs -// add HomeFolderLifecycleHook, AuthzCacheLifecycleHook, etc., each living +// add PersonalDriveLifecycleHook, AuthzCacheLifecycleHook, etc., each living // next to the service it works for. // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/bin/load-seed.rs b/src/bin/load-seed.rs index eca0c6e7..5a825220 100644 --- a/src/bin/load-seed.rs +++ b/src/bin/load-seed.rs @@ -218,10 +218,41 @@ async fn main() -> Result<(), Box> { // Two parallel subtrees off root: one for the user-grant scenario, one for // the group-grant scenario. Each has its own depth/fanout shape so a single // grant cascades over a known fixed number of descendants. - let shared_subtree = - build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?; - let group_subtree = - build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?; + // Post-D0: every folder needs a drive_id (NOT NULL) and root folders + // (parent_id IS NULL) are reserved for the atomic drive-creation + // transaction. The load-seed subtrees nest under admin's default + // personal drive's root folder ("Personal") — they're a workload + // shape, not their own drives. Look it up once and pass to both + // build_subtree calls so they share the same parent. + let admin_root: (Uuid,) = sqlx::query_as( + "SELECT f.id FROM storage.folders f + JOIN storage.drives d ON d.root_folder_id = f.id + WHERE d.default_for_user = $1 + LIMIT 1", + ) + .bind(admin.id) + .fetch_one(&pool) + .await?; + let admin_root_id = admin_root.0; + + let shared_subtree = build_subtree( + &pool, + admin.id, + admin_root_id, + "shared_root", + args.depth, + args.fanout, + ) + .await?; + let group_subtree = build_subtree( + &pool, + admin.id, + admin_root_id, + "group_root", + args.depth, + args.fanout, + ) + .await?; let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64; let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len(); @@ -463,7 +494,14 @@ struct Subtree { leaves: Vec, } -/// Build a folder tree under `parent=NULL` rooted at `root_name`. +/// Build a folder tree under `parent_id = mount_under` rooted at `root_name`. +/// +/// `mount_under` is an existing folder UUID (post-D0: the user's default +/// drive's root folder, since root folders — `parent_id IS NULL` — are +/// reserved for the atomic drive-creation transaction and would trip +/// the no-orphan-root-folder constraint trigger). Every level's +/// `drive_id` is derived from the parent folder so the NOT NULL column +/// is satisfied automatically. /// /// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from /// the already-committed parent rows. Returns the root, a depth-4 sample, the @@ -471,6 +509,7 @@ struct Subtree { async fn build_subtree( pool: &PgPool, user_id: Uuid, + mount_under: Uuid, root_name: &str, depth: u32, fanout: u32, @@ -485,14 +524,19 @@ async fn build_subtree( root_name, predicted ); - // Level 0 — the root folder. + // Level 0 — the subtree's "root" sits inside `mount_under`, not at + // parent_id=NULL. drive_id is inherited from the mount point. let root: (Uuid,) = sqlx::query_as( - "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) + SELECT $1, parent.id, $2, parent.drive_id, $2, $2 + FROM storage.folders parent + WHERE parent.id = $3::uuid RETURNING id", ) .bind(root_name) .bind(user_id) + .bind(mount_under) .fetch_one(pool) .await?; let root_id = root.0; @@ -523,10 +567,16 @@ async fn build_subtree( parents.len() ); + // drive_id derives from the parent folder — same pattern as + // file_blob_write_repository's resolve_owner_and_drive helper. + // Every parent in `current_level` already has a drive_id set, + // so the JOIN is guaranteed to find one. let rows: Vec<(Uuid,)> = sqlx::query_as( - "INSERT INTO storage.folders (name, parent_id, user_id) - SELECT f.name, f.parent_id, $1 + "INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + SELECT f.name, f.parent_id, $1, parent.drive_id, $1, $1 FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name) + JOIN storage.folders parent ON parent.id = f.parent_id RETURNING id", ) .bind(user_id) @@ -601,10 +651,17 @@ async fn insert_files( .flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi))) .collect(); + // Post-D0: storage.files.drive_id is NOT NULL — derive it from the + // parent folder (same pattern as file_blob_write_repository's + // INSERTs and the resolve_owner_and_drive helper). The folder's + // drive_id was set during the M2 backfill or by the lifecycle hook + // for users provisioned after D0. sqlx::query( - "INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain' - FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)", + "INSERT INTO storage.files + (name, folder_id, user_id, drive_id, blob_hash, size, mime_type) + SELECT f.name, f.folder_id, $1, fo.drive_id, $2, 0, 'text/plain' + FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name) + JOIN storage.folders fo ON fo.id = f.folder_id", ) .bind(user_id) .bind(blob_hash) diff --git a/src/common/di.rs b/src/common/di.rs index 0ca12004..9ff71517 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(), @@ -1147,7 +1157,7 @@ impl AppServiceFactory { // audit event is recorded // even if a later hook // errors out. - // 2. HomeFolderLifecycleHook — provisions the user's + // 2. PersonalDriveLifecycleHook — provisions the user's // home folder on // created/login (no-op // for external users). @@ -1190,8 +1200,9 @@ impl AppServiceFactory { crate::application::services::user_lifecycle_service::AuditLifecycleHook, )) .with_hook(Arc::new( - crate::application::services::folder_service::HomeFolderLifecycleHook::new( - apps.folder_service_concrete.clone(), + crate::application::services::folder_service::PersonalDriveLifecycleHook::new( + drive_repo.clone(), + authorization.clone(), ), )) .with_hook(Arc::new( @@ -1224,7 +1235,7 @@ impl AppServiceFactory { // Auth services. Folder service no longer threaded here — // PR 3 moved home-folder provisioning into - // HomeFolderLifecycleHook, which already holds an Arc to the + // PersonalDriveLifecycleHook, which already holds an Arc to the // folder service via the user_lifecycle dispatcher. if self.config.features.enable_auth { let services = crate::infrastructure::auth_factory::create_auth_services( @@ -1379,6 +1390,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 +1851,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..b6ae2e93 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -101,11 +101,19 @@ impl FileReadPort for StubFileReadPort { Ok(StoragePath::from_string("/")) } - async fn get_parent_folder_id(&self, _path: &str) -> Result { + async fn get_parent_folder_id( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { Ok("root".to_string()) } - async fn get_folder_id_by_path(&self, _folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + _folder_path: &str, + _drive_id: Uuid, + ) -> Result { Ok("stub-folder-id".to_string()) } @@ -157,6 +165,7 @@ impl FileWritePort for StubFileWritePort { _content_type: String, _blob_hash: &str, _size: u64, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } @@ -165,6 +174,7 @@ impl FileWritePort for StubFileWritePort { &self, _file_id: &str, _target_folder_id: Option, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } @@ -174,11 +184,17 @@ impl FileWritePort for StubFileWritePort { _file_id: &str, _target_folder_id: Option, _new_name: Option<&str>, + _caller_id: Uuid, ) -> Result { Ok(File::default()) } - async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { + async fn rename_file( + &self, + _file_id: &str, + _new_name: &str, + _caller_id: Uuid, + ) -> Result { Ok(File::default()) } @@ -192,6 +208,7 @@ impl FileWritePort for StubFileWritePort { _blob_hash: &str, _size: u64, _modified_at: Option, + _caller_id: Uuid, ) -> Result<(String, i64), DomainError> { Ok((String::new(), 0)) } @@ -202,11 +219,12 @@ impl FileWritePort for StubFileWritePort { _folder_id: Option, _content_type: String, _size: u64, + _caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError> { Ok((File::default(), PathBuf::from("/tmp/dummy"))) } - async fn move_to_trash(&self, _file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _file_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -214,6 +232,7 @@ impl FileWritePort for StubFileWritePort { &self, _file_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } @@ -234,6 +253,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _name: String, _parent_id: Option, + _caller_id: Uuid, ) -> Result { Ok(Folder::default()) } @@ -242,7 +262,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(Folder::default()) } - async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> Result { + async fn get_folder_by_path( + &self, + _storage_path: &StoragePath, + _drive_id: Uuid, + ) -> Result { Ok(Folder::default()) } @@ -279,7 +303,12 @@ impl FolderRepository for StubFolderStoragePort { Ok((Vec::new(), Some(0))) } - async fn rename_folder(&self, _id: &str, _new_name: String) -> Result { + async fn rename_folder( + &self, + _id: &str, + _new_name: String, + _caller_id: Uuid, + ) -> Result { Ok(Folder::default()) } @@ -287,6 +316,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _id: &str, _new_parent_id: Option<&str>, + _caller_id: Uuid, ) -> Result { Ok(Folder::default()) } @@ -295,7 +325,11 @@ impl FolderRepository for StubFolderStoragePort { Ok(()) } - async fn folder_exists(&self, _storage_path: &StoragePath) -> Result { + async fn folder_exists( + &self, + _storage_path: &StoragePath, + _drive_id: Uuid, + ) -> Result { Ok(false) } @@ -303,7 +337,7 @@ impl FolderRepository for StubFolderStoragePort { Ok(StoragePath::from_string("/")) } - async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, _folder_id: &str, _caller_id: Uuid) -> Result<(), DomainError> { Ok(()) } @@ -311,6 +345,7 @@ impl FolderRepository for StubFolderStoragePort { &self, _folder_id: &str, _original_path: &str, + _caller_id: Uuid, ) -> Result<(), DomainError> { Ok(()) } @@ -318,14 +353,6 @@ impl FolderRepository for StubFolderStoragePort { async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { Ok(()) } - - async fn create_home_folder( - &self, - _user_id: Uuid, - _name: String, - ) -> Result { - Ok(Folder::default()) - } } // --------------------------------------------------------------------------- @@ -397,7 +424,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn get_folder_by_path(&self, _path: &str) -> Result { + async fn get_folder_by_path( + &self, + _path: &str, + _drive_id: Uuid, + ) -> Result { Ok(FolderDto::default()) } @@ -455,14 +486,6 @@ impl FolderUseCase for StubFolderUseCase { ) -> Result<(), DomainError> { Ok(()) } - - async fn create_home_folder( - &self, - _user_id: Uuid, - _name: String, - ) -> Result { - Ok(FolderDto::default()) - } } // --------------------------------------------------------------------------- @@ -478,6 +501,7 @@ impl FileUploadUseCase for StubFileUploadUseCase { _folder_id: Option, _content_type: String, _blob: StoredBlob, + _caller_id: Uuid, ) -> Result { Ok(FileDto::default()) } @@ -485,9 +509,11 @@ impl FileUploadUseCase for StubFileUploadUseCase { async fn update_file_streaming( &self, _path: &str, + _drive_id: Uuid, _blob: StoredBlob, _content_type: &str, _modified_at: Option, + _caller_id: Uuid, ) -> Result { Ok(FileDto::default()) } @@ -567,7 +593,7 @@ impl FileRetrievalUseCase for StubFileRetrievalUseCase { Ok(Box::new(empty_stream)) } - async fn get_file_by_path(&self, _path: &str) -> Result { + async fn get_file_by_path(&self, _path: &str, _drive_id: Uuid) -> Result { Err(DomainError::not_found("File", "stub")) } diff --git a/src/domain/entities/drive.rs b/src/domain/entities/drive.rs new file mode 100644 index 00000000..fcedb54d --- /dev/null +++ b/src/domain/entities/drive.rs @@ -0,0 +1,136 @@ +//! 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`. +/// +/// Drives are pure metadata under the D0 design (docs/plan/drive.md §3): +/// no `name` column — the display name lives on the root folder pointed +/// at by `root_folder_id`. Code that needs the name pairs this struct +/// with a JOIN through `storage.folders`; see the repository's +/// `DriveWithRootName` view-model. +/// +/// 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` and `DriveService` (post-D0). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Drive { + /// Stable identifier. Generated server-side at creation. + pub id: Uuid, + /// 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, + /// The drive's mount-point folder. The column is NULLable in SQL + /// only because the atomic creation CTE writes it mid-statement + /// (a column-level `NOT NULL` would refuse the initial drive INSERT + /// — see docs/plan/drive.md §3). After any successful creation path, + /// this is populated; code reading `Drive` may treat it as `Uuid`, + /// not `Option`. A NULL at read time is a data-invariant bug. + pub root_folder_id: Uuid, + /// Soft cap on this drive's storage usage, in bytes. `None` means + /// "no quota" (rare; reserved for admin overrides). The default + /// initial quota for a fresh personal drive is taken from the + /// owner's `auth.users.storage_quota_bytes` at creation time. + /// **Mutation is OxiCloud-admin only** (docs/plan/drive.md §7) — + /// not in the drive `owner` role bundle. + 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/file.rs b/src/domain/entities/file.rs index 0c379040..9c9ba7ce 100644 --- a/src/domain/entities/file.rs +++ b/src/domain/entities/file.rs @@ -25,6 +25,10 @@ pub struct FileParts { pub owner_id: Option, /// BLAKE3 content hash. See [`File::content_hash`] for semantics. pub blob_hash: String, + /// §14 provenance: original creator. See [`File::created_by`]. + pub created_by: Option, + /// §14 provenance: most recent mutator. See [`File::updated_by`]. + pub updated_by: Option, } /** @@ -77,6 +81,18 @@ pub struct File { /// ETag (the ETag formula may grow to include `modified_at` etc., /// but `content_hash` remains the raw hash). blob_hash: String, + + /// User that originally created this file (§14 provenance). + /// Stamped at INSERT and never updated thereafter. `None` when + /// the referenced user has been deleted (FK is `ON DELETE SET + /// NULL`) or for stub/DTO-reconstructed files. + created_by: Option, + + /// User that performed the most recent mutation that bumped + /// `updated_at` (rename, move, content overwrite, trash, restore). + /// Authorship signal — distinct from ownership. `None` when the + /// referenced user is deleted or for stub/DTO-reconstructed files. + updated_by: Option, } // We no longer need this module, now we use a String directly @@ -95,6 +111,8 @@ impl Default for File { modified_at: 0, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, } } } @@ -134,6 +152,8 @@ impl File { modified_at: now, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, }) } @@ -166,6 +186,8 @@ impl File { modified_at, owner_id: None, blob_hash: String::new(), + created_by: None, + updated_by: None, }) } @@ -207,6 +229,40 @@ impl File { modified_at: u64, owner_id: Option, blob_hash: String, + ) -> FileResult { + Self::with_timestamps_blob_hash_and_provenance( + id, + name, + storage_path, + size, + mime_type, + folder_id, + created_at, + modified_at, + owner_id, + blob_hash, + None, + None, + ) + } + + /// Full constructor including the §14 provenance columns + /// (`created_by` / `updated_by`). PG-row callers use this to + /// preserve authorship across reconstruction. + #[allow(clippy::too_many_arguments)] + pub fn with_timestamps_blob_hash_and_provenance( + id: String, + name: String, + storage_path: StoragePath, + size: u64, + mime_type: String, + folder_id: Option, + created_at: u64, + modified_at: u64, + owner_id: Option, + blob_hash: String, + created_by: Option, + updated_by: Option, ) -> FileResult { let name = normalize_storage_name(&name); if let Err(reason) = validate_storage_name(&name) { @@ -228,6 +284,8 @@ impl File { modified_at, owner_id, blob_hash, + created_by, + updated_by, }) } @@ -248,6 +306,8 @@ impl File { modified_at: self.modified_at, owner_id: self.owner_id, blob_hash: self.blob_hash, + created_by: self.created_by, + updated_by: self.updated_by, } } @@ -351,6 +411,21 @@ impl File { self.owner_id } + /// User that originally created this file (§14 provenance). + /// `None` when the referenced user has been deleted + /// (FK is `ON DELETE SET NULL`) or for stub/DTO entities. + pub fn created_by(&self) -> Option { + self.created_by + } + + /// User that performed the most recent mutation that bumped + /// `updated_at`. Authorship signal — distinct from ownership. + /// `None` when the referenced user is deleted or for + /// stub/DTO entities. + pub fn updated_by(&self) -> Option { + self.updated_by + } + #[allow(clippy::too_many_arguments)] pub fn from_dto( id: String, @@ -382,6 +457,10 @@ impl File { modified_at, owner_id: None, blob_hash: String::new(), + // DTO round-trips don't carry provenance; callers needing + // it must reload from the repository. + created_by: None, + updated_by: None, } } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 6607b917..53242dc3 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -29,6 +29,13 @@ pub struct Folder { /// `None` only for legacy/stub folders; real folders always have an owner. owner_id: Option, + /// Drive that owns this folder. Post-D0 every `storage.folders` row + /// has `drive_id NOT NULL` (M3 migration). Path-based lookups scope + /// by this axis (not by `user_id`, which is dropped in D7). + /// `Uuid::nil()` only for stub/legacy in-memory folders that never + /// touched the DB. + drive_id: Uuid, + /// Creation timestamp created_at: u64, @@ -43,6 +50,20 @@ pub struct Folder { /// HTTP ETag emitted in PROPFIND/GET/HEAD responses — see /// [`Folder::etag`] for the formula and rationale. tree_modified_at: u64, + + /// User that originally created this folder. Stamped at INSERT + /// from the caller's id and never updated afterwards (provenance, + /// not ownership — see §14 of the Drive plan). `None` when the + /// referenced user is later deleted (FK is `ON DELETE SET NULL`) + /// or for stub/DTO-reconstructed folders that never touched the DB. + created_by: Option, + + /// User that performed the most recent mutation that touched + /// `updated_at` (rename, move, trash, restore, content overwrite). + /// Authorship signal — does NOT propagate via the tree-ETag flush + /// trigger. `None` when the referenced user is deleted or for + /// stub/DTO-reconstructed folders. + updated_by: Option, } // We no longer need this module, now we use a String directly @@ -56,9 +77,12 @@ impl Default for Folder { path_string: "/".to_string(), parent_id: None, owner_id: None, + drive_id: Uuid::nil(), created_at: 0, modified_at: 0, tree_modified_at: 0, + created_by: None, + updated_by: None, } } } @@ -103,9 +127,18 @@ impl Folder { path_string, parent_id, owner_id, + // In-memory constructor: callers that don't supply a + // drive_id are by definition stub/legacy paths (tests, + // pre-D0 fixtures, DTO round-trips). Real DB-backed + // folders flow through `with_timestamps_and_tree`. + drive_id: Uuid::nil(), created_at: now, modified_at: now, tree_modified_at: now, + // Provenance is unknown for in-memory construction; the DB + // reconstruction path supplies real values. + created_by: None, + updated_by: None, }) } @@ -128,6 +161,7 @@ impl Folder { storage_path, parent_id, None, + Uuid::nil(), created_at, modified_at, modified_at, @@ -154,6 +188,7 @@ impl Folder { storage_path, parent_id, owner_id, + Uuid::nil(), created_at, modified_at, modified_at, @@ -162,7 +197,12 @@ impl Folder { /// Full constructor used by the PG repository when reading rows. /// `tree_modified_at` comes from the trigger-maintained column on - /// `storage.folders` and feeds [`Folder::etag`]. + /// `storage.folders` and feeds [`Folder::etag`]. `drive_id` is the + /// post-D0 `storage.folders.drive_id NOT NULL` column — every + /// path-based lookup scopes by this axis. `created_by` / + /// `updated_by` are the §14 provenance columns; both are nullable + /// because the M1 FK is `ON DELETE SET NULL` (a deleted user + /// leaves authored rows in place). #[allow(clippy::too_many_arguments)] pub fn with_timestamps_and_tree( id: String, @@ -170,9 +210,42 @@ impl Folder { storage_path: StoragePath, parent_id: Option, owner_id: Option, + drive_id: Uuid, created_at: u64, modified_at: u64, tree_modified_at: u64, + ) -> FolderResult { + Self::with_timestamps_tree_and_provenance( + id, + name, + storage_path, + parent_id, + owner_id, + drive_id, + created_at, + modified_at, + tree_modified_at, + None, + None, + ) + } + + /// Full constructor including the §14 provenance columns + /// (`created_by` / `updated_by`). Direct PG-row callers use this + /// to preserve authorship through the entity layer. + #[allow(clippy::too_many_arguments)] + pub fn with_timestamps_tree_and_provenance( + id: String, + name: String, + storage_path: StoragePath, + parent_id: Option, + owner_id: Option, + drive_id: Uuid, + created_at: u64, + modified_at: u64, + tree_modified_at: u64, + created_by: Option, + updated_by: Option, ) -> FolderResult { let name = normalize_storage_name(&name); if let Err(reason) = validate_storage_name(&name) { @@ -188,9 +261,12 @@ impl Folder { path_string, parent_id, owner_id, + drive_id, created_at, modified_at, tree_modified_at, + created_by, + updated_by, }) } @@ -227,6 +303,29 @@ impl Folder { self.owner_id } + /// Drive that owns this folder. Path-based lookups scope by + /// this axis (post-D0 invariant: `storage.folders.drive_id` + /// is `NOT NULL`). + pub fn drive_id(&self) -> Uuid { + self.drive_id + } + + /// User that originally created this folder (§14 provenance). + /// `None` when the referenced user has been deleted + /// (FK is `ON DELETE SET NULL`) or for in-memory/DTO-reconstructed + /// entities. + pub fn created_by(&self) -> Option { + self.created_by + } + + /// User that performed the most recent mutation that bumped + /// `updated_at`. Authorship signal — distinct from ownership. + /// `None` when the referenced user has been deleted or for + /// in-memory/DTO-reconstructed entities. + pub fn updated_by(&self) -> Option { + self.updated_by + } + /// Latest descendant-write timestamp. Statement-level Postgres /// triggers enqueue every file/folder write into /// `storage.tree_etag_dirty`; the background `TreeEtagFlushService` @@ -314,9 +413,18 @@ impl Folder { path_string: path, parent_id, owner_id: None, + // DTO round-trips lose drive_id (FolderDto carries it, + // but the legacy `from_dto` signature predates this + // change). Callers that need real scoping must reload + // through the repository. + drive_id: Uuid::nil(), created_at, modified_at, tree_modified_at: modified_at, + // DTO round-trips through this constructor lose + // provenance; callers that need it reload through the repo. + created_by: None, + updated_by: None, } } @@ -353,12 +461,17 @@ impl Folder { path_string: new_path_string, parent_id: self.parent_id.clone(), owner_id: self.owner_id, + drive_id: self.drive_id, created_at: self.created_at, modified_at: now, // Renaming bumps both self and descendant rollup — // ancestors' listings now show a new name, so the // collection has materially changed. tree_modified_at: now, + // Provenance is preserved across the in-memory rebuild; + // real persisted updates re-read from the DB. + created_by: self.created_by, + updated_by: self.updated_by, }) } @@ -389,9 +502,12 @@ impl Folder { path_string: new_path_string, parent_id, owner_id: self.owner_id, + drive_id: self.drive_id, created_at: self.created_at, modified_at: now, tree_modified_at: now, + created_by: self.created_by, + updated_by: self.updated_by, }) } @@ -478,6 +594,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 5_000, @@ -499,6 +616,7 @@ mod tests { StoragePath::from_string("/a"), None, None, + Uuid::nil(), 0, 0, 42, @@ -510,6 +628,7 @@ mod tests { StoragePath::from_string("/b"), None, None, + Uuid::nil(), 0, 0, 42, @@ -532,6 +651,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 3_000, @@ -543,6 +663,7 @@ mod tests { StoragePath::from_string("/folder"), None, None, + Uuid::nil(), 1_000, 2_000, 4_000, 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..ea2678ad --- /dev/null +++ b/src/domain/repositories/drive_repository.rs @@ -0,0 +1,173 @@ +//! 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), +} + +/// A drive paired with the display name from its root folder. +/// +/// `storage.drives` has no `name` column under the D0 design +/// (docs/plan/drive.md §3) — the display name lives on +/// `storage.folders.name` of the row pointed at by `drive.root_folder_id`. +/// Read paths join the two tables and hand callers this view-model so the +/// API surface can continue to expose a single "drive with name" shape +/// without a follow-up query per drive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DriveWithRootName { + pub drive: Drive, + /// The drive's display name. Sourced from `storage.folders.name` + /// of the root folder via JOIN at read time. + pub root_folder_name: String, +} + +#[async_trait::async_trait] +pub trait DriveRepository: Send + Sync + 'static { + /// Atomically create a personal drive together with its root folder + /// and the owner role_grant — all four DB writes in a single SQL + /// statement (docs/plan/drive.md §3 "Atomic creation"). The + /// statement runs as its own implicit transaction in autocommit mode + /// so a server crash mid-statement leaves no half-row state. + /// + /// The root folder is created with name `"Personal"` (the canonical + /// default) and `parent_id IS NULL`. The drive's `root_folder_id` + /// is wired to point at it before the statement commits. + /// + /// Returns `DefaultDriveAlreadyExists` when the owner already has a + /// default drive — relies on the partial UNIQUE index on + /// `default_for_user`. + async fn create_personal_drive_atomic( + &self, + owner_id: Uuid, + quota_bytes: Option, + ) -> Result; + + /// Fetch a drive by id together with its display name. `NotFound` + /// when no row matches. + async fn get_by_id(&self, id: Uuid) -> Result; + + /// Return the caller's default personal drive paired with its + /// display name, 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; + + /// Canonical "what is this user's home root folder id?" lookup. + /// + /// Returns `Some(uuid)` for any internal user with a default personal + /// drive (the lifecycle hook provisions one at registration), and + /// `None` for users who have no default drive (external users; users + /// created before the hook existed). The id identifies the user's + /// home **by drive ownership** (`default_for_user == user_id`), + /// never by folder name — users can rename their home, so any code + /// that wants to ask "is this folder the user's home?" must compare + /// folder ids, not names. + /// + /// Storage errors (DB unreachable, etc.) bubble up as `Err`; the + /// "user simply has no home" case is `Ok(None)`, not an error. + async fn home_root_folder_id_for( + &self, + user_id: Uuid, + ) -> Result, DriveRepositoryError> { + match self.find_default_for_user(user_id).await { + Ok(d) => Ok(Some(d.drive.root_folder_id)), + Err(DriveRepositoryError::NotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } + + /// 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 display 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())) + } +} + +/// Locate the user's home root folder within a generic list of items, +/// identifying it by **drive ownership** (never by folder name — users +/// can rename their home). +/// +/// `id_fn` extracts a candidate `Uuid` from each item. The callsite +/// commonly works with `FolderDto` (whose `id` is a `String`); the +/// closure is `|f| Uuid::parse_str(&f.id).ok()`. Items whose ids can't +/// be parsed are simply skipped — `position` ignores them. +/// +/// Defined as a free function (not a trait method) so the +/// `DriveRepository` trait stays `dyn`-compatible. Generic over both +/// the repo (`R`) and the item shape (`T`); accepts both concrete repo +/// types and `&dyn DriveRepository`. +/// +/// Returns `None` when: +/// * The user has no default drive (external users, pre-hook accounts). +/// * The user's home root folder id isn't present in `items`. +/// * The repo lookup errored (storage error is swallowed to None — +/// callers wanting fail-loud semantics should call +/// `home_root_folder_id_for` directly). +pub async fn position_of_user_home_root_folder( + drive_repo: &R, + user_id: Uuid, + items: &[T], + id_fn: impl Fn(&T) -> Option, +) -> Option +where + R: DriveRepository + ?Sized, +{ + let home_id = drive_repo + .home_root_folder_id_for(user_id) + .await + .ok() + .flatten()?; + items.iter().position(|item| id_fn(item) == Some(home_id)) +} diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index d9878f2e..5bc4ea17 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use bytes::Bytes; use futures::Stream; +use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::entities::file::File; @@ -49,8 +50,12 @@ pub trait FileReadRepository: Send + Sync + 'static { /// Gets the logical storage path of a file. async fn get_file_path(&self, id: &str) -> Result; - /// Gets the parent folder ID from a path (WebDAV). - async fn get_parent_folder_id(&self, path: &str) -> Result; + /// Gets the parent folder ID from a path (WebDAV), scoped to a drive. + /// + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive — the `drive_id` filter scopes the lookup. + async fn get_parent_folder_id(&self, path: &str, drive_id: Uuid) + -> Result; } // ───────────────────────────────────────────────────── @@ -73,6 +78,9 @@ pub trait FileWriteRepository: Send + Sync + 'static { /// Registers a file row pointing at a blob already stored in the /// content-addressable chunk store (one blob reference is consumed). + /// + /// `caller_id` stamps both `created_by` and `updated_by` + /// (§14 provenance). async fn save_file_with_blob( &self, name: String, @@ -80,17 +88,26 @@ pub trait FileWriteRepository: Send + Sync + 'static { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result; - /// Moves a file to another folder. + /// Moves a file to another folder. `caller_id` stamps `updated_by` + /// in the same UPDATE that bumps `updated_at` (§14 provenance). async fn move_file( &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result; - /// Renames a file (same folder, different name). - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; + /// Renames a file (same folder, different name). `caller_id` + /// stamps `updated_by` in the same UPDATE (§14 provenance). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result; /// Deletes a file. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; @@ -103,24 +120,31 @@ pub trait FileWriteRepository: Send + Sync + 'static { /// /// Returns `(File, PathBuf)` where `PathBuf` is the destination path for /// the deferred write that the `WriteBehindCache` will perform. + /// + /// `caller_id` stamps both `created_by` and `updated_by` + /// (§14 provenance). async fn register_file_deferred( &self, name: String, folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> Result<(File, PathBuf), DomainError>; // ── Trash operations ── - /// Moves a file to the trash - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>; + /// Moves a file to the trash. `caller_id` stamps `updated_by` + /// (§14 provenance). + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a file from the trash to its original location + /// Restores a file from the trash to its original location. + /// `caller_id` stamps `updated_by` (§14 provenance). async fn restore_from_trash( &self, file_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// Permanently deletes a file (used by the trash) diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index 31e55bbd..aa45286a 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -18,18 +18,35 @@ use uuid::Uuid; /// Defines the CRUD and management operations required for /// the Folder entity in the storage system. pub trait FolderRepository: Send + Sync + 'static { - /// Creates a new folder + /// Creates a new folder. + /// + /// `caller_id` is stamped into `created_by` and `updated_by` + /// (D0 §14 provenance — authorship belongs to whoever issued the + /// create, not to the parent folder's owner). Pre-D2 they're + /// silently equivalent (only the owner can write); D2 ships + /// shared drives where this distinction matters. async fn create_folder( &self, name: String, parent_id: Option, + caller_id: Uuid, ) -> Result; /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result; - /// Gets a folder by its storage path - async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result; + /// Gets a folder by its storage path within a drive's tree. + /// + /// Post-D0, `storage.folders.path` is unique only within a single + /// drive — root-folder names like `"Personal"` repeat across drives. + /// The `drive_id` filter scopes the lookup to a specific drive + /// (caller derives it from its protocol context: NC chroot, native + /// default-drive lookup, WOPI default-drive lookup). + async fn get_folder_by_path( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result; /// Lists folders within a parent folder async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError>; @@ -64,44 +81,61 @@ pub trait FolderRepository: Send + Sync + 'static { include_total: bool, ) -> Result<(Vec, Option), DomainError>; - /// Renames a folder - async fn rename_folder(&self, id: &str, new_name: String) -> Result; + /// Renames a folder. `caller_id` is stamped into `updated_by` + /// alongside the `updated_at = NOW()` bump (§14 provenance). + async fn rename_folder( + &self, + id: &str, + new_name: String, + caller_id: Uuid, + ) -> Result; - /// Moves a folder to another parent + /// Moves a folder to another parent. `caller_id` is stamped into + /// `updated_by` alongside the `updated_at = NOW()` bump + /// (§14 provenance). async fn move_folder( &self, id: &str, new_parent_id: Option<&str>, + caller_id: Uuid, ) -> Result; /// Deletes a folder async fn delete_folder(&self, id: &str) -> Result<(), DomainError>; - /// Checks if a folder exists at the given path - async fn folder_exists(&self, storage_path: &StoragePath) -> Result; + /// Checks if a folder exists at the given path within a drive. + /// + /// Post-D0 `storage.folders.path` is unique only within a single + /// drive — the `drive_id` filter scopes the existence check. + async fn folder_exists( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result; /// Gets the path of a folder async fn get_folder_path(&self, id: &str) -> Result; // ── Trash operations ── - /// Moves a folder to the trash - async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError>; + /// Moves a folder to the trash. `caller_id` is stamped into + /// `updated_by` for the root row and every cascade-trashed + /// descendant (§14 provenance). + async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError>; - /// Restores a folder from the trash to its original location + /// Restores a folder from the trash to its original location. + /// `caller_id` is stamped into `updated_by` for the root row and + /// every cascade-restored descendant (§14 provenance). async fn restore_from_trash( &self, folder_id: &str, original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError>; /// 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; - /// Lists every folder in a subtree rooted at `folder_id` (inclusive). /// /// Uses ltree `<@` for a single GiST-indexed scan. The result is 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/auth_factory.rs b/src/infrastructure/auth_factory.rs index 2003464f..d15790e9 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -47,7 +47,7 @@ pub async fn create_auth_services( ); // Wire the user-lifecycle dispatcher. Home-folder provisioning is - // now handled by HomeFolderLifecycleHook (registered on the + // now handled by PersonalDriveLifecycleHook (registered on the // dispatcher in DI) — AuthApplicationService no longer needs a // direct FolderService dependency for that path. auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle); diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index 628f2cb9..94c21ddd 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -200,6 +200,25 @@ async fn run_migrations(pool: &PgPool) -> Result<()> { match sqlx::migrate!().run(pool).await { Ok(()) => Ok(()), - Err(e) => Err(DbError(format!("Migration error: {}", e))), + Err(e) => Err(DbError(format_error_chain("Migration error", &e))), } } + +/// Format an error and every wrapped `source()` cause on a single line. +/// +/// sqlx's `MigrateError::Execute` wraps the underlying `sqlx::Error::Database` +/// which in turn carries the PG `DETAIL` (e.g. `Key (version)=(20260803000000)` +/// for a duplicate-key on `_sqlx_migrations_pkey`). The default `Display` +/// only renders the outermost layer, so the operationally-critical hint +/// gets buried. Walking the chain surfaces it without needing to bump +/// `RUST_LOG` to debug. +fn format_error_chain(prefix: &str, e: &(dyn std::error::Error + 'static)) -> String { + let mut out = format!("{prefix}: {e}"); + let mut cur = e.source(); + while let Some(c) = cur { + out.push_str(" -> "); + out.push_str(&c.to_string()); + cur = c.source(); + } + out +} 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..f02079b4 --- /dev/null +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -0,0 +1,271 @@ +//! 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::{ + DriveRepository, DriveRepositoryError, DriveWithRootName, +}; + +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_drive_atomic + // during user provisioning). + return DriveRepositoryError::DefaultDriveAlreadyExists(dberr.to_string()); + } + DriveRepositoryError::StorageError(format!("{context}: {e}")) + } + + /// Map a row carrying both the drive's columns AND a `root_folder_name` + /// column (sourced via JOIN with `storage.folders`) into the view-model. + fn row_to_drive_with_name( + row: &sqlx::postgres::PgRow, + ) -> Result { + let kind_str: String = row.get("kind"); + let kind = DriveKind::from_sql(&kind_str)?; + let drive = Drive { + id: row.get("id"), + kind, + default_for_user: row.get("default_for_user"), + root_folder_id: row.get("root_folder_id"), + 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"), + }; + Ok(DriveWithRootName { + drive, + root_folder_name: row.get("root_folder_name"), + }) + } +} + +#[async_trait::async_trait] +impl DriveRepository for DrivePgRepository { + async fn create_personal_drive_atomic( + &self, + owner_id: Uuid, + quota_bytes: Option, + ) -> Result { + // Four writes wrapped in a single transaction so either all + // commit or none does (docs/plan/drive.md §3). A single CTE + // statement would be cleaner on paper but doesn't work in + // PostgreSQL: CTE sub-statements share an MVCC snapshot, so + // `UPDATE storage.drives WHERE id = …` cannot match a row + // inserted by an earlier CTE branch. We use plain sequential + // statements inside `pool.begin()` instead — each statement + // sees the prior ones' writes (transaction-local visibility), + // and FK constraints are satisfied at insert time because the + // referenced rows already exist. + // + // Rollback semantics: any error before `tx.commit()` (FK + // violation, unique_violation on `default_for_user`, server + // crash) discards every partial write. No orphan drive, no + // folder without a drive, no drive without an owner. + let mut tx = self + .pool + .begin() + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.begin", e))?; + + // 1. Drive row (root_folder_id NULL — populated in step 3). + let drive_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO storage.drives + (kind, default_for_user, quota_bytes, policies) + VALUES ('personal', $1, $2, '{}'::jsonb) + RETURNING id + "#, + ) + .bind(owner_id) + .bind(quota_bytes) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.drive", e))?; + + // 2. Root folder. `parent_id IS NULL` makes it a root in the + // drive; `drive_id` closes the FK in this direction. + let folder_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + VALUES ('Personal', NULL, $1, $2, $1, $1) + RETURNING id + "#, + ) + .bind(owner_id) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.folder", e))?; + + // 3. Close the other side of the circular reference. + sqlx::query(r#"UPDATE storage.drives SET root_folder_id = $1 WHERE id = $2"#) + .bind(folder_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.wire", e))?; + + // 4. Owner role_grant — the caller becomes the drive's sole + // owner (single-user invariant on personal drives, §2). + sqlx::query( + r#" + INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, + role, granted_by) + VALUES ('user', $1, 'drive', $2, 'owner', $1) + "#, + ) + .bind(owner_id) + .bind(drive_id) + .execute(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.grant", e))?; + + // Fetch the row in its final state so the caller gets a + // consistent view (including DB-computed defaults like + // `created_at`, `used_bytes`). + let row = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + WHERE d.id = $1 + "#, + ) + .bind(drive_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.read", e))?; + + tx.commit() + .await + .map_err(|e| Self::map_sqlx_err("create_personal_drive_atomic.commit", e))?; + + Self::row_to_drive_with_name(&row) + } + + async fn get_by_id(&self, id: Uuid) -> Result { + let row = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + WHERE d.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_with_name(&row) + } + + async fn find_default_for_user( + &self, + user_id: Uuid, + ) -> Result { + let row = sqlx::query( + r#" + SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + WHERE d.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_with_name(&row) + } + + async fn list_for_subjects( + &self, + subject_types: &[&str], + subject_ids: &[Uuid], + ) -> Result, DriveRepositoryError> { + // Joining role_grants → drives → folders returns every drive the + // expanded subject set can read, paired with its display name. + // ORDER BY puts default drives first (so the picker UI doesn't + // need a follow-up sort), then alphabetical by name. GROUP BY + // collapses duplicate role_grants on the same drive (direct + + // group-mediated) and 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.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, + f.name AS root_folder_name + FROM storage.drives d + JOIN storage.folders f ON f.id = d.root_folder_id + 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.kind, d.default_for_user, d.root_folder_id, + d.quota_bytes, d.used_bytes, d.policies, + d.created_at, d.updated_at, f.name + ORDER BY (d.default_for_user IS NULL) ASC, + LOWER(f.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_with_name).collect() + } +} diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 35c31df8..cc4a7428 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -19,6 +19,8 @@ type MediaFileRow = ( i64, // updated_at String, // blob_hash Option, // user_id + Option, // created_by (§14 provenance) + Option, // updated_by (§14 provenance) i64, // sort_date Option, // width Option, // height @@ -42,7 +44,9 @@ use crate::infrastructure::services::dedup_service::DedupService; use uuid::Uuid; /// Type alias for file metadata rows from SQL queries. -/// Fields: id, name, folder_id, folder_path, size, mime_type, created_at, updated_at, blob_hash, user_id +/// Fields: id, name, folder_id, folder_path, size, mime_type, +/// created_at, updated_at, blob_hash, user_id, created_by, updated_by. +/// `created_by` / `updated_by` are the §14 provenance columns. type FileRow = ( String, String, @@ -54,6 +58,8 @@ type FileRow = ( i64, String, Option, + Option, + Option, ); /// Append the optional type/date/size filters from `criteria` to @@ -228,7 +234,8 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id \ + fi.user_id, \ + fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ WHERE {where_clause}" @@ -248,8 +255,10 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -277,7 +286,8 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, \ EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ - fi.user_id \ + fi.user_id, \ + fi.created_by, fi.updated_by \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ WHERE fi.id = ANY($1) AND NOT fi.is_trashed", @@ -291,8 +301,10 @@ impl FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -357,9 +369,11 @@ impl FileBlobReadRepository { modified_at: i64, blob_hash: String, owner_id: Option, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_blob_hash( + File::with_timestamps_blob_hash_and_provenance( id, name, storage_path, @@ -370,6 +384,8 @@ impl FileBlobReadRepository { modified_at as u64, owner_id, blob_hash, + created_by, + updated_by, ) .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) } @@ -428,6 +444,7 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, fi.user_id, + fi.created_by, fi.updated_by, EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, fm.width, fm.height FROM storage.files fi @@ -453,9 +470,9 @@ impl FileBlobReadRepository { let mut sort_dates = Vec::with_capacity(rows.len()); let mut dims = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, sd, w, h) in rows { files.push(Self::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, )?); sort_dates.push(sd); dims.push((w, h)); @@ -530,6 +547,8 @@ impl FileReadPort for FileBlobReadRepository { i64, // updated_at String, // blob_hash Option, // user_id (owner) + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -538,7 +557,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid AND NOT fi.is_trashed @@ -555,7 +575,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -576,6 +596,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -584,7 +606,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid @@ -598,7 +621,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -616,6 +639,8 @@ impl FileReadPort for FileBlobReadRepository { i64, // updated_at String, // blob_hash Option, // user_id (owner) + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -624,7 +649,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.id = $1::uuid @@ -643,7 +669,7 @@ impl FileReadPort for FileBlobReadRepository { self.hash_cache.insert(id.to_string(), row.8.clone()); Self::row_to_file( - row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, + row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9, row.10, row.11, ) } @@ -657,7 +683,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed @@ -675,7 +702,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL AND NOT fi.is_trashed @@ -689,8 +717,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -711,7 +741,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed @@ -731,7 +762,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL AND NOT fi.is_trashed @@ -747,8 +779,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -777,7 +811,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed @@ -798,7 +833,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL AND NOT fi.is_trashed @@ -815,8 +851,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -839,7 +877,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed @@ -862,7 +901,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL AND NOT fi.is_trashed @@ -883,8 +923,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect() @@ -935,7 +977,11 @@ impl FileReadPort for FileBlobReadRepository { Ok(Self::make_file_path(row.1.as_deref(), &row.0)) } - async fn get_parent_folder_id(&self, path: &str) -> Result { + async fn get_parent_folder_id( + &self, + path: &str, + drive_id: Uuid, + ) -> Result { let path = path.trim_start_matches('/').trim_end_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -955,29 +1001,49 @@ impl FileReadPort for FileBlobReadRepository { )); } - self.get_folder_id_by_path(&folder_path).await + self.get_folder_id_by_path(&folder_path, drive_id).await } - async fn get_folder_id_by_path(&self, folder_path: &str) -> Result { + async fn get_folder_id_by_path( + &self, + folder_path: &str, + drive_id: Uuid, + ) -> Result { let folder_path = folder_path.trim_start_matches('/').trim_end_matches('/'); if folder_path.is_empty() { return Err(DomainError::not_found("Folder", "empty path")); } + // Post-D0 `storage.folders.path` repeats across drives — + // filter by `drive_id` to scope the lookup. sqlx::query_scalar::<_, String>( - "SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed", + "SELECT id::text FROM storage.folders \ + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed", ) .bind(folder_path) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobRead", format!("folder lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", format!("path: {folder_path}"))) } - /// Direct SQL lookup using materialized folder paths. + /// Direct SQL lookup using materialized folder paths, scoped to a drive. /// O(1) query instead of O(depth) folder walk. - async fn find_file_by_path(&self, path: &str) -> Result, DomainError> { + /// + /// Post-D0 `storage.folders.path` repeats across drives (each drive + /// has its own root with a name like `"Personal"`). Without the + /// `drive_id` filter the lookup would be non-deterministic. The + /// root-level branch filters on `fi.drive_id`; the nested branch + /// filters on the parent folder's `fo.drive_id` (which closes the + /// leak cleanly and matches the path semantics — see Step 2 of + /// the path-lookup refactor). + async fn find_file_by_path( + &self, + path: &str, + drive_id: Uuid, + ) -> Result, DomainError> { let path = path.trim_start_matches('/').trim_end_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -996,7 +1062,8 @@ impl FileReadPort for FileBlobReadRepository { let folder_path = segments[..segments.len() - 1].join("/"); let row = if folder_path.is_empty() { - // File at root level (no parent folder) + // File at root level (no parent folder) — filter on + // `fi.drive_id` because there's no folder row to join through. sqlx::query_as::< _, ( @@ -1010,6 +1077,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -1018,17 +1087,23 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed + WHERE fi.name = $1 AND fi.folder_id IS NULL + AND fi.drive_id = $2 AND NOT fi.is_trashed "#, ) .bind(filename) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await } else { - // File inside a folder — look up by folder path + filename + // File inside a folder — look up by folder path + filename, + // filtered by the parent folder's drive_id (path semantics + // are folder-scoped, so this also catches mis-pointed file + // rows during D0/D7's dual-write window). sqlx::query_as::< _, ( @@ -1042,6 +1117,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) ), >( r#" @@ -1050,14 +1127,17 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id - WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed + WHERE fo.path = $1 AND fi.name = $2 + AND fo.drive_id = $3 AND NOT fi.is_trashed "#, ) .bind(&folder_path) .bind(filename) + .bind(drive_id) .fetch_optional(self.pool.as_ref()) .await } @@ -1065,7 +1145,7 @@ impl FileReadPort for FileBlobReadRepository { match row { Some(r) => Ok(Some(Self::row_to_file( - r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, + r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9, r.10, r.11, )?)), None => Ok(None), } @@ -1086,6 +1166,7 @@ impl FileReadPort for FileBlobReadRepository { let mut row_stream = sqlx::query_as::<_, ( String, String, Option, Option, i64, String, i64, i64, String, Option, + Option, Option, )>( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -1093,7 +1174,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) @@ -1107,9 +1189,9 @@ impl FileReadPort for FileBlobReadRepository { while let Some(row) = row_stream.try_next().await.map_err(|e| { DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}")) })? { - let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row; + let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub) = row; let file = FileBlobReadRepository::row_to_file( - id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, )?; yield file; } @@ -1173,6 +1255,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ fi.user_id, \ + fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -1195,6 +1278,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) i64, ), >(&sql) @@ -1217,13 +1302,15 @@ impl FileReadPort for FileBlobReadRepository { .map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?; // total_count is the same in every row; 0 when result set is empty. - let total_count = rows.first().map_or(0, |r| r.10) as usize; + let total_count = rows.first().map_or(0, |r| r.12) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -1299,6 +1386,7 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ fi.blob_hash, \ fi.user_id, \ + fi.created_by, fi.updated_by, \ COUNT(*) OVER() AS total_count \ FROM storage.files fi \ JOIN storage.folders fo ON fo.id = fi.folder_id \ @@ -1321,6 +1409,8 @@ impl FileReadPort for FileBlobReadRepository { i64, String, Option, + Option, // created_by (§14) + Option, // updated_by (§14) i64, ), >(&sql) @@ -1341,13 +1431,15 @@ impl FileReadPort for FileBlobReadRepository { DomainError::internal_error("FileBlobRead", format!("subtree search: {e}")) })?; - let total_count = rows.first().map_or(0, |r| r.10) as usize; + let total_count = rows.first().map_or(0, |r| r.12) as usize; let files = rows .into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, _total)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .collect::, _>>() @@ -1389,7 +1481,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id = $1::uuid @@ -1418,7 +1511,8 @@ impl FileReadPort for FileBlobReadRepository { EXTRACT(EPOCH FROM fi.created_at)::bigint, EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, - fi.user_id + fi.user_id, + fi.created_by, fi.updated_by FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id WHERE fi.folder_id IS NULL @@ -1443,8 +1537,10 @@ impl FileReadPort for FileBlobReadRepository { rows.into_iter() .map( - |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { - Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub)| { + Self::row_to_file( + id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, cb, ub, + ) }, ) .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..3bb60761 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 @@ -111,9 +115,11 @@ impl FileBlobWriteRepository { modified_at: i64, owner_id: Option, blob_hash: String, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = Self::make_file_path(folder_path.as_deref(), &name); - File::with_timestamps_and_blob_hash( + File::with_timestamps_blob_hash_and_provenance( id, name, storage_path, @@ -124,14 +130,33 @@ impl FileBlobWriteRepository { modified_at as u64, owner_id, blob_hash, + created_by, + updated_by, ) .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", @@ -150,12 +175,18 @@ impl FileBlobWriteRepository { /// `(new_hash, updated_at_epoch)` on success — the effective timestamp /// is returned so callers can rebuild the fresh entity without /// re-reading the row. + /// + /// §14: `updated_by = $5` (caller_id). The caller mutated this + /// row — not the row's owner. D2 shared drives let non-owners + /// overwrite content; the previous `updated_by = f.user_id` would + /// have silently recorded the wrong principal. async fn swap_blob_hash( &self, file_id: &str, new_hash: &str, new_size: i64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError> { // Atomic CTE: capture old hash then update in one round-trip, no TOCTOU. // Deadlock victims (40P01) retry before the compensation below runs — @@ -168,7 +199,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 = $5 FROM old WHERE f.id = old.id RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint @@ -178,6 +210,7 @@ impl FileBlobWriteRepository { .bind(new_size) .bind(file_id) .bind(modified_at.map(|t| t as f64)) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await @@ -223,6 +256,12 @@ impl FileBlobWriteRepository { /// Register a file row pointing at a blob already stored in the chunk /// store (the upload-ingest layer streamed the content in). Consumes the /// caller's blob reference: any failure releases it before returning. + /// + /// §14: `created_by = $7 = updated_by = caller_id` — authorship + /// belongs to the principal performing the upload, not to the parent + /// folder's owner. In D2 shared drives a non-owner member can upload + /// into a folder Alice owns; binding `parent.user_id` would have + /// silently recorded Alice as the author. async fn save_file_with_blob_impl( &self, name: String, @@ -230,6 +269,7 @@ impl FileBlobWriteRepository { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result { // Root files have no parent folder to derive an owner from — keep the // previous resolve_user_id(None) contract (release the ref, error out). @@ -260,19 +300,24 @@ impl FileBlobWriteRepository { // (a retried INSERT can legitimately lose to a concurrent identical // upload). let result = retry_on_deadlock("files.insert", || { - sqlx::query_as::<_, (String, Uuid, String, i64, i64)>( + sqlx::query_as::<_, (String, Uuid, String, i64, i64, Option, Option)>( 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, $7, $7 + FROM parent RETURNING id::text, user_id, (SELECT path FROM parent), EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) @@ -281,44 +326,46 @@ impl FileBlobWriteRepository { .bind(size as i64) .bind(&content_type) .bind(category_order_for(&name, &content_type)) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await; - let (id, user_id, folder_path, created_at, updated_at) = match result { - Ok(Some(row)) => row, - Ok(None) => { - if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { - tracing::error!( - "Blob orphaned after missing parent folder — hash: {}, err: {}", - &blob_hash[..12], - rollback_err - ); + let (id, user_id, folder_path, created_at, updated_at, created_by, updated_by) = + match result { + Ok(Some(row)) => row, + Ok(None) => { + if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { + tracing::error!( + "Blob orphaned after missing parent folder — hash: {}, err: {}", + &blob_hash[..12], + rollback_err + ); + } + return Err(DomainError::not_found("Folder", fid)); } - return Err(DomainError::not_found("Folder", fid)); - } - Err(e) => { - if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { - tracing::error!( - "Blob orphaned after failed INSERT — hash: {}, err: {}", - &blob_hash[..12], - rollback_err - ); - } - if let sqlx::Error::Database(ref db_err) = e - && db_err.code().as_deref() == Some("23505") - { - return Err(DomainError::already_exists( - "File", - format!("'{name}' already exists in this folder"), + Err(e) => { + if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await { + tracing::error!( + "Blob orphaned after failed INSERT — hash: {}, err: {}", + &blob_hash[..12], + rollback_err + ); + } + if let sqlx::Error::Database(ref db_err) = e + && db_err.code().as_deref() == Some("23505") + { + return Err(DomainError::already_exists( + "File", + format!("'{name}' already exists in this folder"), + )); + } + return Err(DomainError::internal_error( + "FileBlobWrite", + format!("insert: {e}"), )); } - return Err(DomainError::internal_error( - "FileBlobWrite", - format!("insert: {e}"), - )); - } - }; + }; tracing::info!( "📡 STREAMING WRITE: {} ({} bytes, hash: {})", @@ -338,6 +385,8 @@ impl FileBlobWriteRepository { updated_at, Some(user_id), blob_hash.to_string(), + created_by, + updated_by, ) } } @@ -350,8 +399,9 @@ impl FileWritePort for FileBlobWriteRepository { content_type: String, blob_hash: &str, size: u64, + caller_id: Uuid, ) -> Result { - self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size) + self.save_file_with_blob_impl(name, folder_id, content_type, blob_hash, size, caller_id) .await } @@ -359,20 +409,50 @@ impl FileWritePort for FileBlobWriteRepository { &self, file_id: &str, target_folder_id: Option, + caller_id: Uuid, ) -> Result { - // 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)>( + // If moving to a different folder, get the new user_id (must be same user). + // + // §14: `updated_by = $3` (caller_id) — the caller mutated this + // row. The previous COALESCE derived authorship from the + // destination folder's owner, which is wrong: dest's user_id + // has no claim to authorship of the file's content. D2 shared + // drives surface this most starkly (Alice moves Bob's file + // into Charlie's drive — `updated_by` must be Alice). + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + i64, + String, + i64, + i64, + Option, + Option, + ), + >( 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 = $3 + 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, + f.created_by, f.updated_by "#, ) .bind(&target_folder_id) .bind(file_id) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))? @@ -390,6 +470,8 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, String::new(), + row.7, + row.8, ) } @@ -398,9 +480,16 @@ impl FileWritePort for FileBlobWriteRepository { file_id: &str, target_folder_id: Option, new_name: Option<&str>, + caller_id: Uuid, ) -> Result { // Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count. // Single round-trip; blob content is NOT copied (dedup makes this zero-copy). + // + // §14: `created_by = $4 = updated_by = caller_id` — the caller + // authored this copy. The previous binding used + // `dest_folder.user_id` which silently recorded the destination + // folder's owner as the author when Adam copied a file into + // Alice's folder. let target_fid = target_folder_id.clone(); let rename_to = new_name.map(|s| s.to_string()); @@ -416,6 +505,8 @@ impl FileWritePort for FileBlobWriteRepository { i64, i64, String, + Option, + Option, ), >( r#" @@ -424,20 +515,39 @@ 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, + $4, + $4 + 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, - blob_hash + blob_hash, + created_by, + updated_by ) SELECT * FROM new_file "#, @@ -445,6 +555,7 @@ impl FileWritePort for FileBlobWriteRepository { .bind(file_id) .bind(&target_fid) .bind(&rename_to) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) }) .await @@ -490,22 +601,45 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, row.7, + row.8, + row.9, ) } - async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + caller_id: Uuid, + ) -> Result { + // §14: `updated_by = $3` (caller_id), see move_file. + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + i64, + String, + i64, + i64, + Option, + Option, + ), + >( r#" UPDATE storage.files - SET name = $1, updated_at = NOW() + SET name = $1, updated_at = NOW(), updated_by = $3 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 + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, updated_by "#, ) .bind(new_name) .bind(file_id) + .bind(caller_id) .fetch_optional(self.pool.as_ref()) .await .map_err(|e| { @@ -530,6 +664,8 @@ impl FileWritePort for FileBlobWriteRepository { row.6, None, String::new(), + row.7, + row.8, ) } @@ -559,12 +695,13 @@ impl FileWritePort for FileBlobWriteRepository { blob_hash: &str, size: u64, modified_at: Option, + caller_id: Uuid, ) -> Result<(String, i64), DomainError> { // The content was already ingested into the chunk store by the // upload-ingest layer; swap_blob_hash consumes its reference and // releases it on failure. let swapped = self - .swap_blob_hash(file_id, blob_hash, size as i64, modified_at) + .swap_blob_hash(file_id, blob_hash, size as i64, modified_at, caller_id) .await?; // The file now maps to a different blob — drop the read-side cache // entry so streaming downloads cannot serve the previous content @@ -579,30 +716,41 @@ impl FileWritePort for FileBlobWriteRepository { folder_id: Option, content_type: String, size: u64, + caller_id: Uuid, ) -> 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. let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + // §14: `created_by = $9 = updated_by = caller_id`. The legacy + // `user_id` column (dropped in D7) stays bound to the parent + // folder's owner; only the two provenance columns flip to the + // caller — see save_file_with_blob_impl. let row = retry_on_deadlock("files.insert_deferred", || { - sqlx::query_as::<_, (String, i64, i64)>( + sqlx::query_as::<_, (String, i64, i64, Option, Option)>( 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, $9, $9) RETURNING id::text, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) .bind(&folder_id) .bind(user_id) + .bind(drive_id) .bind(placeholder_hash) .bind(size as i64) .bind(&content_type) .bind(category_order_for(&name, &content_type)) + .bind(caller_id) .fetch_one(self.pool.as_ref()) }) .await @@ -620,6 +768,8 @@ impl FileWritePort for FileBlobWriteRepository { row.2, Some(user_id), String::new(), + row.3, + row.4, )?; // The target_path is not meaningful for blob storage (content goes to .blobs/) @@ -631,18 +781,21 @@ impl FileWritePort for FileBlobWriteRepository { // ── Trash operations ── - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> { + // §14: `updated_by = $2` (caller_id), see move_file. let result = sqlx::query( r#" UPDATE storage.files SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id, - updated_at = NOW() + updated_at = NOW(), + updated_by = $2 WHERE id = $1::uuid AND NOT is_trashed "#, ) .bind(file_id) + .bind(caller_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?; @@ -657,7 +810,9 @@ impl FileWritePort for FileBlobWriteRepository { &self, file_id: &str, _original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError> { + // §14: `updated_by = $2` (caller_id), see move_file. let result = sqlx::query( r#" UPDATE storage.files @@ -665,11 +820,13 @@ 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 = $2 WHERE id = $1::uuid AND is_trashed "#, ) .bind(file_id) + .bind(caller_id) .execute(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?; diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 5d38ce56..d968f05b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -21,36 +21,58 @@ use crate::domain::services::authorization::ResourceKind; use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. -/// Tuple order: id, name, path, parent_id, user_id, created_at, -/// modified_at, tree_modified_at. The trailing `tree_modified_at` -/// feeds [`Folder::etag`] — every SELECT here must include -/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. -type FolderRow = (String, String, String, Option, Uuid, i64, i64, i64); +/// Tuple order: id, name, path, parent_id, user_id, drive_id, +/// created_at, modified_at, tree_modified_at, created_by, updated_by. +/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every +/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. +/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based +/// lookups. `created_by` / `updated_by` are the §14 provenance +/// columns, nullable because the FK is `ON DELETE SET NULL`. +type FolderRow = ( + String, + String, + String, + Option, + Uuid, + Uuid, + i64, + i64, + i64, + Option, + Option, +); /// Type alias for paginated folder rows (includes total_count as -/// the last element after `tree_modified_at`). +/// the last element after the §14 provenance columns). type FolderRowPaginated = ( String, String, String, Option, Uuid, + Uuid, i64, i64, i64, + Option, + Option, i64, ); /// Type alias for folder rows with optional user_id. +/// Includes the §14 provenance columns `created_by` / `updated_by`. type FolderRowOptUser = ( String, String, String, Option, Option, + Uuid, i64, i64, i64, + Option, + Option, ); /// PostgreSQL-backed folder repository. @@ -84,7 +106,9 @@ impl FolderDbRepository { /// Convert a database row into a `Folder` domain entity. /// /// The `path` comes directly from the materialized `path` column — no - /// extra queries needed. + /// extra queries needed. `created_by` / `updated_by` carry the + /// §14 provenance signal through the entity layer; both are + /// `Option` because the FK is `ON DELETE SET NULL`. #[allow(clippy::too_many_arguments)] fn row_to_folder( id: String, @@ -92,20 +116,26 @@ impl FolderDbRepository { path: String, parent_id: Option, user_id: Option, + drive_id: Uuid, created_at: i64, modified_at: i64, tree_modified_at: i64, + created_by: Option, + updated_by: Option, ) -> Result { let storage_path = StoragePath::from_string(&path); - Folder::with_timestamps_and_tree( + Folder::with_timestamps_tree_and_provenance( id, name, storage_path, parent_id, user_id, + drive_id, created_at as u64, modified_at as u64, tree_modified_at as u64, + created_by, + updated_by, ) .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) } @@ -123,10 +153,11 @@ impl FolderDbRepository { let rows = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE id = ANY($1) AND NOT is_trashed "#, @@ -137,7 +168,9 @@ impl FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?; rows.into_iter() - .map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7)) + .map(|r| { + Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10) + }) .collect() } } @@ -147,40 +180,59 @@ impl FolderRepository for FolderDbRepository { &self, name: String, parent_id: Option, + caller_id: Uuid, ) -> 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", - "Cannot create root folder without user_id — use create_home_folder instead", + "Cannot create root folder — root folders are reserved for the \ + atomic drive-creation transaction in DrivePgRepository::\ + create_personal_drive_atomic (docs/plan/drive.md §3). The \ + no-orphan-root-folder trigger enforces this at the DB level.", )); }; - let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>( + // D0 dual-write: drive_id alongside user_id (drops in D7); plus + // §14 provenance — `created_by` / `updated_by` bind to the caller + // ($5), NOT to the parent folder's `user_id`. Pre-D2 they're + // silently equivalent (only the parent's owner can write); the + // distinction matters once shared drives let an Editor mutate + // a folder owned by someone else. + // + // RETURNING also surfaces the two provenance columns so the + // built entity / DTO carries fresh values without a re-read. + let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option, Option)>( 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, $5, $5) RETURNING id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, + updated_by "#, ) .bind(&name) .bind(&parent_id) .bind(user_id) + .bind(drive_id) + .bind(caller_id) .fetch_one(self.pool()) .await .map_err(|e| { @@ -201,19 +253,24 @@ impl FolderRepository for FolderDbRepository { row.1, parent_id, Some(user_id), + drive_id, row.2, row.3, row.4, + // Fresh from RETURNING — caller_id was bound to both columns. + row.5, + row.6, ) } async fn get_folder(&self, id: &str) -> Result { let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE id = $1::uuid AND NOT is_trashed "#, @@ -224,10 +281,26 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + row.9, + row.10, + ) } - async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { + async fn get_folder_by_path( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result { let path_str = storage_path.to_string(); // Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs" let lookup = path_str.strip_prefix('/').unwrap_or(&path_str); @@ -236,23 +309,44 @@ impl FolderRepository for FolderDbRepository { return Err(DomainError::not_found("Folder", "empty path")); } + // Scoped by drive_id: post-D0 `storage.folders.path` is unique + // only within a single drive. Root-folder names like + // `"Personal"` repeat across drives, so without the drive_id + // filter the planner returns a non-deterministic row — which + // breaks owner-short-circuit checks and crosses drive + // boundaries (the AuthZ axis that replaces the old per-user + // wrapper scoping post-D0). let row = sqlx::query_as::<_, FolderRow>( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders - WHERE path = $1 AND NOT is_trashed + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed "#, ) .bind(lookup) + .bind(drive_id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", lookup))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + row.9, + row.10, + ) } #[allow(clippy::type_complexity)] @@ -260,10 +354,11 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name @@ -275,10 +370,11 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name @@ -290,8 +386,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } @@ -305,10 +401,11 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed ORDER BY name @@ -321,10 +418,11 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed ORDER BY name @@ -337,8 +435,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } @@ -357,10 +455,11 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed @@ -376,10 +475,11 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed @@ -396,16 +496,18 @@ impl FolderRepository for FolderDbRepository { // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { - Some(rows.first().map_or(0, |r| r.8) as usize) + Some(rows.first().map_or(0, |r| r.11) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) - }) + .map( + |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) + }, + ) .collect(); Ok((folders?, total)) } @@ -424,10 +526,11 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed @@ -444,10 +547,11 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed @@ -464,41 +568,55 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; let total = if include_total { - Some(rows.first().map_or(0, |r| r.8) as usize) + Some(rows.first().map_or(0, |r| r.11) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) - }) + .map( + |(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) + }, + ) .collect(); Ok((folders?, total)) } - async fn rename_folder(&self, id: &str, new_name: String) -> Result { + async fn rename_folder( + &self, + id: &str, + new_name: String, + caller_id: Uuid, + ) -> Result { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. // That multi-row rewrite can deadlock against the tree-ETag // flusher's id-ordered ancestor bump — retry instead of failing // the user's operation (40P01 only; 23505 still maps below). + // + // §14: `updated_by = $3` (caller_id) — the caller mutated this + // row, not the row's owner. In D2 a shared-drive member can + // rename a row they don't own; the previous `updated_by = user_id` + // would have silently recorded the wrong principal. let row = retry_on_deadlock("folders.rename", || { sqlx::query_as::<_, FolderRow>( r#" UPDATE storage.folders - SET name = $1, updated_at = NOW() + SET name = $1, updated_at = NOW(), updated_by = $3 WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, user_id, + RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by "#, ) .bind(&new_name) .bind(id) + .bind(caller_id) .fetch_optional(self.pool()) }) .await @@ -512,39 +630,68 @@ impl FolderRepository for FolderDbRepository { })? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + row.9, + row.10, + ) } async fn move_folder( &self, id: &str, new_parent_id: Option<&str>, + caller_id: Uuid, ) -> Result { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. // Retried on deadlock vs the tree-ETag flusher (see rename_folder). + // + // §14: `updated_by = $3` (caller_id), see rename_folder. let row = retry_on_deadlock("folders.move", || { 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 = $3 WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, path, parent_id::text, user_id, + RETURNING id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by "#, ) .bind(new_parent_id) .bind(id) + .bind(caller_id) .fetch_optional(self.pool()) }) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) + Self::row_to_folder( + row.0, + row.1, + row.2, + row.3, + Some(row.4), + row.5, + row.6, + row.7, + row.8, + row.9, + row.10, + ) } async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { @@ -582,14 +729,22 @@ impl FolderRepository for FolderDbRepository { Ok(()) } - async fn folder_exists(&self, storage_path: &StoragePath) -> Result { + async fn folder_exists( + &self, + storage_path: &StoragePath, + drive_id: Uuid, + ) -> Result { let path_str = storage_path.to_string(); let lookup = path_str.strip_prefix('/').unwrap_or(&path_str); + // Post-D0 `storage.folders.path` repeats across drives — + // filter by `drive_id` to scope the existence check. let exists: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)", + "SELECT EXISTS(SELECT 1 FROM storage.folders \ + WHERE path = $1 AND drive_id = $2 AND NOT is_trashed)", ) .bind(lookup) + .bind(drive_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?; @@ -611,7 +766,7 @@ impl FolderRepository for FolderDbRepository { // ── Trash operations ── - async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { + async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError> { // Soft-delete the whole subtree in one statement: the root flips // `is_trashed` and records `original_parent_id` so restore knows // where to put it back; every descendant (folder or file) that @@ -626,6 +781,10 @@ impl FolderRepository for FolderDbRepository { // `/g9-tree/file.txt` still resolved 207 even though the parent // collection was gone) — a class of data-integrity drift that // confused desktop-sync tree walks. + // + // §14: all three CTE branches stamp `updated_by = $2` + // (caller_id). The cascade is "the caller trashed this + // subtree", not "each owner trashed their own row". let result = retry_on_deadlock("folders.trash", || { sqlx::query_scalar::<_, i64>( r#" @@ -634,7 +793,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 = $2 WHERE id = $1::uuid AND NOT is_trashed RETURNING id, lpath ), @@ -642,7 +802,8 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders f SET is_trashed = TRUE, trashed_at = NOW(), - updated_at = NOW() + updated_at = NOW(), + updated_by = $2 FROM trash_root tr WHERE f.lpath <@ tr.lpath AND f.id != tr.id @@ -653,7 +814,8 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.files fi SET is_trashed = TRUE, trashed_at = NOW(), - updated_at = NOW() + updated_at = NOW(), + updated_by = $2 FROM trash_root tr JOIN storage.folders f ON f.lpath <@ tr.lpath WHERE fi.folder_id = f.id @@ -664,6 +826,7 @@ impl FolderRepository for FolderDbRepository { "#, ) .bind(folder_id) + .bind(caller_id) .fetch_one(self.pool()) }) .await @@ -680,6 +843,7 @@ impl FolderRepository for FolderDbRepository { &self, folder_id: &str, _original_path: &str, + caller_id: Uuid, ) -> Result<(), DomainError> { // Inverse of the cascade in `move_to_trash`: restore the root // (BEFORE UPDATE trigger recomputes path/lpath via the parent_id @@ -689,6 +853,10 @@ impl FolderRepository for FolderDbRepository { // *before* this folder went to trash have `original_*` set, so // they correctly stay in trash and continue to show up as // top-level trash entries via `storage.trash_items`. + // + // §14: all three CTE branches stamp `updated_by = $2` + // (caller_id). Restoration is "the caller restored this + // subtree", regardless of who originally owned each row. let result = retry_on_deadlock("folders.restore", || { sqlx::query_scalar::<_, i64>( r#" @@ -698,7 +866,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 = $2 WHERE id = $1::uuid AND is_trashed RETURNING id, lpath ), @@ -706,7 +875,8 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.folders f SET is_trashed = FALSE, trashed_at = NULL, - updated_at = NOW() + updated_at = NOW(), + updated_by = $2 FROM restore_root rr WHERE f.lpath <@ rr.lpath AND f.id != rr.id @@ -718,7 +888,8 @@ impl FolderRepository for FolderDbRepository { UPDATE storage.files fi SET is_trashed = FALSE, trashed_at = NULL, - updated_at = NOW() + updated_at = NOW(), + updated_by = $2 FROM restore_root rr JOIN storage.folders f ON f.lpath <@ rr.lpath WHERE fi.folder_id = f.id @@ -730,6 +901,7 @@ impl FolderRepository for FolderDbRepository { "#, ) .bind(folder_id) + .bind(caller_id) .fetch_one(self.pool()) }) .await @@ -775,61 +947,6 @@ impl FolderRepository for FolderDbRepository { Ok(()) } - async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result { - let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>( - r#" - INSERT INTO storage.folders (name, parent_id, user_id) - VALUES ($1, NULL, $2) - ON CONFLICT DO NOTHING - RETURNING id::text, - path, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint - "#, - ) - .bind(&name) - .bind(user_id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; - - match row { - Some((id, path, ca, ma, tma)) => { - Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma) - } - None => { - // Already exists — fetch it - let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>( - r#" - SELECT id::text, - path, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint - FROM storage.folders - WHERE name = $1 AND user_id = $2 AND parent_id IS NULL - "#, - ) - .bind(&name) - .bind(user_id) - .fetch_one(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; - Self::row_to_folder( - existing.0, - name, - existing.1, - None, - Some(user_id), - existing.2, - existing.3, - existing.4, - ) - } - } - } - /// Lists every folder in a subtree rooted at `folder_id` (inclusive). /// /// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`. @@ -837,10 +954,11 @@ impl FolderRepository for FolderDbRepository { #[allow(clippy::type_complexity)] async fn list_subtree_folders(&self, folder_id: &str) -> Result, DomainError> { let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ - EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.is_trashed = false \ AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \ @@ -855,8 +973,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -900,10 +1018,11 @@ impl FolderRepository for FolderDbRepository { // Recursive, no folder scope → ALL user folders let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ - EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -927,8 +1046,8 @@ impl FolderRepository for FolderDbRepository { return rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect(); } @@ -937,10 +1056,11 @@ impl FolderRepository for FolderDbRepository { let sql = if parent_id.is_some() { format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ - EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.parent_id = $1::uuid \ AND fo.user_id = $2 \ @@ -956,10 +1076,11 @@ impl FolderRepository for FolderDbRepository { }; format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ - EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.parent_id IS NULL \ AND fo.user_id = $1 \ @@ -999,8 +1120,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1025,10 +1146,11 @@ impl FolderRepository for FolderDbRepository { let sql = format!( "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ - fo.user_id, \ + fo.user_id, fo.drive_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ - EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \ + fo.created_by, fo.updated_by \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -1055,8 +1177,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub) }) .collect() } @@ -1074,10 +1196,11 @@ impl FolderRepository for FolderDbRepository { let rows: Vec = if let Some(pid) = parent_id { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed @@ -1100,10 +1223,11 @@ impl FolderRepository for FolderDbRepository { } else { sqlx::query_as( r#" - SELECT id::text, name, path, parent_id::text, user_id, + SELECT id::text, name, path, parent_id::text, user_id, drive_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, - EXTRACT(EPOCH FROM tree_modified_at)::bigint + EXTRACT(EPOCH FROM tree_modified_at)::bigint, + created_by, updated_by FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed @@ -1126,8 +1250,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma, tma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) + .map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| { + Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub) }) .collect() } 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..7386b77f 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -1927,7 +1927,7 @@ impl DedupService { OR b.orphaned_at < now() - ($2::int * interval '1 second')) AND NOT EXISTS ( SELECT 1 FROM storage.chunk_manifests m - WHERE m.chunk_hashes @> ARRAY[b.hash] + WHERE m.chunk_hashes @> ARRAY[b.hash::text] ) AND NOT EXISTS ( SELECT 1 FROM storage.files f @@ -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(); @@ -3373,7 +3394,7 @@ 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; // (A) An aged orphan (orphaned well past the grace window) with no // references → must be collected (row + backing file). @@ -3412,7 +3433,7 @@ mod delta_upload_integration_tests { // stale ref_count must never delete referenced content. let data = content(3 * 1024 * 1024, 71); let (file_hash, owned_chunks, file_id) = - seed_owned_content(&svc, &pool, user, &data, "gc").await; + seed_owned_content(&svc, &pool, user, drive_id, &data, "gc").await; let referenced = owned_chunks[0].clone(); sqlx::query( "UPDATE storage.blobs @@ -3470,12 +3491,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; // Single-owner multi-chunk CDC file → its chunks are uniquely owned. let data = content(3 * 1024 * 1024, 91); let (file_hash, chunks, file_id) = - seed_owned_content(&svc, &pool, user, &data, "deref").await; + seed_owned_content(&svc, &pool, user, drive_id, &data, "deref").await; assert!(chunks.len() >= 3, "3 MiB must split into ≥3 chunks"); // The delete_file_permanently sequence: drop the file row (PG trigger) @@ -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/path_resolver_service.rs b/src/infrastructure/services/path_resolver_service.rs index ea187db5..776b96e0 100644 --- a/src/infrastructure/services/path_resolver_service.rs +++ b/src/infrastructure/services/path_resolver_service.rs @@ -64,6 +64,7 @@ impl PathResolverService { String, // path Option, // parent_id Option, // user_id + Uuid, // drive_id i64, // created_at i64, // modified_at Option, // size @@ -72,7 +73,7 @@ impl PathResolverService { ), >( r#" - SELECT resource_type, id, name, path, parent_id, user_id, + SELECT resource_type, id, name, path, parent_id, user_id, drive_id, created_at, modified_at, size, mime_type, folder_id FROM ( SELECT 'folder'::text AS resource_type, @@ -81,6 +82,7 @@ impl PathResolverService { fo.path, fo.parent_id::text, fo.user_id::text, + fo.drive_id, EXTRACT(EPOCH FROM fo.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fo.updated_at)::bigint AS modified_at, NULL::bigint AS size, @@ -102,6 +104,7 @@ impl PathResolverService { END AS path, NULL::text AS parent_id, fi.user_id::text, + fi.drive_id, EXTRACT(EPOCH FROM fi.created_at)::bigint AS created_at, EXTRACT(EPOCH FROM fi.updated_at)::bigint AS modified_at, fi.size, @@ -136,6 +139,7 @@ impl PathResolverService { res_path, parent_id, uid, + drive_id, created_at, modified_at, size, @@ -151,12 +155,19 @@ impl PathResolverService { path: res_path, parent_id, owner_id: uid, + drive_id, created_at: created_at as u64, modified_at: modified_at as u64, is_root: false, icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by this resolver path — + // it's used for existence/type discrimination, not + // detailed DTO emission. Callers that need provenance + // reload through the repo. + created_by: None, + updated_by: None, })), _ => { let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string()); @@ -183,6 +194,9 @@ impl PathResolverService { sort_date: None, content_hash: String::new(), etag: String::new(), + // §14 provenance not selected by this resolver path + created_by: None, + updated_by: None, })) } } diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index bc02550a..ea6687e3 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -259,11 +259,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())), } } @@ -389,6 +410,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. @@ -488,7 +546,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), @@ -529,6 +592,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/chunked_upload_handler.rs b/src/interfaces/api/handlers/chunked_upload_handler.rs index 0998803b..489c2e49 100644 --- a/src/interfaces/api/handlers/chunked_upload_handler.rs +++ b/src/interfaces/api/handlers/chunked_upload_handler.rs @@ -417,6 +417,7 @@ impl ChunkedUploadHandler { parts.folder_id.clone(), ingested.content_type.clone(), ingested.stored(), + auth_user.id, ) .await { 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/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index c32a5a20..08450a96 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -262,12 +262,20 @@ pub async fn list_favorites_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Listing handler — drive_id is informational + // and the favorites row doesn't currently + // SELECT it. Path-based lookups never enter + // this code path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the favorites query. + created_by: None, + updated_by: None, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -311,6 +319,9 @@ pub async fn list_favorites_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the favorites query. + created_by: None, + updated_by: None, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 76296d4c..822d3303 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -284,6 +284,7 @@ impl FileHandler { folder_id, ingested.content_type.clone(), ingested.stored(), + auth_user.id, ) .await { diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index ff1160b7..b868ae16 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -753,12 +753,19 @@ pub async fn list_folder_resources( path: String::new(), // cleared — share recipients must not see hierarchy parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Resources listing — drive_id is informational + // here; not selected by the underlying query. + // Path-based lookups never enter this code path. + drive_id: uuid::Uuid::nil(), created_at: row.created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by the resources query. + created_by: None, + updated_by: None, }; FolderResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -801,6 +808,9 @@ pub async fn list_folder_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the resources query. + created_by: None, + updated_by: None, }; FolderResourceItemDto { resource_type: ResourceTypeDto::File, 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/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 14dcb189..eb313b40 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -292,12 +292,20 @@ pub async fn list_recent_resources( path, parent_id: row.parent_id.map(|u| u.to_string()), owner_id: Some(row.owner_id.to_string()), + // Listing handler — drive_id is informational + // and the recents row doesn't currently SELECT + // it. Path-based lookups never enter this code + // path. + drive_id: uuid::Uuid::nil(), created_at: row.resource_created_at.timestamp() as u64, modified_at: row.modified_at.timestamp() as u64, is_root: false, icon_class: std::sync::Arc::from("fas fa-folder"), icon_special_class: std::sync::Arc::from("folder-icon"), category: std::sync::Arc::from("Folder"), + // §14 provenance not selected by the recents query. + created_by: None, + updated_by: None, }; RecentResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -339,6 +347,9 @@ pub async fn list_recent_resources( sort_date: None, content_hash, etag, + // §14 provenance not selected by the recents query. + created_by: None, + updated_by: None, }; RecentResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 83987e28..74b1bde0 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort; use crate::application::services::file_retrieval_service::FileRetrievalService; use crate::application::services::folder_service::FolderService; use crate::common::di::AppState; +use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::services::path_resolver_service::ResolvedResource; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; @@ -247,6 +248,29 @@ async fn resolve_webdav_path(state: &Arc, user_id: Uuid, path: &str) - } } +/// Native WebDAV protocol entry: resolve the caller's default drive +/// once per handler so every downstream path-based lookup +/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`) +/// can pass the same `drive_id` scope. +/// +/// Post-D0 `storage.{folders,files}.path` repeats across drives — the +/// scope is mandatory. Native WebDAV today lives in a single-drive +/// surface (one default drive per user), so the lookup is unambiguous. +/// Multi-drive support via path segments (`/webdav/drives//…`) +/// is tracked separately and will derive `drive_id` directly from the +/// URL instead of going through `find_default_for_user`. +async fn resolve_drive_id_for_native_webdav( + state: &Arc, + user_id: Uuid, +) -> Result { + state + .drive_repo + .find_default_for_user(user_id) + .await + .map(|d| d.drive.id) + .map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))) +} + async fn handle_webdav_dispatch( state: Arc, req: Request, @@ -405,12 +429,18 @@ async fn handle_propfind( path: "".to_string(), parent_id: None, owner_id: None, + // Synthetic root folder for PROPFIND on `/`; not an + // actual DB row, so drive_id has no meaningful value. + drive_id: Uuid::nil(), created_at: Utc::now().timestamp() as u64, modified_at: Utc::now().timestamp() as u64, is_root: true, icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not applicable to the synthetic root. + created_by: None, + updated_by: None, }; return build_streaming_propfind_response( @@ -468,8 +498,11 @@ async fn handle_propfind( Err(_) => {} } } else { - // Fallback: legacy double-query path when PathResolver is unavailable - if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + // Fallback: legacy double-query path when PathResolver is unavailable. + // `drive_id` is mandatory post-D0 for path-based lookups — derive + // the caller's default drive once and reuse it for both probes. + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; let folder_id = folder.id.clone(); return build_streaming_propfind_response( @@ -484,7 +517,10 @@ async fn handle_propfind( ) .await; } - if let Ok(file) = file_retrieval_service.get_file_by_path(&path).await { + if let Ok(file) = file_retrieval_service + .get_file_by_path(&path, drive_id) + .await + { assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; let mut buf = Vec::with_capacity(1024); { @@ -656,7 +692,7 @@ async fn handle_proppatch( req: Request, path: String, ) -> Result, AppError> { - let _user = extract_user(&req)?; + let user = extract_user(&req)?; // Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties, // so a lock on the target must release them via `If:`. Captured @@ -688,10 +724,11 @@ async fn handle_proppatch( let is_collection = if path.is_empty() || path == "/" { true } else { + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service - .get_folder_by_path(&path) + .get_folder_by_path(&path, drive_id) .await .is_ok() }; @@ -776,9 +813,12 @@ async fn handle_get( } } } else { - // Legacy fallback — fetch + ownership check + // Legacy fallback — fetch + ownership check. `drive_id` is the + // path-lookup scope post-D0 (`storage.files.path` repeats across + // drives), derived once from the caller's default drive. + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let f = file_retrieval_service - .get_file_by_path(&path) + .get_file_by_path(&path, drive_id) .await .map_err(|_e| AppError::not_found(format!("File not found: {}", path)))?; assert_owner(f.owner_id.as_deref(), &user.id.to_string(), &path)?; @@ -876,8 +916,11 @@ async fn handle_head( } } - // Fallback: legacy double-query path (with ownership check) - if let Ok(folder) = folder_service.get_folder_by_path(&path).await { + // Fallback: legacy double-query path (with ownership check). + // `drive_id` is the path-lookup scope post-D0 — derive once and + // reuse for both the folder and file probes. + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + if let Ok(folder) = folder_service.get_folder_by_path(&path, drive_id).await { assert_owner(folder.owner_id.as_deref(), &user.id.to_string(), &path)?; return Ok(Response::builder() .status(StatusCode::OK) @@ -890,7 +933,7 @@ async fn handle_head( // Try as file — use metadata only, never load content for HEAD let file = file_retrieval_service - .get_file_by_path(&path) + .get_file_by_path(&path, drive_id) .await .map_err(|_e| AppError::not_found(format!("Resource not found: {}", path)))?; assert_owner(file.owner_id.as_deref(), &user.id.to_string(), &path)?; @@ -939,15 +982,27 @@ async fn resolve_or_legacy( return Some(r); } + // Path-lookup scope post-D0 — derive the caller's default drive + // for both legacy probes. `find_default_for_user` returning Err + // (e.g. external user, or boot before the lifecycle hook fired) + // means no fallback resolution is possible: return None. + let drive_id = state + .drive_repo + .find_default_for_user(user_id) + .await + .ok()? + .drive + .id; + let user_id_str = user_id.to_string(); let folder_service = &state.applications.folder_service; - if let Ok(folder) = folder_service.get_folder_by_path(path).await + if let Ok(folder) = folder_service.get_folder_by_path(path, drive_id).await && folder.owner_id.as_deref() == Some(&user_id_str) { return Some(ResolvedResource::Folder(folder)); } let file_retrieval = &state.applications.file_retrieval_service; - if let Ok(file) = file_retrieval.get_file_by_path(path).await + if let Ok(file) = file_retrieval.get_file_by_path(path, drive_id).await && file.owner_id.as_deref() == Some(&user_id_str) { return Some(ResolvedResource::File(file)); @@ -1143,8 +1198,16 @@ async fn handle_put( // ── Atomic store: swap the file row onto the ingested blob ── let content_type = ingested.content_type.clone(); + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let result = file_upload_service - .update_file_streaming(&path, ingested.stored(), &content_type, None) + .update_file_streaming( + &path, + drive_id, + ingested.stored(), + &content_type, + None, + user.id, + ) .await; match result { @@ -1198,6 +1261,9 @@ async fn handle_mkcol( // Path is already translated by dispatch (e.g. "My Folder - jared/03/01"). // Walk each segment: the first is the home folder (already exists), // subsequent segments are created as needed with proper parent_id. + // `drive_id` scopes each per-segment path probe to the caller's default + // drive (post-D0 invariant: `storage.folders.path` repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut parent_id: Option = None; let mut accumulated_path = String::new(); @@ -1208,7 +1274,10 @@ async fn handle_mkcol( } accumulated_path.push_str(segment); - match folder_service.get_folder_by_path(&accumulated_path).await { + match folder_service + .get_folder_by_path(&accumulated_path, drive_id) + .await + { Ok(existing) => { parent_id = Some(existing.id); } @@ -1392,6 +1461,11 @@ async fn handle_move( let file_management_service = &state.applications.file_management_service; let folder_service = &state.applications.folder_service; + // `drive_id` scopes every path-based lookup below to the caller's + // default drive (post-D0 invariant: `storage.{files,folders}.path` + // repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { @@ -1401,11 +1475,11 @@ async fn handle_move( .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path) + .get_folder_by_path(&destination_path, drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path) + .get_file_by_path(&destination_path, drive_id) .await .is_ok() }; @@ -1443,7 +1517,9 @@ async fn handle_move( let move_dto = crate::application::dtos::folder_dto::MoveFolderDto { parent_id: if dest_parent_path.is_empty() { None - } else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await + } else if let Ok(parent) = folder_service + .get_folder_by_path(dest_parent_path, drive_id) + .await { assert_owner( parent.owner_id.as_deref(), @@ -1483,7 +1559,7 @@ async fn handle_move( None } else { let parent = folder_service - .get_folder_by_path(dest_parent_path) + .get_folder_by_path(dest_parent_path, drive_id) .await .map_err(|_| { AppError::not_found(format!( @@ -1601,6 +1677,11 @@ async fn handle_copy( let file_retrieval_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; + // `drive_id` scopes every path-based lookup below to the caller's + // default drive (post-D0 invariant: `storage.{files,folders}.path` + // repeats across drives). + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; + // Check if destination already exists (for Overwrite header compliance) if !overwrite { let dest_exists = if let Some(resolver) = &state.path_resolver { @@ -1610,11 +1691,11 @@ async fn handle_copy( .unwrap_or(false) } else { folder_service - .get_folder_by_path(&destination_path) + .get_folder_by_path(&destination_path, drive_id) .await .is_ok() || file_retrieval_service - .get_file_by_path(&destination_path) + .get_file_by_path(&destination_path, drive_id) .await .is_ok() }; @@ -1644,7 +1725,10 @@ async fn handle_copy( let target_parent_id = if dest_parent_path.is_empty() { None - } else if let Ok(parent) = folder_service.get_folder_by_path(dest_parent_path).await { + } else if let Ok(parent) = folder_service + .get_folder_by_path(dest_parent_path, drive_id) + .await + { assert_owner( parent.owner_id.as_deref(), &user.id.to_string(), @@ -1740,10 +1824,11 @@ async fn handle_lock( let is_collection = if path.is_empty() || path == "/" { true } else { + let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?; state .applications .folder_service - .get_folder_by_path(&path) + .get_folder_by_path(&path, drive_id) .await .is_ok() }; diff --git a/src/interfaces/api/handlers/wopi_handler.rs b/src/interfaces/api/handlers/wopi_handler.rs index 063423e6..232038a5 100644 --- a/src/interfaces/api/handlers/wopi_handler.rs +++ b/src/interfaces/api/handlers/wopi_handler.rs @@ -23,6 +23,7 @@ use std::sync::Arc; use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase}; use crate::application::services::wopi_lock_service::WopiLockService; use crate::application::services::wopi_token_service::WopiTokenService; +use crate::domain::repositories::drive_repository::DriveRepository; use crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService; /// Shared state for WOPI handlers. @@ -233,11 +234,38 @@ async fn put_file( }; // ── Atomic store: swap the file row onto the ingested blob ── + // `drive_id` scopes the path-based lookups in `update_file_streaming` + // post-D0. WOPI tokens carry the user UUID in `claims.sub`; we resolve + // that to the caller's default drive (WOPI today is a single-drive + // editing surface — no drive marker travels in the token). + let claims_sub_uuid = match uuid::Uuid::parse_str(&claims.sub) { + Ok(u) => u, + Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + }; + let drive_id = match state + .app_state + .drive_repo + .find_default_for_user(claims_sub_uuid) + .await + { + Ok(d) => d.drive.id, + Err(e) => { + tracing::error!("WOPI PutFile: default-drive lookup failed: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; let result = state .app_state .applications .file_upload_service - .update_file_streaming(&file.path, ingested.stored(), &content_type, None) + .update_file_streaming( + &file.path, + drive_id, + ingested.stored(), + &content_type, + None, + claims_sub_uuid, + ) .await; match result { 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/src/interfaces/middleware/trace_span.rs b/src/interfaces/middleware/trace_span.rs index 881cdbb3..95550f1b 100644 --- a/src/interfaces/middleware/trace_span.rs +++ b/src/interfaces/middleware/trace_span.rs @@ -103,7 +103,8 @@ impl MakeSpan for ClientIpMakeSpan { method = %request.method(), uri = %request.uri().path(), user_id = tracing::field::Empty, - // The Nextcloud chroot folder id, set by `basic_auth_middleware`. + + // The Nextcloud chroot folder id, set by `basic_auth_middleware` (will be the Drive Id in the future). chroot_id = tracing::field::Empty, ) } diff --git a/src/interfaces/nextcloud/avatar_handler.rs b/src/interfaces/nextcloud/avatar_handler.rs index e5af8a3a..165ef07f 100644 --- a/src/interfaces/nextcloud/avatar_handler.rs +++ b/src/interfaces/nextcloud/avatar_handler.rs @@ -101,6 +101,11 @@ pub async fn handle_avatar( ) -> Response { let size = size.clamp(16, 1024); + let username = match username.split_once("~") { + None => username, + Some((u, _)) => u.to_string(), + }; + // ── Stored profile image — preferred when present ─────────── if let Some(auth_svc) = state.auth_service.as_ref() && let Ok(user) = auth_svc diff --git a/src/interfaces/nextcloud/basic_auth_middleware.rs b/src/interfaces/nextcloud/basic_auth_middleware.rs index 8486a4a1..c9621c0e 100644 --- a/src/interfaces/nextcloud/basic_auth_middleware.rs +++ b/src/interfaces/nextcloud/basic_auth_middleware.rs @@ -59,9 +59,43 @@ pub async fn basic_auth_middleware( NextcloudAuthError::Unauthorized })?; - let (username, password) = + let (raw_username, password) = parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?; + // ── Multi-drive composite-username parse ──────────────────────── + // POC wire shape: `{username}~{drive_marker}` may appear in the + // Basic Auth header. `~` was chosen because it needs no URL + // encoding and doesn't collide with UUID hyphens. The marker + // after `~` is a chroot SELECTOR (handled by `NcSession` via the + // URL `{user}` segment), NOT an auth credential — the password + // is verified against the username PREFIX. The middleware just + // peels the prefix off so the app-password lookup uses the + // canonical name. When no `~` is present, the request is a + // plain single-drive ("home") NC sync. + // + // Reject `name~` (empty marker) and `~marker` (empty username) + // at the auth boundary rather than treating them as "missing + // marker" — they are unambiguous typos that would otherwise + // silently fall into a different code path. + let (username, drive_marker): (String, Option) = match raw_username.split_once('~') { + Some(("", _)) => { + tracing::warn!( + "[NC] 401 malformed composite username (empty prefix): {}", + raw_username + ); + return Err(NextcloudAuthError::Unauthorized); + } + Some((_, "")) => { + tracing::warn!( + "[NC] 401 malformed composite username (empty marker): {}", + raw_username + ); + return Err(NextcloudAuthError::Unauthorized); + } + Some((u, m)) => (u.to_string(), Some(m.to_string())), + None => (raw_username.clone(), None), + }; + // Check account lockout before attempting password verification (saves CPU). // The lockout is per (account, IP), see #323 for rationale. let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&request); @@ -126,12 +160,81 @@ pub async fn basic_auth_middleware( // making it harder to correlate WebDAV / OCS activity to // a specific principal. tracing::Span::current().record("user_id", user_id.to_string()); - request.extensions_mut().insert(Arc::new(CurrentUser { + let current_user = CurrentUser { id: user_id, username: uname, email, role, - })); + }; + + // ── Resolve chroot from the Basic Auth drive marker ───── + // No marker → caller's default personal drive's root folder + // (post-D0 every internal user has one — provisioned by the + // lifecycle hook via the atomic four-write transaction in + // §3 of docs/plan/drive.md). With a marker → + // `get_folder_with_perms` enforces per-folder access (404 + // anti-enumeration on miss / no-read). Today this is the + // sole chroot source; tomorrow it'll come from the + // app-password row instead. + // + // Pre-D0 this lookup name-matched `"My Folder - "` + // against the user's root folders; that broke after the + // wrapper was renamed to `"Personal"` and shared across all + // users — name-matching was the wrong axis. The drive lookup + // is the right one: name-independent, secondary-drive-safe. + use crate::application::ports::folder_ports::FolderUseCase; + use crate::domain::repositories::drive_repository::DriveRepository; + let chroot = match drive_marker.as_deref() { + None => { + match state + .drive_repo + .find_default_for_user(current_user.id) + .await + { + Ok(drive_with_name) => state + .applications + .folder_service + .get_folder(&drive_with_name.drive.root_folder_id.to_string()) + .await + .ok(), + Err(_) => None, + } + } + Some(folder_id) => state + .applications + .folder_service + .get_folder_with_perms(folder_id, current_user.id) + .await + .ok(), + }; + if chroot.is_none() { + tracing::warn!( + "[NC] 404 chroot not resolvable: user={} marker={:?}", + current_user.username, + drive_marker + ); + return Err(NextcloudAuthError::Unauthorized); + } + + request + .extensions_mut() + .insert(Arc::new(current_user.clone())); + request.extensions_mut().insert(Arc::new( + crate::interfaces::nextcloud::session::NcSession { + user: current_user, + raw_username: raw_username.clone(), + chroot, + }, + )); + tracing::Span::current().record( + "chroot_id", + request + .extensions() + .get::>() + .and_then(|s| s.chroot.as_ref()) + .map(|c| c.id.to_string()) + .unwrap_or_default(), + ); Ok(next.run(request).await) } Err(_) => { diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index 9dc4a8b9..810fc773 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -1,3 +1,4 @@ +use askama::Template; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode, header}, @@ -7,8 +8,32 @@ use serde_json::json; use std::collections::HashMap; use std::sync::Arc; +use crate::application::ports::folder_ports::FolderUseCase; use crate::common::di::AppState; use crate::common::errors::DomainError; +use crate::interfaces::middleware::auth::CurrentUser; + +/// Drive option rendered on the picker page. `name` is the folder's +/// display name; `id` is the folder UUID that becomes the `~{marker}` +/// half of the composite Basic-Auth username if the user picks +/// anything other than the first (home) row. +struct DriveOption { + id: String, + name: String, +} + +#[derive(Template)] +#[template(path = "nextcloud/drive_picker.html")] +struct DrivePickerTemplate { + form_action: String, + drives: Vec, +} + +// Home identification is via `position_of_user_home_root_folder` from +// `domain::repositories::drive_repository` — a generic helper that +// keys off `drives.default_for_user == user_id` rather than folder +// name, so user renames of the home folder don't silently break the +// picker UX. /// Serve an HTML page with a Content-Security-Policy header as defense-in-depth. fn html_with_csp(html: &'static str) -> Response { @@ -175,47 +200,294 @@ pub async fn handle_login_submit( Err(e) => return login_failed_response(e), }; - let app_password = match nextcloud - .app_passwords - .create_nc(current_user.id, "Nextcloud") + // ── Multi-drive fork ───────────────────────────────────────────── + // List the user's root folders. By convention the first row is the + // user's home; additional rows are extra drives (POC seeded by + // direct DB insert until a drive admin surface exists). With 0 or + // 1 drive we go straight to the legacy one-shot completion path so + // the common case stays one click. With ≥2 drives we pause the + // flow, stash the user_id, and render the picker — drive selection + // resumes the flow via `handle_drive_pick`. + let mut drives = match state + .applications + .folder_service + .list_folders_with_perms(None, current_user.id) .await { - Ok((_id, password)) => password, + Ok(d) => d, Err(e) => { - tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password"); + tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to list drives"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; - let base_url = state.core.config.base_url(); - let completed = - nextcloud + if drives.len() >= 2 { + // Reorder so home is at index 0. The picker template ties + // both the default-checked radio and the "Home" badge to + // `loop.first`, so placing home first is the single point + // that makes the picker UI line up with the home convention. + // Other drives keep their original alphabetical order. + if let Some(idx) = + crate::domain::repositories::drive_repository::position_of_user_home_root_folder( + state.drive_repo.as_ref(), + current_user.id, + &drives, + |f| uuid::Uuid::parse_str(&f.id).ok(), + ) + .await + && idx != 0 + { + let home = drives.remove(idx); + drives.insert(0, home); + } + // If no home matched the convention, we fall through with the + // raw alphabetical order. The picker will still work but the + // first row gets the badge by default — slightly wrong UX but + // never breaks the auth flow (`handle_drive_pick` re-runs + // `find_home_index` independently). + + if !nextcloud .login_flow - .complete(&token, ¤t_user.username, &base_url, &app_password); + .mark_awaiting_drive(&token, current_user.id) + { + // Flow token vanished (TTL?) between password submit and + // here — extremely unlikely but treat the same as any + // session-expired case. + return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired") + .into_response(); + } + return render_drive_picker(&token, &drives); + } + + complete_flow(&state, &nextcloud.login_flow, &token, ¤t_user, None).await +} + +/// Render the drive picker page. The form posts to +/// `/login/v2/flow/{token}/drive`, carrying only the chosen folder +/// UUID — the authenticated user id is read from the flow's +/// `pending_user_id` slot (consumed by `take_pending_user`). +fn render_drive_picker( + token: &str, + drives: &[crate::application::dtos::folder_dto::FolderDto], +) -> Response { + let template = DrivePickerTemplate { + form_action: format!("/login/v2/flow/{}/drive", token), + drives: drives + .iter() + .map(|f| DriveOption { + id: f.id.clone(), + name: f.name.clone(), + }) + .collect(), + }; + + match template.render() { + Ok(html) => ( + [( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'", + )], + Html(html), + ) + .into_response(), + Err(e) => { + tracing::error!(error = %e, "Login Flow v2: drive picker template render failed"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Mint an app password, complete the flow, and emit the `nc://` deep +/// link. Shared by the single-drive path (called from +/// `handle_login_submit`) and the post-picker path (called from +/// `handle_drive_pick`). +/// +/// `drive_id` is `None` for the single-drive shortcut and for the +/// home-drive choice on the picker; `Some(uuid)` for any other drive, +/// in which case the NC login name carries the `~{uuid}` marker. +async fn complete_flow( + state: &Arc, + login_flow: &crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService, + token: &str, + user: &CurrentUser, + drive_id: Option<&str>, +) -> Response { + let nextcloud = match state.nextcloud.as_ref() { + Some(nc) => nc, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let app_password = match nextcloud + .app_passwords + .create_nc(user.id, "Nextcloud") + .await + { + Ok((_id, password)) => password, + Err(e) => { + tracing::error!(error = %e, user = %user.username, "Login Flow v2: failed to create app password"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let login_name = match drive_id { + Some(uuid) => format!("{}~{}", user.username, uuid), + None => user.username.clone(), + }; + + let base_url = state.core.config.base_url(); + let completed = login_flow.complete(token, &login_name, &base_url, &app_password); if completed { tracing::info!( - user = %current_user.username, + user = %user.username, + login_name = %login_name, base_url = %base_url, "Login Flow v2: flow completed successfully" ); - // Redirect to nc:// deep link so the Nextcloud mobile app receives - // the credentials via Android/iOS intent. Desktop clients use polling - // instead, so they will pick up the result from the poll endpoint. let nc_url = format!( "nc://login/server:{}&user:{}&password:{}", - base_url, current_user.username, app_password + base_url, login_name, app_password ); axum::response::Redirect::to(&nc_url).into_response() } else { tracing::error!( - user = %current_user.username, + user = %user.username, "Login Flow v2: complete() returned false — flow token not found" ); axum::response::Redirect::to("/nextcloud-error.html?type=session-expired").into_response() } } +/// POST `/login/v2/flow/{token}/drive` — finalise a paused login flow +/// after the user picks a drive on the picker page. +/// +/// Auth model: the route is **public** (no Basic Auth — this is the +/// browser-side leg of Login Flow v2, before the app password is +/// issued). The proof of authentication is the single-use +/// `pending_user_id` slot on the flow, set by `handle_login_submit` +/// after password verification and consumed here. Replay is naturally +/// blocked: a second POST finds nothing to consume. +pub async fn handle_drive_pick( + State(state): State>, + Path(token): Path, + body: String, +) -> Response { + let nextcloud = match state.nextcloud.as_ref() { + Some(nc) => nc, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let drive_id = match parse_form_value(&body, "drive") { + Some(v) if !v.is_empty() => v, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + + let user_id = match nextcloud.login_flow.take_pending_user(&token) { + Some(uid) => uid, + None => { + tracing::warn!( + target: "audit", + event = "nc_login_flow.drive_pick_rejected", + reason = "no_pending_user", + "👮🏻‍♂️ NC drive pick rejected: flow has no pending user (replay or unknown token)" + ); + return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired") + .into_response(); + } + }; + + // Resolve user (for username) and validate drive ownership in one + // service call each. `get_folder_with_perms` enforces that the + // caller can read the folder — covers "drive doesn't exist" and + // "drive belongs to someone else" with the same 404 to defeat + // enumeration. We additionally need to differentiate home vs. + // non-home so the NC login name carries `~{uuid}` only for + // non-home choices. + let auth = match state.auth_service.as_ref() { + Some(a) => a, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + let user_dto = match auth.auth_application_service.get_user_by_id(user_id).await { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, %user_id, "Login Flow v2: failed to fetch user for drive pick"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + // Username must be present — only password-login users reach this + // branch, and password login requires a claimed username. Defensive + // check anyway: a username-less user here means an upstream invariant + // broke, not something to silently paper over. + let Some(username) = user_dto.username.clone() else { + tracing::error!(%user_id, "Login Flow v2: pending user has no username — invariant violated"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let user = CurrentUser { + id: user_id, + username, + email: user_dto.email.clone(), + role: user_dto.role.clone(), + }; + + let _folder = match state + .applications + .folder_service + .get_folder_with_perms(&drive_id, user_id) + .await + { + Ok(f) => f, + Err(_) => { + tracing::warn!( + target: "audit", + event = "nc_login_flow.drive_pick_rejected", + reason = "drive_not_owned_or_missing", + %user_id, + drive_id = %drive_id, + "👮🏻‍♂️ NC drive pick rejected: folder missing or caller has no read access" + ); + return StatusCode::NOT_FOUND.into_response(); + } + }; + + // Determine if the pick is home. The previous "first row of + // list_folders_with_perms" heuristic was wrong: the underlying + // repo query orders by `name`, so any drive named alphabetically + // before "My Folder - {username}" stole the first slot and was + // mis-classified as home — `login_name` then dropped the `~uuid` + // marker and NC desktop rooted at the home folder regardless of + // the user's pick. `find_home_index` keys off the registered + // home-folder name, which extra drives (POC SQL-seeded) don't + // share, so it disambiguates cleanly. + let drives = match state + .applications + .folder_service + .list_folders_with_perms(None, user_id) + .await + { + Ok(d) => d, + Err(e) => { + tracing::error!(error = %e, %user_id, "Login Flow v2: failed to list drives for home detection"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let home_id = crate::domain::repositories::drive_repository::position_of_user_home_root_folder( + state.drive_repo.as_ref(), + user.id, + &drives, + |f| uuid::Uuid::parse_str(&f.id).ok(), + ) + .await + .map(|i| drives[i].id.as_str()); + let is_home = home_id == Some(drive_id.as_str()); + let drive_marker = if is_home { + None + } else { + Some(drive_id.as_str()) + }; + + complete_flow(&state, &nextcloud.login_flow, &token, &user, drive_marker).await +} + /// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is /// tied to a Nextcloud Login Flow v2 session. After successful IdP /// authentication the regular `/api/auth/oidc/callback` endpoint will detect diff --git a/src/interfaces/nextcloud/mod.rs b/src/interfaces/nextcloud/mod.rs index 04ed0330..52eb8153 100644 --- a/src/interfaces/nextcloud/mod.rs +++ b/src/interfaces/nextcloud/mod.rs @@ -5,6 +5,7 @@ pub mod ocs_handler; pub mod preview_handler; pub mod report_handler; pub mod routes; +pub mod session; pub mod status_handler; pub mod trashbin_handler; pub mod uploads_handler; diff --git a/src/interfaces/nextcloud/ocs_handler.rs b/src/interfaces/nextcloud/ocs_handler.rs index 6c86fb8d..e673288b 100644 --- a/src/interfaces/nextcloud/ocs_handler.rs +++ b/src/interfaces/nextcloud/ocs_handler.rs @@ -46,9 +46,12 @@ pub async fn handle_capabilities_v2(State(state): State>) -> Respo Json(payload).into_response() } -pub async fn handle_user_info(State(state): State>, user: AuthUser) -> Response { +pub async fn handle_user_info( + State(state): State>, + session: crate::interfaces::nextcloud::session::NcSession, +) -> Response { let quota: (i64, i64) = match state.storage_usage_service.as_ref() { - Some(service) => match service.get_user_storage_info(user.id).await { + Some(service) => match service.get_user_storage_info(session.user.id).await { Ok((used, total)) => (used, total), Err(_) => (0, 0), }, @@ -62,15 +65,36 @@ pub async fn handle_user_info(State(state): State>, user: AuthUser 0.0 }; + // `id` MUST echo the raw wire username the client used at Basic + // Auth time — NC desktop reads `data.id` from this endpoint and + // splices it into every subsequent WebDAV path it builds + // (`/remote.php/dav/files/{id}/…`). Returning the bare canonical + // username on a `~{uuid}` session would make the client strip + // the marker and revert to the home drive. + // + // Display fields stay short on the default drive (bare + // username); on a marker session we render `username@` + // using the resolved chroot's stored name, which is friendlier + // than the raw UUID the wire form carries. + let id = session.raw_username.clone(); + let displayname = if session.is_home() { + session.user.username.clone() + } else { + match session.chroot.as_ref() { + Some(chroot) => format!("{}@{}", session.user.username, chroot.name), + None => session.user.username.clone(), + } + }; + Json(json!({ "ocs": { "meta": { "status": "ok", "statuscode": 200, "message": "OK" }, "data": { "enabled": true, - "id": user.username, - "display-name": user.username, - "displayname": user.username, - "email": user.email, + "id": id, + "display-name": displayname, + "displayname": displayname, + "email": session.user.email, "quota": { "used": quota.0, "total": quota.1, @@ -399,11 +423,12 @@ pub async fn handle_search( let mut entries: Vec = Vec::new(); // Map file results + // TODO(D1): drop the hardcoded "Personal/" prefix and read the + // caller's default-drive root folder name from `drives.root_folder_id` + // instead. Correct for D0-provisioned default drives; secondary + // drives keep their original root name. for file in &results.files { - let display_path = file - .path - .strip_prefix(&format!("My Folder - {}/", user.username)) - .unwrap_or(&file.path); + let display_path = file.path.strip_prefix("Personal/").unwrap_or(&file.path); let display_path = format!("/{}", display_path); let numeric_id = file_id_map.get(&file.id).copied(); @@ -427,11 +452,11 @@ pub async fn handle_search( })); } - // Map folder results + // Map folder results — same TODO(D1) as above. for folder in &results.folders { let display_path = folder .path - .strip_prefix(&format!("My Folder - {}/", user.username)) + .strip_prefix("Personal/") .unwrap_or(&folder.path); let display_path = format!("/{}", display_path); diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 9e056d0b..01d37b05 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -22,7 +22,6 @@ use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::domain::entities::file::File; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::CurrentUser; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response, }; @@ -35,7 +34,7 @@ use crate::interfaces::nextcloud::webdav_handler::{ pub async fn handle_nc_report( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, _subpath: &str, ) -> Result, AppError> { let body_bytes = body::to_bytes(req.into_body(), 64 * 1024) @@ -45,9 +44,9 @@ pub async fn handle_nc_report( let body_str = String::from_utf8_lossy(&body_bytes); if body_str.contains("filter-files") { - handle_filter_files(state, &body_str, user).await + handle_filter_files(state, &body_str, session).await } else if body_str.contains("searchrequest") { - handle_search(state, &body_str, user).await + handle_search(state, &body_str, session).await } else { // Unknown REPORT type -- return empty multistatus. Ok(empty_multistatus()) @@ -59,8 +58,10 @@ pub async fn handle_nc_report( async fn handle_filter_files( state: Arc, _body: &str, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; + let url_user = &session.raw_username; let fav_svc = match state.favorites_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), @@ -83,7 +84,11 @@ async fn handle_filter_files( // All items in this response are favorites. let favorite_ids: HashSet = favorites.iter().map(|f| f.item_id.clone()).collect(); - let home_prefix = format!("My Folder - {}/", user.username); + // TODO(D1): replace the hardcoded "Personal/" prefix with the + // caller's default-drive root folder name read from + // `drives.root_folder_id`. Correct for D0-provisioned default + // drives; secondary drives keep their original root name. + let home_prefix = "Personal/"; // Pass 1: resolve the favorited DTOs in two batch queries (was one // get_* per favorite — up to N serial round-trips on a sync client's @@ -145,9 +150,13 @@ async fn handle_filter_files( write_multistatus_start(&mut xml)?; + // Keep main's batched-resolution structure (one batch query + // per type, not 2N round-trips). Hrefs use `url_user` so the + // multi-drive `~{drive}` form is echoed back to the client; + // owner-id stays canonical via `&user.username`. for file in &files { - let subpath = strip_home_prefix(&file.path, &home_prefix); - let href = nc_href(&user.username, subpath); + let subpath = strip_home_prefix(&file.path, home_prefix); + let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -163,8 +172,8 @@ async fn handle_filter_files( } for folder in &folders { - let subpath = strip_home_prefix(&folder.path, &home_prefix); - let href = format!("{}/", nc_href(&user.username, subpath)); + let subpath = strip_home_prefix(&folder.path, home_prefix); + let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( @@ -195,8 +204,13 @@ async fn handle_filter_files( async fn handle_search( state: Arc, body: &str, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; + // Validate chroot up-front (path-scoped handler); `resolve_scope_folder` + // below re-pulls it from the session for the path-mapping step. + session.require_chroot()?; + let url_user = &session.raw_username; let search_svc = match state.applications.search_service.as_ref() { Some(svc) => svc, None => return Ok(empty_multistatus()), @@ -210,7 +224,7 @@ async fn handle_search( let nresults = parse_nresults(body).unwrap_or(100); // Resolve folder scope from inside . - let folder_id = resolve_scope_folder(&state, body, &user.username).await; + let folder_id = resolve_scope_folder(&state, body, session).await; let criteria = SearchCriteriaDto { name_contains: Some(term), @@ -227,7 +241,10 @@ async fn handle_search( let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); - let home_prefix = format!("My Folder - {}/", user.username); + // TODO(D1): same as the favorites pass above — replace the + // hardcoded "Personal/" with the caller's actual default-drive + // root folder name from `drives.root_folder_id`. + let home_prefix = "Personal/"; // No favorite checking for search results -- pass an empty set. let favorite_ids: HashSet = HashSet::new(); @@ -249,8 +266,8 @@ async fn handle_search( // Files. for file in &files { - let subpath = strip_home_prefix(&file.path, &home_prefix); - let href = nc_href(&user.username, subpath); + let subpath = strip_home_prefix(&file.path, home_prefix); + let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -267,8 +284,8 @@ async fn handle_search( // Folders. for folder in &folders { - let subpath = strip_home_prefix(&folder.path, &home_prefix); - let href = format!("{}/", nc_href(&user.username, subpath)); + let subpath = strip_home_prefix(&folder.path, home_prefix); + let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); write_folder_response( @@ -327,6 +344,9 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes sort_date: None, content_hash: fr.blob_hash.clone(), etag, + // §14 provenance not selected by the search result DTO. + created_by: None, + updated_by: None, } } @@ -341,12 +361,19 @@ fn folder_dto_from_search( path: sr.path.clone(), parent_id: sr.parent_id.clone(), owner_id: None, + // Search result — drive_id is informational. The search row + // doesn't currently SELECT it, and path-based lookups never + // enter this code path. + drive_id: uuid::Uuid::nil(), created_at: sr.created_at, modified_at: sr.modified_at, is_root: sr.is_root, icon_class: Arc::from("fas fa-folder"), icon_special_class: Arc::from("folder-icon"), category: Arc::from("Folder"), + // §14 provenance not selected by search results. + created_by: None, + updated_by: None, } } @@ -461,23 +488,39 @@ fn xml_extract_text(body: &str, local_name: &[u8]) -> Option { } /// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID. -async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option { +/// +/// Pulls everything it needs from the `NcSession`: the caller's id (so +/// `get_folder_by_path` can be user-scoped — post-D0 paths like +/// `Personal/Docs` are not globally unique), the chroot (provides the +/// path prefix that `nc_to_internal_path` prepends), and the raw wire +/// `{user}` segment (bare or `admin~{uuid}`) so we strip the prefix the +/// NC client actually sent. +async fn resolve_scope_folder( + state: &AppState, + body: &str, + session: &crate::interfaces::nextcloud::session::NcSession, +) -> Option { + let chroot = session.require_chroot().ok()?; + let url_user = &session.raw_username; let href = parse_scope_href(body)?; - // The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`. - let subpath = extract_subpath_from_scope(&href, username)?; + // The href is typically `/files/{url_user}/subpath` or + // `/remote.php/dav/files/{url_user}/subpath`. On a multi-drive + // session the `{url_user}` segment carries the `~{uuid}` marker, + // so we strip with the composite to find the real subpath. Using + // `user.username` here would fail to match for non-home drives. + let subpath = extract_subpath_from_scope(&href, url_user)?; if subpath.is_empty() { // Root scope -- no folder_id filter needed. return None; } let internal_path = - crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath) - .ok()?; + crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(chroot, &subpath).ok()?; let folder_service = &state.applications.folder_service; folder_service - .get_folder_by_path(&internal_path) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .ok() .map(|f| f.id) @@ -486,13 +529,16 @@ async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> O /// Extract the subpath portion from a scope href. /// /// Handles both short form `/files/{user}/sub` and full -/// `/remote.php/dav/files/{user}/sub`. -fn extract_subpath_from_scope(href: &str, username: &str) -> Option { +/// `/remote.php/dav/files/{user}/sub`. `url_user` is the literal URL +/// `{user}` segment — bare for legacy single-drive sync, composite +/// `admin~{uuid}` for multi-drive — so this matches whichever shape +/// the NC client actually sent. +fn extract_subpath_from_scope(href: &str, url_user: &str) -> Option { let patterns = [ - format!("/remote.php/dav/files/{}/", username), - format!("/files/{}/", username), - format!("/remote.php/dav/files/{}", username), - format!("/files/{}", username), + format!("/remote.php/dav/files/{}/", url_user), + format!("/files/{}/", url_user), + format!("/remote.php/dav/files/{}", url_user), + format!("/files/{}", url_user), ]; for pat in &patterns { diff --git a/src/interfaces/nextcloud/routes.rs b/src/interfaces/nextcloud/routes.rs index 3e94b557..6ff08867 100644 --- a/src/interfaces/nextcloud/routes.rs +++ b/src/interfaces/nextcloud/routes.rs @@ -10,13 +10,14 @@ use axum::{ use std::sync::Arc; use crate::common::di::AppState; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; +use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::rate_limit::{RateLimiter, rate_limit_login}; use crate::interfaces::nextcloud::avatar_handler; use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware; use crate::interfaces::nextcloud::login_v2_handler; use crate::interfaces::nextcloud::ocs_handler; use crate::interfaces::nextcloud::preview_handler; +use crate::interfaces::nextcloud::session::NcSession; use crate::interfaces::nextcloud::status_handler; use crate::interfaces::nextcloud::trashbin_handler; use crate::interfaces::nextcloud::uploads_handler; @@ -58,6 +59,14 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router rate_limit_login, )), ) + // Drive picker submission — finalises a multi-drive flow that + // paused after password verification. Public route by design: + // the flow token + single-use `pending_user_id` slot is the + // proof of authentication. See `login_v2_handler::handle_drive_pick`. + .route( + "/login/v2/flow/{token}/drive", + post(login_v2_handler::handle_drive_pick), + ) // OIDC initiation from Nextcloud login page .route( "/login/v2/flow/{token}/oidc", @@ -204,60 +213,46 @@ pub fn nextcloud_routes_with_state(state: Arc) -> Router // ──────────────── Handler glue ──────────────── -/// Reject requests where the URL `{user}` doesn't match the authenticated user. -#[allow(clippy::result_large_err)] -fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> { - if url_user != auth_user.username { - Err(StatusCode::FORBIDDEN.into_response()) - } else { - Ok(()) - } -} - async fn handle_dav_files( State(state): State>, - Path((url_user, subpath)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, subpath)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - webdav_handler::handle_nc_webdav(state, req, user_ext, subpath) + webdav_handler::handle_nc_webdav(state, req, session, subpath) .await .map_err(|e| e.into_response()) } async fn handle_dav_files_root( State(state): State>, - Path(url_user): Path, - user_ext: AuthUser, + Path(_url_user): Path, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - webdav_handler::handle_nc_webdav(state, req, user_ext, String::new()) + webdav_handler::handle_nc_webdav(state, req, session, String::new()) .await .map_err(|e| e.into_response()) } async fn handle_dav_uploads( State(state): State>, - Path((url_user, upload_id, rest)): Path<(String, String, String)>, - user_ext: AuthUser, + Path((_url_user, upload_id, rest)): Path<(String, String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, rest) + uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest) .await .map_err(|e| e.into_response()) } async fn handle_dav_uploads_root( State(state): State>, - Path((url_user, upload_id)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, upload_id)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - uploads_handler::handle_nc_uploads(state, req, user_ext, upload_id, String::new()) + uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new()) .await .map_err(|e| e.into_response()) } @@ -283,24 +278,22 @@ async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response { async fn handle_dav_trashbin( State(state): State>, - Path((url_user, subpath)): Path<(String, String)>, - user_ext: AuthUser, + Path((_url_user, subpath)): Path<(String, String)>, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath) + trashbin_handler::handle_nc_trashbin(state, req, session, subpath) .await .map_err(|e| e.into_response()) } async fn handle_dav_trashbin_root( State(state): State>, - Path(url_user): Path, - user_ext: AuthUser, + Path(_url_user): Path, + session: NcSession, req: Request, ) -> Result { - verify_url_user(&url_user, &user_ext)?; - trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new()) + trashbin_handler::handle_nc_trashbin(state, req, session, String::new()) .await .map_err(|e| e.into_response()) } diff --git a/src/interfaces/nextcloud/session.rs b/src/interfaces/nextcloud/session.rs new file mode 100644 index 00000000..2e48f198 --- /dev/null +++ b/src/interfaces/nextcloud/session.rs @@ -0,0 +1,134 @@ +//! Per-request NextCloud session context. +//! +//! Bundles WHO the caller is, the raw wire username they presented, +//! and (for path-scoped endpoints) WHERE they're confined to. Built +//! by `basic_auth_middleware` and stashed in request extensions as +//! `Arc`; handlers extract it via the [`FromRequestParts`] +//! impl below — just declare `session: NcSession` in the signature. +//! +//! ## Source of truth +//! +//! - `user`: authenticated identity (id, canonical username, role). +//! - `raw_username`: the opaque wire identifier from the Basic Auth +//! header. Today: plain `user` (single-drive) or `user~{drive_uuid}` +//! (multi-drive POC). May look different again when future auth +//! schemes land. **Handlers MUST NOT parse it** — it's used verbatim +//! only for echoing back into DAV/OCS URLs the client expects to +//! see (notably OCS `cloud/user`'s `id` field, which NC desktop +//! splices into every subsequent DAV path it builds) and for +//! audit logs. +//! - `chroot`: folder the request is jailed inside. `Some` for every +//! authenticated NC request today (the home folder when no drive +//! marker is present, or the resolved drive when one is). `None` +//! is reserved for future routes that don't operate on a single +//! folder (admin / cross-drive queries). +//! +//! ## Why this lives in middleware, not routes.rs +//! +//! The auth step already has every input needed (raw username from +//! header + drive marker after `~` + authenticated user). Resolving +//! the chroot there means every NC handler — DAV, OCS, uploads, +//! trashbin, sharees, … — gets a uniform `NcSession` regardless of +//! whether its URL carries a `{user}` segment. The URL `{user}` +//! segment becomes informational; the auth header is canonical. + +use std::sync::Arc; + +use axum::{ + extract::FromRequestParts, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; + +use crate::application::dtos::folder_dto::FolderDto; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUser; + +#[derive(Debug, Clone)] +pub struct NcSession { + pub user: CurrentUser, + pub raw_username: String, + pub chroot: Option, +} + +impl NcSession { + /// Return the chroot, or 500 if a path-scoped handler is reached + /// without one. Documents the invariant that every NC route + /// today is path-scoped — if this fires, route wiring is wrong. + pub fn require_chroot(&self) -> Result<&FolderDto, AppError> { + self.chroot.as_ref().ok_or_else(|| { + AppError::internal_error( + "NcSession: path-scoped handler reached without a chroot — route wiring bug", + ) + }) + } + + /// True when the session is scoped to the user's home folder + /// (no drive marker in the Basic Auth username). Useful for + /// handlers that want to render a friendlier display when the + /// user is on their default drive. + pub fn is_home(&self) -> bool { + !self.raw_username.contains('~') + } +} + +/// Pull the `{user}` segment out of a NC DAV URL. +/// +/// Expected URL shapes: +/// - `/remote.php/dav/files/{user}` (root) +/// - `/remote.php/dav/files/{user}/{*subpath}` +/// - `/remote.php/dav/uploads/{user}/{upload_id}[/{*rest}]` +/// - `/remote.php/dav/trashbin/{user}[/{*subpath}]` +/// +/// Returns `None` for anything that doesn't follow this shape (notably +/// the OCS surfaces, where there is no `{user}` segment to compare). +fn extract_url_user(path: &str) -> Option { + let mut segments = path.split('/'); + if !segments.next()?.is_empty() { + return None; + } + if segments.next()? != "remote.php" { + return None; + } + if segments.next()? != "dav" { + return None; + } + let _surface = segments.next()?; // files / uploads / trashbin + let user_seg = segments.next()?; + if user_seg.is_empty() { + return None; + } + urlencoding::decode(user_seg).ok().map(|s| s.into_owned()) +} + +/// Axum extractor: pulls the `Arc` that +/// `basic_auth_middleware` stashed in request extensions and clones +/// it (cheap — one `Arc` increment, no field copy) into an owned +/// `NcSession` for handler use. +/// +/// On path-scoped DAV routes (`/remote.php/dav/{files,uploads, +/// trashbin}/{user}/…`), the URL `{user}` segment is cross-checked +/// against `session.raw_username` and 403'd on mismatch. This is a +/// consistency check, NOT a security boundary — the chroot ACL +/// (`get_folder_with_perms`) is what actually prevents cross-user +/// access. It just surfaces malformed requests early (403) instead +/// of silently letting them through. +impl FromRequestParts for NcSession { + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let session = parts + .extensions + .get::>() + .map(|arc| (**arc).clone()) + .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + + if let Some(url_user) = extract_url_user(parts.uri.path()) + && url_user != session.raw_username + { + return Err(StatusCode::FORBIDDEN.into_response()); + } + + Ok(session) + } +} diff --git a/src/interfaces/nextcloud/trashbin_handler.rs b/src/interfaces/nextcloud/trashbin_handler.rs index 8799354b..6a7077ed 100644 --- a/src/interfaces/nextcloud/trashbin_handler.rs +++ b/src/interfaces/nextcloud/trashbin_handler.rs @@ -14,7 +14,6 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::common::di::AppState; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path, write_text_element, @@ -28,7 +27,7 @@ const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); pub async fn handle_nc_trashbin( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, subpath: String, ) -> Result, AppError> { let method = req.method().clone(); @@ -37,21 +36,25 @@ pub async fn handle_nc_trashbin( match method.as_str() { "OPTIONS" => handle_options(), "PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => { - handle_propfind(state, &user).await + handle_propfind(state, &session).await } "MOVE" if subpath_trimmed.starts_with("trash/") => { + // Keep the destination-collision-check feature added on HEAD + // (RFC 4918 §9.9.4: refuse restore with 412 when the + // destination is taken by a live resource). The chroot lookup + // moves into `handle_restore` via the session. let dest_header = req .headers() .get("destination") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - handle_restore(state, dest_header, &user, subpath_trimmed).await + handle_restore(state, dest_header, &session, subpath_trimmed).await } "DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => { - handle_empty_trash(state, &user).await + handle_empty_trash(state, &session).await } "DELETE" if subpath_trimmed.starts_with("trash/") => { - handle_delete_permanent(state, &user, subpath_trimmed).await + handle_delete_permanent(state, &session, subpath_trimmed).await } _ => Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) @@ -75,8 +78,9 @@ fn handle_options() -> Result, AppError> { async fn handle_propfind( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; let trash_svc = state .trash_service .as_ref() @@ -107,9 +111,11 @@ async fn handle_propfind( async fn handle_restore( state: Arc, dest_header: Option, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; let id = extract_trash_id(subpath)?; let trash_svc = state @@ -128,12 +134,15 @@ async fn handle_restore( if let Some(dest_header) = dest_header && let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username) { - let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?; + let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; - let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok() + let dest_taken = file_service + .get_file_by_path(&dest_internal, chroot.drive_id) + .await + .is_ok() || folder_service - .get_folder_by_path(&dest_internal) + .get_folder_by_path(&dest_internal, chroot.drive_id) .await .is_ok(); if dest_taken { @@ -184,8 +193,9 @@ async fn handle_restore( async fn handle_empty_trash( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, ) -> Result, AppError> { + let user = &session.user; let trash_svc = state .trash_service .as_ref() @@ -206,9 +216,10 @@ async fn handle_empty_trash( async fn handle_delete_permanent( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; let id = extract_trash_id(subpath)?; let trash_svc = state @@ -248,11 +259,18 @@ fn mime_from_name(name: &str) -> String { .to_string() } -/// Strip the "My Folder - {username}/" prefix from an original path to produce -/// the Nextcloud-relative original location. -fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str { - let prefix = format!("My Folder - {}/", username); - original_path.strip_prefix(&prefix).unwrap_or(original_path) +/// Strip the home-folder prefix from an original path to produce the +/// Nextcloud-relative original location. +/// +/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual +/// default-drive root folder name read from `drives.root_folder_id`. +/// Correct for D0-provisioned default drives; secondary drives keep +/// their original root name. The `_username` arg stays for now so the +/// upcoming dynamic lookup has a way to identify the caller. +fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str { + original_path + .strip_prefix("Personal/") + .unwrap_or(original_path) } // ────────────── Trashbin PROPFIND XML Generation ────────────── diff --git a/src/interfaces/nextcloud/uploads_handler.rs b/src/interfaces/nextcloud/uploads_handler.rs index 16dd5b07..e96fe674 100644 --- a/src/interfaces/nextcloud/uploads_handler.rs +++ b/src/interfaces/nextcloud/uploads_handler.rs @@ -9,7 +9,6 @@ use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseC use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::upload_ingest::{ discard_ingested, ingest_stream_to_cas, stream_body_to_path, stream_from_files, }; @@ -25,17 +24,17 @@ use crate::interfaces::upload_ingest::{ pub async fn handle_nc_uploads( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, upload_id: String, rest: String, // chunk name or ".file" or empty ) -> Result, AppError> { let method = req.method().clone(); match method.as_str() { - "MKCOL" => handle_mkcol(state, &user, &upload_id).await, - "PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await, - "MOVE" => handle_assemble(state, req, &user, &upload_id).await, - "DELETE" => handle_abort(state, &user, &upload_id).await, - "PROPFIND" => handle_propfind_session(state, &user, &upload_id).await, + "MKCOL" => handle_mkcol(state, &session, &upload_id).await, + "PUT" => handle_put_chunk(state, req, &session, &upload_id, &rest).await, + "MOVE" => handle_assemble(state, req, &session, &upload_id).await, + "DELETE" => handle_abort(state, &session, &upload_id).await, + "PROPFIND" => handle_propfind_session(state, &session, &upload_id).await, _ => Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .body(Body::empty()) @@ -60,9 +59,10 @@ pub async fn handle_nc_uploads( /// which matches NC server behaviour. async fn handle_propfind_session( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -147,9 +147,10 @@ fn xml_escape(s: &str) -> String { /// MKCOL — create upload session directory. async fn handle_mkcol( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -178,10 +179,11 @@ async fn handle_mkcol( async fn handle_put_chunk( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, chunk_name: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -216,9 +218,10 @@ async fn handle_put_chunk( async fn handle_assemble( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() @@ -256,11 +259,19 @@ async fn handle_assemble( let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; - let internal_path = format!( - "My Folder - {}/{}", - user.username, - dest_subpath.trim_matches('/') - ); + // Path-based lookups below scope by `drive_id`. The NC session's + // chroot is always populated for path-scoped handlers (see + // `NcSession::require_chroot`); the FolderDto carries `drive_id` + // post-D0. + let chroot = session.require_chroot()?; + let drive_id = chroot.drive_id; + + // TODO(D1): read the caller's default-drive root folder name from + // `drives.root_folder_id` instead of hardcoding "Personal". The + // constant is correct for every default personal drive provisioned + // by the D0 lifecycle hook, but secondary drives (M2 backfill from + // SQL-created sibling root folders) keep their original name. + let internal_path = format!("Personal/{}", dest_subpath.trim_matches('/')); let filename = filename_from_path(&dest_subpath).to_string(); let ingested = ingest_stream_to_cas( @@ -275,11 +286,20 @@ async fn handle_assemble( let content_type = ingested.content_type.clone(); // Check if file exists (update vs create). - let existing = file_service.get_file_by_path(&internal_path).await; + let existing = file_service + .get_file_by_path(&internal_path, drive_id) + .await; let etag: Option = if existing.is_ok() { let dto = upload_service - .update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime) + .update_file_streaming( + &internal_path, + drive_id, + ingested.stored(), + &content_type, + oc_mtime, + user.id, + ) .await .map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?; @@ -291,15 +311,14 @@ async fn handle_assemble( Some((p, n)) => (p, n), None => ("", dest_subpath.as_str()), }; - let parent_internal = format!( - "My Folder - {}/{}", - user.username, - parent_sub.trim_matches('/') - ); + let parent_internal = format!("Personal/{}", parent_sub.trim_matches('/')); let parent_internal = parent_internal.trim_end_matches('/'); use crate::application::ports::folder_ports::FolderUseCase; - let parent_folder = match folder_service.get_folder_by_path(parent_internal).await { + let parent_folder = match folder_service + .get_folder_by_path(parent_internal, drive_id) + .await + { Ok(folder) => folder, Err(e) => { discard_ingested(&state.core.dedup_service, &ingested).await; @@ -316,6 +335,7 @@ async fn handle_assemble( Some(parent_folder.id), content_type.to_string(), ingested.stored(), + user.id, ) .await .map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?; @@ -344,9 +364,10 @@ async fn handle_assemble( /// DELETE — abort an upload session. async fn handle_abort( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, upload_id: &str, ) -> Result, AppError> { + let user = &session.user; let nc = state .nextcloud .as_ref() diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 135872cb..05e3f060 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -25,7 +25,6 @@ use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; use crate::interfaces::errors::AppError; -use crate::interfaces::middleware::auth::{AuthUser, CurrentUser}; use crate::interfaces::range_requests::{not_modified_response, range_response}; use crate::interfaces::upload_ingest::ingest_body_to_cas; @@ -47,23 +46,35 @@ fn timestamp_to_i64(ts: u64) -> i64 { const HEADER_DAV: HeaderName = HeaderName::from_static("dav"); -/// Resolve the internal OxiCloud path from a Nextcloud DAV subpath. +/// Resolve the internal OxiCloud path from a NextCloud DAV subpath +/// and the storage chroot the request is confined to. /// -/// Nextcloud: /remote.php/dav/files/{user}/{subpath} -/// Internal: My Folder - {username}/{subpath} +/// `chroot` is the storage path the request is "jailed" inside — +/// the route glue (`routes.rs::handle_dav_*`) computes it once per +/// request: +/// - Legacy `/files/{user}/…` or explicit `~{home_folder_uuid}` → +/// `"My Folder - {username}"` (no DB lookup needed). +/// - `~{some_other_folder_uuid}` → the folder's stored `path` after +/// a `get_folder_with_perms` check (404 if missing / no access). /// -/// An empty subpath maps to the user's home folder root. -pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result { - let home = format!("My Folder - {}", username); +/// By the time we get here `chroot` is known to be a legitimate +/// target — validation and permission live in the route layer, not +/// in the path mapper. This function stays sync and free of any +/// folder-service handle. The chroot's `path` is the canonical root +/// segment (e.g. `"Personal"` for default personal drives provisioned +/// by D0, the original sibling-root folder name for secondary drives). +/// Replaces the pre-D0 hardcoded `"My Folder - {username}/"` prefix. +pub fn nc_to_internal_path(chroot: &FolderDto, subpath: &str) -> Result { let subpath = subpath.trim_matches('/'); if subpath.is_empty() { - return Ok(home); + return Ok(chroot.path.clone()); } // Reject path traversal attempts. if subpath.split('/').any(|seg| seg == ".." || seg == ".") { return Err(AppError::bad_request("Invalid path: traversal not allowed")); } - Ok(format!("{}/{}", home, subpath)) + + Ok(format!("{}/{}", chroot.path, subpath)) } /// Build the Nextcloud DAV href for a **collection** (folder). Always @@ -113,26 +124,42 @@ pub fn nc_href(username: &str, subpath: &str) -> String { /// Dispatch Nextcloud WebDAV request to the appropriate handler. /// /// `subpath` is everything after `/remote.php/dav/files/{user}/`. +/// `session.chroot` is the storage path the request is confined to +/// — see [`nc_to_internal_path`] for what gets resolved upstream. +/// `session.raw_username` is the literal wire identifier — bare +/// `admin` for single-drive sync, composite `admin~{drive_uuid}` for +/// multi-drive. **Hrefs in every response MUST be built from +/// `session.raw_username`, not from `session.user.username`** — the +/// NC desktop client validates that PROPFIND/MOVE response hrefs +/// share the requested URL's prefix and aborts the parse otherwise +/// (`Invalid href "<…>" expected starting with ""`). +/// The bare `session.user.username` is still the right value for +/// the storage-side owner identity (`oc:owner-id`). pub async fn handle_nc_webdav( state: Arc, req: Request, - user: AuthUser, + session: crate::interfaces::nextcloud::session::NcSession, subpath: String, ) -> Result, AppError> { + // Validate up-front that we have a chroot — every method below is + // path-scoped, so a missing chroot is a route-wiring bug we want to + // surface as a 500 immediately rather than re-checking inside each + // handler. + session.require_chroot()?; let method = req.method().clone(); match method.as_str() { "OPTIONS" => handle_options(), - "GET" => handle_get(state, &user, &subpath, req.headers()).await, - "PROPFIND" => handle_propfind(state, req, &user, &subpath).await, - "PUT" => handle_put(state, req, &user, &subpath).await, - "MKCOL" => handle_mkcol(state, &user, &subpath).await, - "DELETE" => handle_delete(state, &user, &subpath).await, - "MOVE" => handle_move(state, req, &user, &subpath).await, - "HEAD" => handle_head(state, &user, &subpath).await, - "PROPPATCH" => handle_proppatch(state, req, &user, &subpath).await, + "PROPFIND" => handle_propfind(state, req, &session, &subpath).await, + "GET" => handle_get(state, &session, &subpath, req.headers()).await, + "PUT" => handle_put(state, req, &session, &subpath).await, + "MKCOL" => handle_mkcol(state, &session, &subpath).await, + "DELETE" => handle_delete(state, &session, &subpath).await, + "MOVE" => handle_move(state, req, &session, &subpath).await, + "HEAD" => handle_head(state, &session, &subpath).await, + "PROPPATCH" => handle_proppatch(state, req, &session, &subpath).await, "REPORT" | "SEARCH" => { crate::interfaces::nextcloud::report_handler::handle_nc_report( - state, req, &user, &subpath, + state, req, &session, &subpath, ) .await } @@ -171,9 +198,12 @@ fn handle_options() -> Result, AppError> { async fn handle_propfind( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let depth = req .headers() .get("depth") @@ -198,29 +228,41 @@ async fn handle_propfind( .map_err(|e| AppError::bad_request(format!("Invalid PROPFIND XML: {}", e)))? }; - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; + let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; // Try to resolve as folder first. - let folder_result = folder_service.get_folder_by_path(&internal_path).await; + let folder_result = folder_service + .get_folder_by_path(&internal_path, chroot.drive_id) + .await; if let Ok(folder) = folder_result { // It's a folder — stream the multistatus: children are fetched in // pages and serialized chunk by chunk, so memory stays O(batch) // regardless of how many entries the folder holds. + // + // Multi-drive POC: the hrefs in the response must echo the + // wire form (`{user}~{drive}`) the client requested, so we + // pass `url_user` (not `user.username`) as the streaming + // function's username arg. Refining the owner-id usages + // back to the canonical username is deferred to the + // NcSession commit. return Ok(build_nc_streaming_propfind( state.clone(), folder, depth, user.id, - user.username.clone(), + url_user.to_string(), subpath.to_string(), )); } // Not a folder — try as a file. - let file_result = file_service.get_file_by_path(&internal_path).await; + let file_result = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await; if let Ok(file) = file_result { // Batch-check favorites for this single file. let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() { @@ -240,6 +282,7 @@ async fn handle_propfind( write_nc_file_multistatus( &mut buf, &file, + url_user, &user.username, subpath, file_id_svc, @@ -262,10 +305,11 @@ async fn handle_propfind( async fn handle_get( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, headers: &axum::http::HeaderMap, ) -> Result, AppError> { + let chroot = session.require_chroot()?; // GET on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { return Ok(Response::builder() @@ -275,13 +319,13 @@ async fn handle_get( .unwrap()); } - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; // Check if path is a folder first (NC clients use GET as existence check) if folder_service - .get_folder_by_path(&internal_path) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -293,7 +337,7 @@ async fn handle_get( } let file = file_service - .get_file_by_path(&internal_path) + .get_file_by_path(&internal_path, chroot.drive_id) .await .map_err(|_| AppError::not_found("File not found"))?; @@ -340,9 +384,10 @@ async fn handle_get( async fn handle_head( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let chroot = session.require_chroot()?; // HEAD on root folder — NC clients use this as an existence check if subpath.is_empty() || subpath == "/" { return Ok(Response::builder() @@ -352,13 +397,13 @@ async fn handle_head( .unwrap()); } - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; // Check if path is a folder (NC clients use HEAD as existence check) if folder_service - .get_folder_by_path(&internal_path) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -370,7 +415,7 @@ async fn handle_head( } let file = file_service - .get_file_by_path(&internal_path) + .get_file_by_path(&internal_path, chroot.drive_id) .await .map_err(|_| AppError::not_found("File not found"))?; @@ -404,9 +449,12 @@ async fn handle_head( async fn handle_proppatch( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let body_bytes = body::to_bytes(req.into_body(), 64 * 1024) .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; @@ -428,12 +476,18 @@ async fn handle_proppatch( // PROPPATCH path (no favorite directive in the body) — matches // the prior behaviour. A PROPPATCH that *does* try to set // favorite on a missing resource still returns NotFound. - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let folder_service = &state.applications.folder_service; - let resource = if let Ok(file) = file_service.get_file_by_path(&internal_path).await { + let resource = if let Ok(file) = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + { Some((file.id, "file")) - } else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await { + } else if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, chroot.drive_id) + .await + { Some((folder.id, "folder")) } else { None @@ -472,9 +526,9 @@ async fn handle_proppatch( // type to satisfy the RFC 4918 §5.2 trailing-slash invariant — // see the comment block at the top of this function. let href = if is_collection { - nc_collection_href(&user.username, subpath) + nc_collection_href(url_user, subpath) } else { - nc_href(&user.username, subpath) + nc_href(url_user, subpath) }; let mut buf = Vec::new(); { @@ -610,10 +664,11 @@ fn precondition_failed_response() -> Response { async fn handle_put( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let chroot = session.require_chroot()?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let file_service = &state.applications.file_retrieval_service; let upload_service = &state.applications.file_upload_service; @@ -635,7 +690,10 @@ async fn handle_put( // bandwidth or disk I/O on a body the server is going to throw away. // The lookup is reused for the create-vs-update distinction below, // so this is also free of an extra DB hit. - let existing = file_service.get_file_by_path(&internal_path).await.ok(); + let existing = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + .ok(); let current_etag = existing.as_ref().map(|f| f.etag.as_str()); if let Some(value) = req @@ -690,7 +748,14 @@ async fn handle_put( // Single streaming path — handles both update and create internally, // swapping the file row onto the already-ingested blob. let stored = upload_service - .update_file_streaming(&internal_path, ingested.stored(), &content_type, oc_mtime) + .update_file_streaming( + &internal_path, + chroot.drive_id, + ingested.stored(), + &content_type, + oc_mtime, + session.user.id, + ) .await .map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?; @@ -712,13 +777,15 @@ async fn handle_put( async fn handle_mkcol( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; use crate::application::dtos::folder_dto::CreateFolderDto; let folder_service = &state.applications.folder_service; - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let internal_path = nc_to_internal_path(chroot, subpath)?; // RFC 4918 §9.3.1: // - target already exists → 405 Method Not Allowed @@ -733,7 +800,7 @@ async fn handle_mkcol( // auto-create doesn't break real clients. if folder_service - .get_folder_by_path(&internal_path) + .get_folder_by_path(&internal_path, chroot.drive_id) .await .is_ok() { @@ -751,14 +818,21 @@ async fn handle_mkcol( } let (target_name, parent_segments) = segments.split_last().expect("checked non-empty above"); - let user_root = nc_to_internal_path(&user.username, "")?; + // Take POC's `chroot`-based root resolution (drive-aware mount + // point) but keep HEAD's parent_path lookup pattern — the + // continuation below uses `get_folder_by_path(&parent_path, + // user.id)` (user-scoped lookup added in the D0 rewind). + let user_root = nc_to_internal_path(chroot, "")?; let parent_path = if parent_segments.is_empty() { user_root.clone() } else { format!("{}/{}", user_root, parent_segments.join("/")) }; - let parent_folder = match folder_service.get_folder_by_path(&parent_path).await { + let parent_folder = match folder_service + .get_folder_by_path(&parent_path, chroot.drive_id) + .await + { Ok(folder) => folder, Err(_) => { return Ok(Response::builder() @@ -787,17 +861,22 @@ async fn handle_mkcol( async fn handle_delete( state: Arc, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { - let internal_path = nc_to_internal_path(&user.username, subpath)?; + let user = &session.user; + let chroot = session.require_chroot()?; + let internal_path = nc_to_internal_path(chroot, subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; // Prefer soft-delete (move to trash) when trash service is available. // This is what Nextcloud clients expect — items appear in the trashbin. if let Some(trash_svc) = state.trash_service.as_ref() { - if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, chroot.drive_id) + .await + { trash_svc .move_to_trash(&folder.id, "folder", user.id) .await @@ -807,7 +886,10 @@ async fn handle_delete( .body(Body::empty()) .unwrap()); } - if let Ok(file) = file_service.get_file_by_path(&internal_path).await { + if let Ok(file) = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + { trash_svc .move_to_trash(&file.id, "file", user.id) .await @@ -823,7 +905,10 @@ async fn handle_delete( // Fallback: hard delete when trash service is not available. let file_mgmt = &state.applications.file_management_service; - if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&internal_path, chroot.drive_id) + .await + { folder_service .delete_folder_with_perms(&folder.id, user.id) .await @@ -835,7 +920,10 @@ async fn handle_delete( .unwrap()); } - if let Ok(file) = file_service.get_file_by_path(&internal_path).await { + if let Ok(file) = file_service + .get_file_by_path(&internal_path, chroot.drive_id) + .await + { file_mgmt .delete_file_with_perms(&file.id, user.id) .await @@ -855,9 +943,12 @@ async fn handle_delete( async fn handle_move( state: Arc, req: Request, - user: &CurrentUser, + session: &crate::interfaces::nextcloud::session::NcSession, subpath: &str, ) -> Result, AppError> { + let user = &session.user; + let chroot = session.require_chroot()?; + let url_user = &session.raw_username; let destination = req .headers() .get("destination") @@ -879,10 +970,14 @@ async fn handle_move( .unwrap_or(false); // Parse destination path: extract subpath after /remote.php/dav/files/{user}/ - let dest_subpath = extract_nc_subpath_from_dest(&destination, &user.username) + // — the URL user-segment carries the drive marker on multi-drive + // sessions, so we strip the *composite* prefix to find the real + // subpath. Using `user.username` here would fail to match for any + // request hitting a non-home drive. + let dest_subpath = extract_nc_subpath_from_dest(&destination, url_user) .ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?; - let src_internal = nc_to_internal_path(&user.username, subpath)?; + let src_internal = nc_to_internal_path(chroot, subpath)?; let folder_service = &state.applications.folder_service; let file_service = &state.applications.file_retrieval_service; let file_mgmt = &state.applications.file_management_service; @@ -891,13 +986,13 @@ async fn handle_move( // Resolved once up-front so the file/folder branches below don't // each have to repeat the check. `dest_existed_before` becomes the // 204-vs-201 selector at response time. - let dest_internal_precheck = nc_to_internal_path(&user.username, &dest_subpath)?; + let dest_internal_precheck = nc_to_internal_path(chroot, &dest_subpath)?; let dest_existing_file = file_service - .get_file_by_path(&dest_internal_precheck) + .get_file_by_path(&dest_internal_precheck, chroot.drive_id) .await .ok(); let dest_existing_folder = folder_service - .get_folder_by_path(&dest_internal_precheck) + .get_folder_by_path(&dest_internal_precheck, chroot.drive_id) .await .ok(); let dest_existed_before = dest_existing_file.is_some() || dest_existing_folder.is_some(); @@ -940,12 +1035,15 @@ async fn handle_move( }; // Try as file first. - if let Ok(file) = file_service.get_file_by_path(&src_internal).await { + if let Ok(file) = file_service + .get_file_by_path(&src_internal, chroot.drive_id) + .await + { let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') { Some((parent, name)) => (parent, name), None => ("", dest_subpath.as_str()), }; - let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?; + let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?; // Rename if only the name changes (same parent). let src_parent_sub = match subpath.rsplit_once('/') { @@ -962,7 +1060,7 @@ async fn handle_move( } else { // Different parent → move. let dest_parent = folder_service - .get_folder_by_path(&dest_parent_internal) + .get_folder_by_path(&dest_parent_internal, chroot.drive_id) .await .map_err(|_| AppError::not_found("Destination folder not found"))?; @@ -981,9 +1079,15 @@ async fn handle_move( } // Return ETag and OC-ETag so Nextcloud clients can track the moved file. - let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?; + // Take POC's chroot-based path resolution; keep HEAD's + // final_status (201 vs 204 depending on whether the destination + // existed — RFC 4918 §9.9.4 distinguishes create vs overwrite). + let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?; let mut builder = Response::builder().status(final_status); - if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await { + if let Ok(moved) = file_service + .get_file_by_path(&dest_internal, chroot.drive_id) + .await + { // Route through `FileDto::etag` so the MOVE response // matches what a subsequent PROPFIND on the destination // will return — `moved.id` (UUID) would differ from the @@ -997,12 +1101,15 @@ async fn handle_move( } // Try as folder. - if let Ok(folder) = folder_service.get_folder_by_path(&src_internal).await { + if let Ok(folder) = folder_service + .get_folder_by_path(&src_internal, chroot.drive_id) + .await + { let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') { Some((parent, name)) => (parent, name), None => ("", dest_subpath.as_str()), }; - let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?; + let dest_parent_internal = nc_to_internal_path(chroot, dest_parent_sub)?; let src_parent_sub = match subpath.rsplit_once('/') { Some((parent, _)) => parent, @@ -1025,7 +1132,7 @@ async fn handle_move( } else { // Different parent → move. let dest_parent = folder_service - .get_folder_by_path(&dest_parent_internal) + .get_folder_by_path(&dest_parent_internal, chroot.drive_id) .await .map_err(|_| AppError::not_found("Destination parent not found"))?; @@ -1112,6 +1219,7 @@ fn write_nc_multistatus_open(xml: &mut Writer) -> Result<( async fn write_nc_file_multistatus( writer: W, file: &FileDto, + url_user: &str, username: &str, subpath: &str, file_id_svc: Option<&Arc>, @@ -1124,7 +1232,11 @@ async fn write_nc_file_multistatus( write_nc_multistatus_open(&mut xml)?; // Single-file PROPFIND — subpath already points to the file. - let href = nc_href(username, subpath); + // `url_user` is the wire identifier (may carry a `~{drive}` + // marker); the NC client validates that the returned `` + // shares the requested URL's prefix. `username` is the canonical + // identity for the `oc:owner-id` field. + let href = nc_href(url_user, subpath); let file_id = file_id_map.get(&file.id).copied(); let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc)); write_file_response( @@ -1530,39 +1642,83 @@ mod tests { use super::*; // ── nc_to_internal_path ── + // + // The route glue resolves the `chroot` FolderDto once per request + // (legacy/home → user's home folder DTO; explicit `~{folder_uuid}` → + // folder's stored DTO after permission check). These tests cover only + // the path-mapping function itself; the resolver logic lives in + // `routes.rs::verify_url_user_and_resolve_chroot`. - #[test] - fn test_empty_subpath_returns_home() { - assert_eq!( - nc_to_internal_path("alice", "").unwrap(), - "My Folder - alice" - ); + /// Build a stub `FolderDto` carrying only the `path` field (all the + /// path mapper looks at). Keeps the tests focused on path mapping + /// without dragging in folder-construction machinery. + fn stub_folder(path: &str) -> FolderDto { + FolderDto { + id: "00000000-0000-0000-0000-000000000000".to_string(), + name: path.rsplit('/').next().unwrap_or("").to_string(), + path: path.to_string(), + parent_id: None, + owner_id: None, + // Test stub — path mapper doesn't read drive_id. + drive_id: uuid::Uuid::nil(), + created_at: 0, + modified_at: 0, + is_root: false, + icon_class: std::sync::Arc::from("fas fa-folder"), + icon_special_class: std::sync::Arc::from("folder-icon"), + category: std::sync::Arc::from("Folder"), + etag: String::new(), + // §14 provenance not relevant to path-mapper tests. + created_by: None, + updated_by: None, + } } #[test] - fn test_subpath_appended() { + fn test_empty_subpath_returns_chroot() { + let home = stub_folder("My Folder - alice"); + assert_eq!(nc_to_internal_path(&home, "").unwrap(), "My Folder - alice"); + } + + #[test] + fn test_subpath_appended_to_chroot() { + let home = stub_folder("My Folder - alice"); assert_eq!( - nc_to_internal_path("alice", "Documents/work").unwrap(), + nc_to_internal_path(&home, "Documents/work").unwrap(), "My Folder - alice/Documents/work" ); } #[test] fn test_strips_surrounding_slashes() { + let home = stub_folder("My Folder - alice"); assert_eq!( - nc_to_internal_path("alice", "/Photos/").unwrap(), + nc_to_internal_path(&home, "/Photos/").unwrap(), "My Folder - alice/Photos" ); } #[test] fn test_rejects_dot_dot_traversal() { - assert!(nc_to_internal_path("alice", "../etc/passwd").is_err()); + let home = stub_folder("My Folder - alice"); + assert!(nc_to_internal_path(&home, "../etc/passwd").is_err()); } #[test] fn test_rejects_single_dot() { - assert!(nc_to_internal_path("alice", "foo/./bar").is_err()); + let home = stub_folder("My Folder - alice"); + assert!(nc_to_internal_path(&home, "foo/./bar").is_err()); + } + + /// Confines a subfolder chroot (the multi-drive form once + /// resolved). Same path-mapping logic — only the chroot differs. + #[test] + fn test_subfolder_chroot_with_subpath() { + let chroot = stub_folder("My Folder - alice/ext"); + assert_eq!( + nc_to_internal_path(&chroot, "report.pdf").unwrap(), + "My Folder - alice/ext/report.pdf" + ); } // ── nc_href ── diff --git a/static/css/views/auth.css b/static/css/views/auth.css index 526eecf7..c5ced6b9 100644 --- a/static/css/views/auth.css +++ b/static/css/views/auth.css @@ -803,3 +803,50 @@ font-weight: var(--weight-medium); color: var(--color-warning-orange-text); } + +/* Nextcloud drive picker — radio list of drives the authenticated user + can select for this app-password binding. */ +.auth-drive-option { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + margin-bottom: 8px; + border: 1px solid var(--color-border); + border-radius: 12px; + cursor: pointer; + transition: + background 0.2s ease, + border-color 0.2s ease; +} + +.auth-drive-option:hover { + background: var(--color-bg-hover); + border-color: var(--color-accent); +} + +.auth-drive-option input[type="radio"] { + margin: 0; + accent-color: var(--color-accent); +} + +.auth-drive-option input[type="radio"]:checked ~ .auth-drive-name { + font-weight: 600; +} + +.auth-drive-name { + flex: 1; + color: var(--color-text); + font-size: 15px; +} + +.auth-drive-badge { + padding: 2px 10px; + border-radius: 999px; + background: var(--color-accent-gradient); + color: var(--color-danger-text); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} diff --git a/templates/nextcloud/drive_picker.html b/templates/nextcloud/drive_picker.html new file mode 100644 index 00000000..d8f7d405 --- /dev/null +++ b/templates/nextcloud/drive_picker.html @@ -0,0 +1,51 @@ + + + + + + + Choose a drive - OxiCloud + + + + + + +
+
+ + +

Choose a drive

+

+ Your account has access to several drives. Pick the one this Nextcloud client should sync. +

+ +
+ {%- for drive in drives %} + + {%- endfor %} + + +
+
+
+ + diff --git a/tests/api/drives_foundation.hurl b/tests/api/drives_foundation.hurl new file mode 100644 index 00000000..55202224 --- /dev/null +++ b/tests/api/drives_foundation.hurl @@ -0,0 +1,219 @@ +# ============================================================= +# 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" +# root_folder_id surfaces the drive's mount-point folder. Sourced via +# JOIN from storage.folders.name — drives have no `name` column under +# the D0 design (docs/plan/drive.md §3). Folder API operations +# (create-in-drive, rename-drive) all key off this id. +jsonpath "$[0].root_folder_id" exists +[Captures] +admin_drive_id: jsonpath "$[0].id" +admin_root_folder_id: jsonpath "$[0].root_folder_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}}" +jsonpath "$[0].name" == "Personal" +jsonpath "$[0].root_folder_id" exists +[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}}" +jsonpath "$[0].name" == "Personal" +jsonpath "$[0].root_folder_id" exists +[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/files-folders.hurl b/tests/api/files-folders.hurl index 02155acb..43c2865b 100644 --- a/tests/api/files-folders.hurl +++ b/tests/api/files-folders.hurl @@ -19,9 +19,11 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" +jsonpath "$.user.id" isString # ───────────────────────────────────────────────────────────── @@ -102,6 +104,9 @@ test1_id: jsonpath "$.id" jsonpath "$.id" isString jsonpath "$.name" == "test1" jsonpath "$.parent_id" == {{home_folder_id}} +# D0 §14 provenance — self-creation: both fields stamp the caller. +jsonpath "$.created_by" == "{{admin_user_id}}" +jsonpath "$.updated_by" == "{{admin_user_id}}" # ───────────────────────────────────────────────────────────── @@ -169,6 +174,9 @@ jsonpath "$.name" == "hello.txt" jsonpath "$.folder_id" == {{test2_id}} jsonpath "$.size" == 32 jsonpath "$.mime_type" == "text/plain" +# D0 §14 provenance — uploader's id stamps both fields on a fresh upload. +jsonpath "$.created_by" == "{{admin_user_id}}" +jsonpath "$.updated_by" == "{{admin_user_id}}" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 6c503eb6..588ebc67 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -13,6 +13,8 @@ # ───────────────────────────────────────────────────────────── # Step 1 — Login as admin (Alice), capture token + home folder. +# `alice_user_id` is captured for the D0 §14 provenance assertions +# that compare `created_by` / `updated_by` on resources Alice owns. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/auth/login Content-Type: application/json @@ -21,6 +23,7 @@ Content-Type: application/json HTTP 200 [Captures] alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" GET {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -85,6 +88,10 @@ Content-Type: application/json HTTP 201 [Captures] shared_folder_id: jsonpath "$.id" +[Asserts] +# D0 §14 provenance — Alice creates, so both fields stamp Alice. +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{alice_user_id}}" POST {{base_url}}/api/folders Authorization: Bearer {{alice_token}} @@ -294,7 +301,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 +320,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" # ════════════════════════════════════════════════════════════════════ @@ -651,6 +674,12 @@ Content-Type: application/json { "name": "renamed-by-adam-as-editor" } HTTP 200 +[Asserts] +# D0 §14 provenance — folder counterpart of the file rename below. +# Adam (Editor) mutates Alice's folder; `updated_by` becomes Adam, +# `created_by` stays Alice. +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" PUT {{base_url}}/api/files/{{perm_file_id}}/rename Authorization: Bearer {{adam_token}} @@ -658,6 +687,16 @@ Content-Type: application/json { "name": "adam-renamed-logo.jpg" } HTTP 200 +[Asserts] +# D0 §14 provenance — Adam (an Editor, not the owner) mutates the +# file, so `updated_by` switches to Adam's id while `created_by` +# stays Alice (the original uploader). This is the canonical +# cross-user provenance check: distinguishes "who first put this +# here" from "who last touched it" and proves the mutator's id +# overrides the row's `user_id` (pre-D0 they were silently the +# same; post-D0 they can diverge once a non-owner mutates). +jsonpath "$.created_by" == "{{alice_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" # ── Thumbnail push (Update) succeeds ──────────────────────── PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview @@ -674,6 +713,15 @@ Content-Type: application/json { "name": "adam-created-child", "parent_id": "{{perm_folder_id}}" } HTTP 201 +[Asserts] +# D0 §14 provenance — Adam (Editor on Alice's folder) creates a +# child folder inside it. Both `created_by` and `updated_by` stamp +# Adam: he's the original author AND the last toucher of this +# fresh row. The parent's owner (Alice) doesn't appear anywhere on +# the new row's provenance — content authored in a shared scope +# belongs to its author. +jsonpath "$.created_by" == "{{adam_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" POST {{base_url}}/api/files/upload Authorization: Bearer {{adam_token}} @@ -682,6 +730,10 @@ folder_id: {{perm_folder_id}} file: file,fixtures/hello.txt; text/plain HTTP 201 +[Asserts] +# Same shape for a file upload: Adam authored, Adam touched last. +jsonpath "$.created_by" == "{{adam_user_id}}" +jsonpath "$.updated_by" == "{{adam_user_id}}" # ── Chunked upload full lifecycle as Editor ───────────────── # 1. Open session (server pre-checks Create on folder) @@ -809,7 +861,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 +1293,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/permissions.hurl b/tests/api/permissions.hurl index 2c1a8052..220489c4 100644 --- a/tests/api/permissions.hurl +++ b/tests/api/permissions.hurl @@ -281,12 +281,16 @@ jsonpath "$.items[*].resource.name" not contains "bob-attack-2" # WebDAV MKCOL — namespace isolation # ═════════════════════════════════════════════════════════════ # WebDAV requests are isolated per-user by `resolve_webdav_path` -# (webdav_handler.rs:189). If the requested path doesn't begin -# with the caller's home folder name ("My Folder - "), -# the handler silently prefixes the caller's home folder path -# onto the front. Effect: any WebDAV path a client sends is -# always resolved INSIDE the caller's own tree, regardless of -# what they wrote. +# (webdav_handler.rs:235). If the requested path doesn't begin +# with the caller's home folder name (the drive's root folder +# name — "Personal" by default post-D0), the handler silently +# prefixes the caller's home folder path onto the front. Effect: +# any WebDAV path a client sends is always resolved INSIDE the +# caller's own tree, regardless of what they wrote. +# The test URLs below use "My Folder - " as a path +# segment that's GUARANTEED not to match any caller's home name +# (all home folders are "Personal" post-D0), so the resolver's +# prepend branch always fires. # # These tests assert the isolation works (regression guard) and # that the service-level verify_owner still acts as @@ -307,8 +311,13 @@ HTTP 201 # ───────────────────────────────────────────────────────────── # Step 17 – Positive control: bob MKCOL inside his own home. +# Uses "Personal" — bob's home folder name post-D0 +# (docs/plan/drive.md §3, the canonical default). The resolver +# detects the URL already starts with the caller's home name and +# does NOT prepend again, so the new folder lands directly in +# bob's home rather than in a fresh intermediate. # ───────────────────────────────────────────────────────────── -MKCOL {{base_url}}/webdav/My%20Folder%20-%20bob/bob-webdav-own +MKCOL {{base_url}}/webdav/Personal/bob-webdav-own Authorization: Bearer {{bob_token}} HTTP 201 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/api/search_basic.hurl b/tests/api/search_basic.hurl index 4bc306a5..919ca997 100644 --- a/tests/api/search_basic.hurl +++ b/tests/api/search_basic.hurl @@ -153,7 +153,83 @@ body not contains "{{needle_file_id}}" # ───────────────────────────────────────────────────────────── -# 6 — Teardown: removing the folder recursively takes the file +# 6 — CONTENT-search cross-drive isolation (docs/plan/drive.md §11). +# The cross-user check above (step 5) verifies the NAME-search +# path. The Tantivy content index is a separate code path with +# its own filter: `Must drive_id ∈ accessible_drives`. This +# block pins it. +# +# Sequence: +# 6a. Admin uploads `content-canary.txt` whose body contains +# the distinctive phrase `ContentIndexCanaryXyzzy2026Drive`. +# 6b. Wait ~2s for the async content-index worker +# (`OXICLOUD_CONTENT_SEARCH_FLUSH_INTERVAL_MS` defaults +# to 1500ms) to drain the dirty queue and apply the +# Tantivy mutation. +# 6c. Admin searches for the phrase → MUST hit the file +# (the index works). +# 6d. Bob searches for the same phrase → MUST be empty, +# AND the response shape MUST carry no hidden-count +# leak (no `total`/`hidden`/etc. field that could +# reveal "you have N matches you can't see"). The +# pivot from `Must user_id = caller` to `Must drive_id +# ∈ accessible_drives` is the §11 security primitive; +# a regression here would be a cross-drive leak. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{search_folder_id}} +file: file,fixtures/content-canary.txt; text/plain + +HTTP 201 +[Captures] +canary_file_id: jsonpath "$.id" + + +# Drain the content-index worker. 2s exceeds the 1500ms flush +# interval comfortably; raise if a slower CI machine flakes. +GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive +Authorization: Bearer {{admin_token}} +[Options] +delay: 2500ms + +HTTP 200 +[Asserts] +# Admin sees the content match — proves indexing landed. +jsonpath "$.files" count >= 1 +body contains "{{canary_file_id}}" + + +GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +# Bob has no access to admin's drive → Tantivy's Must-clause +# filters every doc that doesn't carry one of Bob's drive_ids, +# so the file vanishes entirely. +jsonpath "$.files" count == 0 +jsonpath "$.folders" count == 0 +body not contains "{{canary_file_id}}" +body not contains "ContentIndexCanaryXyzzy2026Drive" +# Anti-enum: every count the response surfaces must reflect the +# FILTERED set — i.e. zero when the caller has no accessible +# hits. The §11 rule is "no 'you have N hidden matches' field +# anywhere". `total_count` is a legitimate pagination count and +# is OK as long as it equals the filtered total (zero here). The +# other field names below MUST stay absent: a future field +# called `hidden_count`/`filtered`/etc. that reveals matches +# Bob can't see would be the regression. +jsonpath "$.total_count" == 0 +jsonpath "$.has_more" == false +jsonpath "$.hidden_count" not exists +jsonpath "$.filtered" not exists +jsonpath "$.total" not exists + + +# ───────────────────────────────────────────────────────────── +# 7 — Teardown: removing the folder recursively takes the files # with it, so a single DELETE is enough. # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/folders/{{search_folder_id}} diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index d0dd1c8e..542855ed 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -13,6 +13,19 @@ # bash tests/api/storage_cleanup_check.sh # ============================================================= +cat </dev/null +# The OxiCloud server normally provisions a default Personal drive + +# its root folder + 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. +# +# Four sequential writes inside one transaction (docs/plan/drive.md §3): +# drive + root folder + drives.root_folder_id wire-up + Owner role_grant. +# A single CTE would be more compact but doesn't work — PG's CTE +# sub-statements share an MVCC snapshot, so a later branch's UPDATE +# can't match a row inserted by an earlier branch. The transaction +# form is the production path's shape (DrivePgRepository::create_personal_drive_atomic). +# Idempotency: skipped on retry by the `default_for_user` precondition. +echo "[init-schema] provisioning ci-admin's default Personal drive (idempotent)" +psql -v ON_ERROR_STOP=1 <<'SQL' >/dev/null +DO $$ +DECLARE + admin_id uuid; + drive_id uuid; + folder_id uuid; +BEGIN + SELECT id INTO admin_id FROM auth.users WHERE username = 'ci-admin'; + IF EXISTS (SELECT 1 FROM storage.drives WHERE default_for_user = admin_id) THEN + RETURN; -- already provisioned, idempotent no-op + END IF; + + INSERT INTO storage.drives (kind, default_for_user, quota_bytes) + VALUES ('personal', admin_id, NULL) + RETURNING id INTO drive_id; + + INSERT INTO storage.folders + (name, parent_id, user_id, drive_id, created_by, updated_by) + VALUES ('Personal', NULL, admin_id, drive_id, admin_id, admin_id) + RETURNING id INTO folder_id; + + UPDATE storage.drives SET root_folder_id = folder_id WHERE id = drive_id; + + INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ('user', admin_id, 'drive', drive_id, 'owner', admin_id); +END +$$; +SQL + echo "[init-schema] done" diff --git a/tests/common/server.env b/tests/common/server.env index 979625d5..12e0b7a6 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -18,7 +18,7 @@ OXICLOUD_OIDC_ENABLED=false OXICLOUD_NEXTCLOUD_ENABLED=true -RUST_LOG="warn,audit=info" +RUST_LOG="warn,audit=info,sqlx::migrate=info" #RUST_LOG=debug #RUST_LOG=info diff --git a/tests/fixtures/content-canary.txt b/tests/fixtures/content-canary.txt new file mode 100644 index 00000000..af9821a1 --- /dev/null +++ b/tests/fixtures/content-canary.txt @@ -0,0 +1 @@ +ContentIndexCanaryXyzzy2026Drive — this phrase is intentionally distinctive so that searches in the Tantivy content index can locate it unambiguously across test runs. Used by tests/api/search_basic.hurl to verify cross-drive content-search isolation (docs/plan/drive.md §11). Do not include this phrase in any other fixture.