dd9e3b8868
ensure that a user that don't havee permission to a drive cannot search elements in this drive
note: current design: Index are associated to drive, so if a document / directory is shared,
the shared resource will not be in index for targetted user
design is explicitely as is to reduce complexity
1874 lines
98 KiB
Markdown
1874 lines
98 KiB
Markdown
# Drives — design proposal
|
||
|
||
> **Status**: design proposal, locked but not implemented. Reviewed Jun 2026 with
|
||
> Ed; all blocking decisions answered. Open items listed at the end of the file.
|
||
|
||
## Context
|
||
|
||
Today every resource (folder, file) in OxiCloud has a single user owner
|
||
recorded directly on the row (`storage.folders.user_id`,
|
||
`storage.files.user_id`). Sharing is layered on top via ReBAC grants in
|
||
`storage.access_grants`. The model is simple and has carried us this far,
|
||
but it has two structural problems:
|
||
|
||
1. **Owner = user, full stop.** When a user leaves the company, their
|
||
files leave with them. The team's de-facto shared folders are
|
||
technically owned by a single person; transferring ownership requires
|
||
moving every resource one-by-one and re-issuing every grant. There is
|
||
no "this folder belongs to the Engineering team" concept.
|
||
|
||
2. **Quota is per-user, full stop.** A user with personal storage who
|
||
also collaborates in a 1 TB team space currently has both counted
|
||
against their personal quota (or the team data costs are absorbed by
|
||
whoever owns the folder). There is no way to bill team storage to the
|
||
team.
|
||
|
||
The proposal is to introduce **drives** as a Google-Workspace-style
|
||
container concept:
|
||
|
||
- Every resource belongs to exactly one drive.
|
||
- A drive has owners (users **and/or** groups) with roles.
|
||
- Each user automatically gets a personal drive at registration.
|
||
- Shared drives can be owned by groups, so membership changes
|
||
automatically follow the group — "Bob left" no longer needs an admin
|
||
to chase down which folders to reassign.
|
||
- Quotas move from users to drives.
|
||
- Per-drive policies enable team-level rules (forbid public links,
|
||
forbid sharing, forbid cross-drive moves, future: timeboxed sessions,
|
||
end-to-end encryption).
|
||
|
||
The shift is large but the model is well-understood — it's the same
|
||
pattern Google Workspace's Shared Drives and Microsoft SharePoint
|
||
Document Libraries use. OxiCloud users coming from those services will
|
||
recognise it instantly.
|
||
|
||
## Prerequisite — PR D-Prep: `access_grants → role_grants`
|
||
|
||
**Status (2026-06-17): scope complete, ready to PR.** The schema migration
|
||
runs cleanly against real sandbox data (38 role_grants rows produced,
|
||
matching the audit's 29 viewer / 5 editor / 4 owner distribution, zero
|
||
bundle mismatches). End-to-end Hurl test (`tests/api/role_grants.hurl`)
|
||
covers create / atomic role update / revoke with both the canonical
|
||
`"owner"` and the legacy `"admin"` compat wire format. Engine reads
|
||
pivot to `role_grants` for authz decisions; `access_grants` stays
|
||
populated via dual-write as the safety net for one release cycle.
|
||
**Ratified 2026-06-16.** A separate PR lands BEFORE D0 that refactors
|
||
`storage.access_grants` into `storage.role_grants` with role-bundle
|
||
semantics:
|
||
|
||
```sql
|
||
storage.role_grants
|
||
id uuid PRIMARY KEY DEFAULT gen_random_uuid()
|
||
subject_type text NOT NULL CHECK (subject_type IN ('user','group'))
|
||
subject_id uuid NOT NULL
|
||
resource_type text NOT NULL -- 'folder','file','drive','group',…
|
||
resource_id uuid NOT NULL
|
||
role text NOT NULL CHECK (role IN ('viewer','editor','owner', …))
|
||
expires_at timestamptz NULL
|
||
granted_by uuid NOT NULL FK → auth.users(id)
|
||
granted_at timestamptz NOT NULL DEFAULT now()
|
||
```
|
||
|
||
Roles map to permission bundles via a single
|
||
server-side function `role_bundle(role) -> &[Permission]`:
|
||
|
||
| Role | Bundle |
|
||
|---|---|
|
||
| `viewer` | `Read` |
|
||
| `editor` | `Read, Create, Update, Comment` |
|
||
| `owner` | `Read, Create, Update, Comment, Delete, Share, Manage` |
|
||
|
||
`Manage` is a new Permission introduced in this refactor — covers
|
||
"configure this resource's settings, add/remove members, change role
|
||
assignments". Future-friendly for Group-as-Resource.
|
||
|
||
**Why first**:
|
||
1. Refactor has standalone value beyond Drive (cleaner API, audit
|
||
log, UI surface).
|
||
2. Doing it AFTER Drive would force migrating both `access_grants`
|
||
AND `drive_members` — solving the unification first means D0
|
||
migrates one table, not two.
|
||
3. **The `drive_members` table planned by earlier drafts goes away.**
|
||
Drive membership becomes rows in `role_grants` with
|
||
`resource_type='drive'`. One table, one engine, one cache.
|
||
|
||
**The sections below describe the Drive model assuming D-Prep has
|
||
landed.** References to "drive membership" mean rows in
|
||
`role_grants` with `resource_type='drive'`, not a separate table.
|
||
|
||
**Gating policy**: D-Prep must ship, bake in production, and pass
|
||
validation across the API surface (grant endpoints, audit log
|
||
events) and the UI surface (My Shares dialog, share modals, admin
|
||
group/role views) before any of the Drive PRs (D0–D7) begin. The
|
||
goal is to surface and absorb refactor-related issues against the
|
||
existing single-resource model — not to discover them mid-flight
|
||
while the Drive migration is also in motion. If D-Prep needs
|
||
follow-up patches after deploy, they land as standalone fixes
|
||
before D0 starts.
|
||
|
||
## Locked design (decisions ratified in design discussion)
|
||
|
||
### 1. Ownership pivot
|
||
|
||
- A new table `storage.drives` becomes the ownership anchor.
|
||
- `storage.folders` and `storage.files` lose their `user_id` column;
|
||
they gain `drive_id NOT NULL`.
|
||
- The owner of a resource is computed via `resource.drive_id →
|
||
drive.drive_members WHERE role='owner'` — a JOIN, not a denormalised
|
||
column. The drives table will be tiny (one row per internal user +
|
||
handful of shared drives), so the join is cheap with a proper index.
|
||
- **Single source of truth** confirmed. `user_id` is dropped from
|
||
resources after a migration phase (see Migration below).
|
||
|
||
### 2. Drive membership
|
||
|
||
Drive membership lives in `storage.role_grants` (introduced by PR
|
||
D-Prep — see prerequisite above) with `resource_type='drive'` and
|
||
`resource_id=<drive uuid>`. There is **no** separate
|
||
`drive_members` table. One row per `(subject, drive)` pair carries
|
||
the role; the role expands to a permission bundle via the same
|
||
`role_bundle()` function used everywhere else.
|
||
|
||
Concretely, a drive membership is:
|
||
|
||
```sql
|
||
INSERT INTO storage.role_grants
|
||
(subject_type, subject_id, resource_type, resource_id, role, granted_by)
|
||
VALUES ('user', $user_id, 'drive', $drive_id, 'editor', $admin_id);
|
||
```
|
||
|
||
The `PgAclEngine` reads this row no differently than any other
|
||
grant; the drive-membership concept exists only at the application
|
||
layer (`DriveService` enforces the personal-drive invariants, etc.)
|
||
— there's no separate engine path or cache keyspace.
|
||
|
||
- A **shared** drive (`kind='shared'`) can have **0..N user
|
||
owners + 0..N group owners** (mixed freely) plus any number of
|
||
editors/viewers.
|
||
- A **personal** drive (`kind='personal'`) has exactly **one**
|
||
member row in `drive_members`, fixed to `(subject_type='user',
|
||
subject_id=<the user>, role='owner')`. The rule applies to
|
||
every `kind='personal'` row, whether it carries
|
||
`default_for_user IS NOT NULL` (the user's default drive) or
|
||
`default_for_user IS NULL` (a secondary personal silo, e.g. a
|
||
SQL-created sibling root folder promoted by migration §10).
|
||
Secondary does **not** loosen the single-user constraint.
|
||
- Personal drives **cannot** be added to, role-changed, or have
|
||
their sole owner removed. They cannot be co-owned. They cannot
|
||
be transferred to another user. This matches Google Drive's
|
||
"My Drive" and Microsoft's "OneDrive" semantics: a personal
|
||
drive is a single-user namespace; collaboration happens through
|
||
per-resource grants, by moving content into a shared drive,
|
||
or by promoting a secondary personal drive to `kind='shared'`
|
||
(capability matrix in §3).
|
||
- Enforcement is at the application layer:
|
||
`DriveService::add_member` refuses when `kind='personal'`;
|
||
`remove_member` refuses when the drive is personal. User
|
||
deletion cleans up the user's **default** personal drive via
|
||
the `default_for_user` FK cascade, and the user's
|
||
**secondary** personal drives via an application-layer pass
|
||
(those have no FK to cascade through — see §6 lifecycle).
|
||
- **Shared-drive last-owner protection**: removing the final
|
||
`role='owner'` member of a shared drive is refused at the
|
||
application layer. The check counts remaining owner-role members
|
||
(expanding group owners to their members) and rejects the delete
|
||
if the count would become zero.
|
||
- A shared drive can have **0 viewers** and **0 editors** — only
|
||
the ≥1-owner invariant matters.
|
||
|
||
### 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()
|
||
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.
|
||
-- Shared drives have it NULL by definition.
|
||
CONSTRAINT drives_default_marker_personal_only
|
||
CHECK (default_for_user IS NULL OR kind = 'personal')
|
||
|
||
CREATE UNIQUE INDEX drives_default_for_user_idx
|
||
ON storage.drives (default_for_user)
|
||
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/<root_id>`
|
||
with a new name renames the drive. No separate
|
||
`PATCH /api/drives/<id>/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):
|
||
- `'personal'` = single-user single-owner; cannot have members
|
||
added; sharing happens only through per-resource grants.
|
||
- `'shared'` = multi-owner / multi-member; supports user and
|
||
group members at every role.
|
||
- **`default_for_user`** = "this is the default drive for user
|
||
X". Set on exactly one `kind='personal'` drive per user (the
|
||
partial unique index enforces it). Used by:
|
||
- UI default-drive redirect (`/` → `/drive/<default>`).
|
||
- NC default-drive resolution when the credential doesn't pin
|
||
a specific drive.
|
||
- The `ON DELETE CASCADE` on this column cleans up the user's
|
||
default drive in one step when the user is deleted.
|
||
- Canonical lookup: `SELECT id FROM storage.drives WHERE
|
||
default_for_user = $1` — single-row, index-backed.
|
||
|
||
A user can have **multiple `kind='personal'` drives** — at most
|
||
one of them carries `default_for_user`, the others sit as
|
||
"secondary personal" silos (e.g. SQL-created root folders
|
||
promoted by migration §10). Secondary personals follow the same
|
||
single-owner rules as the default; they just aren't the default
|
||
landing target.
|
||
|
||
External users **never** get a personal drive (lifecycle §6).
|
||
There is no constraint to write — externals simply have no row
|
||
in `storage.drives` with `default_for_user = <their id>`, and
|
||
nothing tries to create one.
|
||
|
||
#### Atomic creation — single transaction, four writes
|
||
|
||
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.
|
||
|
||
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=<existing>)`
|
||
*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
|
||
|
||
| Capability | `kind='personal'`, default (`default_for_user` set) | `kind='personal'`, secondary (`default_for_user IS NULL`) | `kind='shared'` |
|
||
|---|---|---|---|
|
||
| Membership shape | exactly 1 user-owner row | exactly 1 user-owner row | 0..N users + 0..N groups at any role |
|
||
| `add_member` | refused | refused | allowed |
|
||
| `remove_member` | refused (sole owner is fixed) | refused (sole owner is fixed) | allowed except sole owner |
|
||
| 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 `role_grants` (`subject_id=<user> 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
|
||
|
||
Drive-level roles map to existing ReBAC `Permission` values via union
|
||
expansion:
|
||
|
||
| Role | Permissions implied on every resource inside the drive |
|
||
|---|---|
|
||
| `viewer` | `Read` |
|
||
| `editor` | `Read`, `Create`, `Update`, `Comment` |
|
||
| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members) |
|
||
|
||
### 5. Permission resolution — additive over `role_grants`
|
||
|
||
A user has permission `P` on resource `R` if any of the
|
||
`role_grants` rows reachable from them (directly, via group
|
||
membership, or via drive membership) carries a role whose bundle
|
||
includes `P`. Specifically:
|
||
|
||
- A direct grant: `(subject_type='user', subject_id=$user, resource_id=$R, role=…)`
|
||
- A group-mediated grant: any `role_grants` row whose
|
||
`subject_type='group'` points at a group the user is a transitive
|
||
member of.
|
||
- A drive-mediated grant: any `role_grants` row whose
|
||
`resource_type='drive'` and `resource_id=$R.drive_id`, with
|
||
the same per-row role bundle.
|
||
|
||
Permissions are **additive**. Drive role is the baseline floor for
|
||
every resource in the drive; explicit per-resource grants only add
|
||
on top. There is no "revoke for this file even though you're a
|
||
drive editor" concept, matching Google Workspace.
|
||
|
||
The auth engine reads `role_grants` as a single source of truth.
|
||
Cache invalidation has one keyspace (rows in `role_grants`) instead
|
||
of two. The drive-membership lookup ("what drives can this caller
|
||
read?") is a `WHERE subject_id=$1 AND resource_type='drive'` scan
|
||
against the same table.
|
||
|
||
### 6. Lifecycle rules
|
||
|
||
| Event | Behaviour |
|
||
|---|---|
|
||
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=<new_user>`, `quota_bytes=<OXICLOUD_DEFAULT_QUOTA_BYTES>`) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=<user>, resource_type='drive', resource_id=<drive>, 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 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. The drive's display name lives on its root folder (§3) — rename via `PATCH /api/folders/<root_folder_id>`, 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
|
||
|
||
The per-user `auth.users.storage_quota_bytes` field is **migrated to
|
||
the user's personal drive's `quota_bytes`** in one step, then the
|
||
column is deprecated (kept for one release cycle as a no-op, dropped
|
||
in a later migration).
|
||
|
||
After the cutover:
|
||
- Every drive owns its quota. Files inside a drive count against that
|
||
drive's `used_bytes` only.
|
||
- A user who collaborates in a 1 TB shared drive sees their personal
|
||
drive's quota as "their" quota; the shared drive's quota is owned
|
||
by the team.
|
||
- New drives default to a tenant-configured `OXICLOUD_DEFAULT_DRIVE_QUOTA_BYTES`
|
||
setting (separate env var, replacing today's per-user equivalent).
|
||
|
||
`used_bytes` is maintained incrementally on every file insert/delete
|
||
(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=<admin_user_id>` 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
|
||
multiple drives. The accounting decision: **each drive counts the
|
||
file's logical size in full against its own `used_bytes`** — dedup
|
||
savings are server-side only and never visible in the per-drive
|
||
quota number. This matches the existing per-user blob-dedup model
|
||
and avoids the alternative "pro-rated quota" trap (which makes
|
||
quota math depend on cross-drive content and breaks the user's
|
||
mental model of "I have 1 TB free"). Reconciliation job sums file
|
||
sizes per drive, not chunk allocations.
|
||
|
||
### 8. Policies (JSONB, extensible)
|
||
|
||
Each drive carries a `policies` JSON object. Five known keys for v1:
|
||
|
||
```jsonc
|
||
{
|
||
"forbid_sharing": false, // disables per-resource grants on this drive
|
||
"forbid_external_sharing": false, // blocks grants to is_external=true subjects
|
||
"forbid_public_links": false, // blocks token-share (anonymous link) creation
|
||
"forbid_cross_drive_move": false // blocks MOVE when src.drive_id != dst.drive_id
|
||
}
|
||
```
|
||
|
||
Enforcement points (one place per policy — single grep target):
|
||
|
||
| Policy | Enforcement callsite |
|
||
|---|---|
|
||
| `forbid_sharing` | `grant_handler::create_grant` — checks `resource.drive_id`'s policy before insertion |
|
||
| `forbid_external_sharing` | `magic_link_invite_service::resolve_or_create_recipient` and `grant_handler::create_grant` (when subject is `is_external=true`) |
|
||
| `forbid_public_links` | `share_handler::create_shared_link` |
|
||
| `forbid_cross_drive_move` | `file_handler::move_file` and `folder_handler::move_folder` — refuse when `src.drive_id != dst.drive_id` |
|
||
|
||
Default to `false` (everything allowed) — opt-in by drive owner via
|
||
the drive settings UI.
|
||
|
||
#### Policy semantics — subtleties to remember
|
||
|
||
- **`forbid_sharing`** disables **per-resource** grants on resources
|
||
in the drive. Drive owners can still add **drive-level** members
|
||
(otherwise the drive becomes uneditable except by the original
|
||
owner). The policy means "no fine-grained sharing of individual
|
||
files; access happens through drive membership only".
|
||
- **`forbid_cross_drive_move`** protects against exfiltration via UI
|
||
move. It does **not** stop download + re-upload (that's a different
|
||
category of policy — file-egress, future). UI surface should make
|
||
this explicit so users don't read it as data-leak protection.
|
||
|
||
#### Future policy keys (out of scope for v1 — but the JSONB shape
|
||
accommodates them without schema migration)
|
||
|
||
- `timeboxed_session` — drive contents require re-auth after N
|
||
minutes. Significant UX/middleware lift; defer.
|
||
- `end_to_end_encrypted` — client-side encryption; massive scope.
|
||
|
||
### 9. URL surface
|
||
|
||
#### Frontend
|
||
|
||
| URL | Resolves to |
|
||
|---|---|
|
||
| `/` | Redirect to the caller's personal drive UUID |
|
||
| `/drive/<drive-uuid>` | Drive root view |
|
||
| `/drive/<drive-uuid>/<folder-id>` | Folder inside the drive |
|
||
|
||
#### Native WebDAV (`/webdav/...`)
|
||
|
||
| URL | Resolves to |
|
||
|---|---|
|
||
| `/webdav/<path>` | Caller's personal drive root + `<path>` (back-compat with today's behaviour) |
|
||
| `/webdav/drives/<drive-uuid>/<path>` | Specific drive root + `<path>` |
|
||
|
||
Today's `/webdav/<path>` handler implicitly looks up the caller's
|
||
home folder and prepends it. Post-drives, the same handler looks up
|
||
the caller's personal drive and resolves paths inside it. **Zero
|
||
breakage** for existing native WebDAV clients.
|
||
|
||
The `drives` path segment is **reserved**: a folder literally named
|
||
`drives` cannot exist at the top level of any drive. Migration
|
||
pre-check refuses to start if existing data violates this — operator
|
||
must rename before upgrading. (Conservative estimate: zero existing
|
||
folders are named exactly `drives`. The migration script reports any
|
||
collisions for manual fix-up.)
|
||
|
||
#### NextCloud-compat WebDAV (`/remote.php/dav/...`)
|
||
|
||
> **The path-segment `/drives/<uuid>/` form is NOT used on the NC
|
||
> surface.** It is reserved for the native WebDAV surface (see
|
||
> table above). The NC surface keeps the URL shape
|
||
> `/remote.php/dav/files/<username>/<path>` and carries the drive
|
||
> selector in the **credential**, not the URL.
|
||
>
|
||
> NC desktop / mobile clients store credentials per
|
||
> `(host, username)` and offer a single sync root per saved
|
||
> account. A path-segment scheme would require NC clients to grow
|
||
> multi-root awareness, which they don't have. Two
|
||
> credential-side mechanisms are valid here:
|
||
>
|
||
> 1. **Username discriminator (`{user}~{drive-uuid}`)** — the
|
||
> chroot POC on `feat/nextcloud-drive` (commit `137169b7`).
|
||
> The Basic Auth username carries the drive UUID after a `~`
|
||
> separator; the URL stays under `/remote.php/dav/files/{user}~{uuid}/<path>`.
|
||
> Explicit on the wire, no per-credential server state needed
|
||
> beyond the existing app-password row.
|
||
>
|
||
> 2. **App-password ↔ drive binding** — store the chosen drive
|
||
> UUID directly on the `auth.app_passwords` row at issuance
|
||
> time. The Basic Auth username stays as `{user}` (clean
|
||
> NextCloud UX, no `~` to explain). The auth middleware looks
|
||
> up the app-password row, reads its `drive_id` binding, and
|
||
> uses that as the drive context. Each drive a user wants to
|
||
> sync gets its own app-password.
|
||
>
|
||
> Both are workable; option (2) is the cleaner UX (username
|
||
> matches what users type, no extra character to explain) but
|
||
> requires a schema add on `auth.app_passwords` and one extra
|
||
> JOIN in the hot auth path. Option (1) is the smallest possible
|
||
> change but exposes the `~` to the user. They're not mutually
|
||
> exclusive — the issuance flow can produce credentials in either
|
||
> shape. Decide before D1 ships which is the **default** the
|
||
> Login Flow v2 picker produces.
|
||
|
||
| URL | Resolves to |
|
||
|---|---|
|
||
| `/remote.php/dav/files/<username>/<path>` | That user's personal drive — unchanged. (App-password drive-binding NULL ⇒ personal.) |
|
||
| `/remote.php/dav/files/<username>~<drive-uuid>/<path>` | Option 1: explicit drive in the URL. |
|
||
| `/remote.php/dav/files/<username>/<path>` *(with `auth.app_passwords.drive_id` set)* | Option 2: drive resolved from the credential row. URL is indistinguishable from the unchanged personal case to the client. |
|
||
|
||
In either case, the auth middleware asserts the caller is a member
|
||
of the resolved drive before serving any DAV verb. Pre-existing NC
|
||
clients pointed at `/remote.php/dav/files/<username>/` continue
|
||
syncing the user's personal drive without reconfiguration —
|
||
regardless of which option is chosen as the default.
|
||
|
||
#### Username/UUID collision — defused
|
||
|
||
The `/remote.php/dav/files/<x>/` vs `/remote.php/dav/drives/<x>/`
|
||
split solves the worry about username/UUID ambiguity. The
|
||
discriminator is the literal segment (`files` vs `drives`), never
|
||
the value of `<x>`. A user happening to have a UUID-shaped username
|
||
is no longer a problem.
|
||
|
||
### 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 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 (uuid=…, kind=personal, default_for_user=admin)
|
||
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Personal") ← was "My Folder - admin"
|
||
├── Docs/
|
||
└── aa.pdf
|
||
```
|
||
|
||
Shared drives follow the same shape — drive + root folder + content
|
||
underneath:
|
||
|
||
```
|
||
Drive (uuid=…, kind=shared, owners=group:engineering)
|
||
└── root folder (parent_id=NULL, drive_id=<drive_uuid>, name="Engineering")
|
||
├── Specs/
|
||
├── Roadmap.md
|
||
└── archive/
|
||
```
|
||
|
||
One rule, one model: **every drive has exactly one folder where
|
||
`parent_id IS NULL` AND `drive_id = <the drive>`**.
|
||
|
||
#### Why this is a better model
|
||
|
||
Three things converge:
|
||
|
||
1. **No API duality.** Folder creation is always `POST /api/folders
|
||
{ name, parent_id: <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
|
||
|
||
The wrapper was already invisible to WebDAV / NC clients pre-drive:
|
||
|
||
- NC clients hit `/remote.php/dav/files/<user>/<path>` and
|
||
`nc_to_internal_path` prepended `My Folder - <user>/` internally
|
||
before talking to the storage layer. The client never saw the
|
||
wrapper segment in its URL.
|
||
- Native `/webdav/<path>` was implicitly chrooted to the user's
|
||
home by `resolve_webdav_path`. Same story.
|
||
|
||
Post-migration the resolver chroot becomes `<drive_root_folder.path>/`
|
||
instead of `My Folder - <user>/`. 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.
|
||
|
||
#### Uniqueness constraints become drive-scoped
|
||
|
||
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
|
||
|
||
Today the only way to get a `parent_id IS NULL` folder is via the
|
||
home-folder creation path, which produces exactly one per user.
|
||
However, manual SQL has already been used (and may still be used
|
||
by ops) to create additional top-level folders — folders that sit
|
||
beside `My Folder - <user>` at `parent_id IS NULL`. These are
|
||
**not** addressable today through any handler (the UI assumes
|
||
exactly one root); they exist as data only.
|
||
|
||
Post-migration, the model has to absorb them. Rule:
|
||
|
||
- For each user, find every row with `parent_id IS NULL AND user_id
|
||
= <this user>`.
|
||
- The one named `My Folder - <username>` becomes the **default
|
||
personal drive's root folder**: a new drive row is created with
|
||
`kind='personal'`, `default_for_user=<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=`<user>`.
|
||
- Every **other** sibling becomes a fresh **secondary personal
|
||
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:
|
||
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 — 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`
|
||
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.
|
||
|
||
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=<uuid>` 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`
|
||
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.
|
||
|
||
### 12. Trash — per-drive scoped, owner-actioned
|
||
|
||
Today trash is per-user: `storage.files` / `storage.folders`
|
||
carry `is_trashed BOOLEAN` + `trashed_at` + `original_parent_id`
|
||
(soft-delete in place), and `storage.trash_items` is a VIEW that
|
||
UNIONs them. The listing endpoint (`GET /api/trash/resources`)
|
||
filters by `user_id = caller`.
|
||
|
||
Post-drives, trash becomes **per-drive**:
|
||
|
||
- **Storage shape is unchanged.** The `drive_id` column added to
|
||
`storage.files` / `storage.folders` in Phase A already
|
||
identifies which drive a trashed row belongs to. No new
|
||
trash table, no schema work beyond updating the
|
||
`storage.trash_items` VIEW to surface `drive_id` alongside
|
||
(or replacing) `user_id`.
|
||
- **Trash listing query** filters by drive(s) the caller can
|
||
read. Default listing returns trash from every drive the
|
||
caller has membership on; a `?drive_id=<uuid>` parameter
|
||
scopes to one drive. UI shows a drive picker above the trash
|
||
list, same as the main file view.
|
||
- **Trash mutations are owner-only.** Per the §4 role-bundle,
|
||
`Delete` is in the owner bundle only — so today's "anyone
|
||
who can delete the original can act on its trash entry" is
|
||
already drive-owner-scoped. Specifically:
|
||
- **Send to trash** — any drive owner (carries `Delete`).
|
||
Personal drive: the user themselves.
|
||
- **Restore** — any drive owner. Operation reverses
|
||
`is_trashed`, sets `parent_id` back to `original_parent_id`
|
||
when that ancestor is still in the same drive (otherwise to
|
||
the drive root with a name conflict resolver).
|
||
- **Permanent delete** — any drive owner. Clears the row and
|
||
decrements drive `used_bytes`.
|
||
- **View trash** — any drive member (viewer / editor /
|
||
owner). Viewers can see what was deleted from a drive they
|
||
have access to; only owners can act on it. Mirrors Google
|
||
Drive's per-shared-drive trash UX.
|
||
- **Cross-drive moves carry their trash home with them**:
|
||
when a file moves from drive A → drive B and is later
|
||
trashed, the row's `drive_id` is B's, so trash for B sees
|
||
it (not A's, which is the natural and expected answer).
|
||
- **Cascade on drive deletion**: when a shared drive is
|
||
deleted (D3 will land the delete-drive flow), every
|
||
`storage.files` / `storage.folders` row with that
|
||
`drive_id` cascades, trashed or not. There's no need to
|
||
"drain the trash first" — the whole drive disappears in
|
||
one CASCADE.
|
||
- **Personal-drive trash** follows the same model: bound to
|
||
the personal drive, sole owner (= the user) does
|
||
everything. No new UX divergence between personal and
|
||
shared.
|
||
|
||
The orphan/aborted-upload sweep introduced in v0.7.0
|
||
(`944c8337`, periodic trash job) must become drive-aware so
|
||
it doesn't accidentally sweep across drives the caller
|
||
shouldn't see. It already keys off ownership; the rewrite is
|
||
a per-drive pass instead of per-user.
|
||
|
||
### 13. Upload paths and quota timing
|
||
|
||
Four upload protocols, each with different "when do we know the
|
||
size" and "when do we know the destination" answers. The Drive
|
||
migration pivots quota from user-scoped to drive-scoped without
|
||
changing protocol shapes — but one path (NC chunked) carries a
|
||
pre-existing wart that the chroot POC's `~` username (or the
|
||
`app_passwords.drive_id` binding) lets us finally fix.
|
||
|
||
| Protocol | Size known | Quota check fires | Destination / drive known |
|
||
|---|---|---|---|
|
||
| Default multipart (`POST /api/files/upload`) | At request start (Content-Length / multipart `size`). | `file_upload_service.rs:185` — `check_storage_quota(caller_id, metadata.size)` before any bytes are stored. | At request start (form field `folder_id`). Drive derives from `folder.drive_id`. |
|
||
| Native chunked (`POST /api/uploads`) | At session create — client declares `total_size` in JSON. | `chunked_upload_handler.rs:213` — at session creation against declared `total_size`. | At session creation (`folder_id` in JSON). Drive derives from `folder.drive_id`. |
|
||
| **NextCloud chunked** (`/remote.php/dav/uploads/{user}/{session}/...`) | Never declared. MKCOL creates empty session, PUT chunks arrive one at a time, client decides "done". | **Today: only at the final MOVE (assemble)** — `handle_assemble` → `file_upload_service::ingest_stream_to_cas` → quota check on the assembled size. Wasted-bandwidth wart: a client over quota can upload GB before the server can refuse. | **Today**: only at MOVE (parsed from the `Destination:` header). **With the chroot POC** (`{user}~{drive-uuid}` username, see §9): known at MKCOL — the auth middleware already split the username. **With `app_passwords.drive_id` binding**: known at MKCOL — the credential row pins the drive. |
|
||
| Delta protocol (`/api/files/delta/*`) | At `negotiate` (client provides manifest with `total_size`). | `delta_upload_service.rs:331` — at commit against `total_size`. | At negotiate (target file_id or `folder_id`). Drive derives accordingly. |
|
||
|
||
#### Decision — per-chunk incremental quota check on the NC chunked path
|
||
|
||
The three non-NC paths trivially pivot to drive-scoped quota:
|
||
replace `check_storage_quota(caller_id, size)` with
|
||
`check_drive_quota(drive_id, size)`. Destination is known at
|
||
handler entry; drive falls out of the destination's `drive_id`
|
||
column. No protocol change.
|
||
|
||
For NC chunked, the Drive migration **also closes the
|
||
wasted-bandwidth wart** because the drive identity is now known
|
||
at MKCOL (via `~` username or app-password binding — either NC
|
||
credential-side scheme from §9 surfaces it). Approach:
|
||
|
||
1. **MKCOL guard** — if `drive.used_bytes >= drive.quota_bytes`,
|
||
refuse the session creation with `507 Insufficient Storage`.
|
||
No point letting the client even start.
|
||
2. **Per-chunk PUT check** — track cumulative bytes received in
|
||
the session (sum of on-disk chunk sizes, maintained by the
|
||
chunked-uploads service). On each PUT, before writing the
|
||
chunk:
|
||
```text
|
||
if drive.used_bytes + session.bytes_so_far + chunk.size > drive.quota_bytes:
|
||
refuse with 507 Insufficient Storage
|
||
```
|
||
The first chunk that would push us over is refused; client
|
||
sees the error within one chunk's worth of wasted upload
|
||
(typically a few MB) instead of after the whole multi-GB file.
|
||
3. **Assemble-time re-check** stays as a defence-in-depth (in
|
||
case two concurrent sessions on the same drive each got past
|
||
the per-chunk check but their sum exceeds quota at MOVE). This
|
||
matches today's structure.
|
||
4. **Unlimited quota** (`quota_bytes IS NULL`) short-circuits all
|
||
three checks — no work.
|
||
|
||
The per-chunk check is O(1) amortised: each session tracks its
|
||
cumulative size as it goes. The drive's `used_bytes` is read from
|
||
the row once per chunk; with the v0.7.0 incremental-update
|
||
pattern (`b5b80549`, `d6987329`) that's a single primary-key
|
||
lookup, not an aggregate query.
|
||
|
||
Net effect on NC clients: nothing changes for in-quota uploads;
|
||
over-quota clients get a clear 507 within seconds instead of
|
||
after the whole upload finishes.
|
||
|
||
#### Editor-role delete and trash — call out the UX tension
|
||
|
||
§4's role bundle gives `Delete` only to `owner`. That means
|
||
in a shared drive, an editor who uploads a typo file CANNOT
|
||
send it to trash themselves — they have to ask an owner. This
|
||
is already flagged as Open Question 4 (revisit before D2) and
|
||
the answer there determines the trash UX for editors too. If
|
||
editors get a `Delete` capability (or a dedicated "trash own
|
||
content" capability), the trash mutation rules become "trash
|
||
your own files" + "drive owners can act on anyone's trashed
|
||
files". Until that's decided, the conservative answer above
|
||
(owner-only mutations) holds.
|
||
|
||
### 14. File provenance — `created_by` / `updated_by`
|
||
|
||
Today `storage.files.user_id` (and `storage.folders.user_id`)
|
||
quietly doubles as **both** the ownership pointer (whose files are
|
||
these?) AND the provenance signal (who created this?). The Drive
|
||
migration cleanly separates ownership onto `drive_id`, but
|
||
provenance has to come with us — in a shared drive where Ed,
|
||
Alice, and Bob all upload files, "who put this here?" remains a
|
||
load-bearing question for UI, audit, and account-cleanup workflows.
|
||
|
||
The split:
|
||
|
||
```sql
|
||
-- on both storage.folders and storage.files:
|
||
drive_id uuid NOT NULL -- ownership
|
||
created_by uuid NULL FK → auth.users(id) ON DELETE SET NULL
|
||
created_at timestamptz NOT NULL DEFAULT now() -- existing
|
||
updated_by uuid NULL FK → auth.users(id) ON DELETE SET NULL
|
||
updated_at timestamptz NOT NULL DEFAULT now() -- existing
|
||
```
|
||
|
||
- **`created_by`** is set once at row insert (every upload path:
|
||
multipart, native chunked, NC chunked, streaming CDC, delta,
|
||
instant-upload-by-hash) — the caller_id stamps it. Never
|
||
changes after.
|
||
- **`updated_by`** is set whenever `updated_at` is touched —
|
||
rename, overwrite, move, restore from trash, PROPPATCH on
|
||
favorites. Same write-path discipline as `updated_at` today.
|
||
- **`ON DELETE SET NULL`** on both FKs. When a user is deleted,
|
||
files they created or edited in shared drives stay (they belong
|
||
to the drive, not the deleter); the FK nulls out, the UI renders
|
||
"Unknown user" (or "Deleted user" with a small tombstone table —
|
||
future polish, see Open Question 12).
|
||
|
||
#### Why this lands in D0, not D7
|
||
|
||
If we waited until D7 to introduce these columns, every existing
|
||
file/folder row would have NULL `created_by` after the migration —
|
||
provenance permanently lost for pre-D7 content. Adding the columns
|
||
in D0 and **backfilling from `user_id`** during the dual-write
|
||
phase gives every existing row a real `created_by` value (the user
|
||
we know created it, because that's what `user_id` meant). New
|
||
writes during the dual-write window populate both `user_id` AND
|
||
`created_by` / `updated_by`. By D7 the columns are self-sufficient
|
||
and dropping `user_id` loses nothing.
|
||
|
||
#### UI display semantics
|
||
|
||
- File details panel: "Created by Alice on 2026-01-15", "Last
|
||
edited by Bob 3 hours ago" — both fields drive a normal user
|
||
avatar + name lookup. NULL → "Unknown user" placeholder.
|
||
- Activity log on a shared drive: aggregates `updated_by` over
|
||
recent rows to show "who's been active here lately".
|
||
- Account-cleanup workflow: when an admin deletes a user, show a
|
||
pre-flight summary "Bob authored 47 files and last edited 12
|
||
more across drives X, Y, Z — proceeding will mark those entries
|
||
as authored by 'Deleted user'." Lets the admin choose to
|
||
reassign or simply confirm.
|
||
|
||
#### Engine touch-point
|
||
|
||
The "who touched updated_at" rule applies uniformly across all
|
||
mutation paths. Centralise it: every service that bumps
|
||
`updated_at` must also set `updated_by = caller_id` in the same
|
||
SQL statement. The `FileRepository::update_*` and
|
||
`FolderRepository::update_*` methods are the natural choke points
|
||
— a single audit reveals every callsite to verify.
|
||
|
||
The async `tree_etag_queue` (v0.7.0) propagates an etag up the
|
||
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
|
||
every storage query. We phase it for safety:
|
||
|
||
### Phase A — additive (PR D0)
|
||
|
||
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 adoption sweep**: for each internal user,
|
||
list every `storage.folders` row where `parent_id IS NULL AND
|
||
user_id = <this user>`. Exactly one is expected to be
|
||
`My Folder - <username>`; any extras are SQL-created siblings
|
||
(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 - <username>` row → becomes the **default
|
||
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=<user>`, `quota_bytes=<user.storage_quota_bytes>`
|
||
(no `name` column).
|
||
- UPDATE the wrapper folder row: set `drive_id=<new drive>`
|
||
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=<wrapper row id>`.
|
||
- INSERT one `role_grants` row: subject=`<user>`,
|
||
`resource_type='drive'`, `resource_id=<new drive>`,
|
||
`role='owner'`.
|
||
- Every other sibling row → becomes a fresh **secondary
|
||
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). 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) — 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; unaffected.
|
||
- **On-disk storage mirror** — see Open Question 10. If the
|
||
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;
|
||
`user_id` is dual-written for one release as a safety net. If
|
||
something breaks we can roll back without data loss.
|
||
|
||
Pre-flight checks the migration script runs before any writes:
|
||
- Report how many sibling-root folders exist per user. Don't
|
||
refuse — extras become drives — but surface the count so an
|
||
operator can sanity-check ("Ed has 4 root folders, expected
|
||
≤1; verify those are real and intended before promoting them
|
||
to drives").
|
||
- Refuse if any sibling root folder is literally named `drives`
|
||
(would collide with the reserved URL segment on the native
|
||
`/webdav/drives/<uuid>/...` surface). Operator renames first.
|
||
- Refuse if any user has `storage_used_bytes > storage_quota_bytes`
|
||
by an amount that wouldn't fit the destination drive's quota
|
||
semantics (sanity check).
|
||
|
||
### Phase B — cleanup (PR D7, one release later)
|
||
|
||
1. Drop `user_id` from `storage.folders` and `storage.files`.
|
||
2. Drop dual-write code paths.
|
||
3. Deprecate `auth.users.storage_quota_bytes` (or drop — quotas live
|
||
on drives now).
|
||
|
||
Phase B is the point of no return; deferring it by one release gives
|
||
us a real rollback window while the new model bakes in production.
|
||
|
||
## PR sequencing
|
||
|
||
| PR | Scope | Risk |
|
||
|---|---|---|
|
||
| **D-Prep — role_grants refactor** | `access_grants → role_grants` schema migration with role-bundle semantics. `Manage` Permission added to the enum + role bundle. Engine reads role_grants only; `access_grants` removed (after one dual-write release if compat is needed). API gains `role` parameter on grant endpoints; audit log emits one `role_grant.*` event per role assignment instead of N permission events. **No Drive concept yet.** Sets the foundation that all subsequent PRs build on. **Data shape confirmed**: empirical audit shows >99% of existing `access_grants` rows already cluster into the standard bundles (viewer/editor/owner) — the migration is mechanical for the vast majority of data; the <1% edge cases get absorbed by shipping `commenter` and `contributor` roles on day one or get an explicit per-row migration decision logged. | **Medium** — touches the load-bearing authorisation table, but the data shape removes the main migration risk |
|
||
| **D0 — foundation** | `storage.drives` schema (no `drive_members` — uses `role_grants` from D-Prep); `Drive` domain entity; migration creating personal drives + backfilling `drive_id` on every resource; read-only `GET /api/drives` listing the caller's drives (single query: `SELECT … FROM role_grants WHERE subject_id=$caller AND resource_type='drive'`). Dual-write `user_id` alongside `drive_id` for safety. **No new UI.** **Every upload path stamps `drive_id` at insert**: classic multipart (`file_handler::upload`), chunked NC (`uploads_handler`), streaming CDC (`upload_ingest`), delta upload (`delta_upload_service`), instant upload by hash. Tantivy reindex (see §11) is part of this PR. **Provenance columns added** (see §14): `created_by` and `updated_by` on both `storage.folders` and `storage.files`, FK to `auth.users` with `ON DELETE SET NULL`; backfilled from `user_id` so pre-Drive content has provenance from day one; every mutation path that touches `updated_at` also sets `updated_by`. | **High** — every storage query touches, all upload paths touched |
|
||
| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive/<uuid>/<folder-id>` frontend routes, default-drive redirect from `/`. WebDAV path dispatcher recognising `drives/<uuid>` as the drive-explicit prefix on both `/webdav/` and `/remote.php/dav/`. | Medium |
|
||
| **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium |
|
||
| **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low |
|
||
| **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium |
|
||
| **D5 — policies** | JSONB policies column + enforcement at the four known callsites. Owner-only UI in drive settings. Ship policies one at a time if you want fine-grained rollout — `forbid_public_links` first (lowest risk), then `forbid_external_sharing`, then `forbid_sharing`, then `forbid_cross_drive_move`. | Low |
|
||
| **D6 — cross-drive move + audit** | Move folder/file between drives (allowed by default; gated by `forbid_cross_drive_move` policy on the source drive). Audit events for every drive lifecycle event (`drive.created`, `drive.member_added`, `drive.member_removed`, `drive.policy_changed`, `drive.deleted`, `resource.moved_between_drives`). | Low |
|
||
| **D7 — back-compat sweep** | Drop `user_id` from `storage.folders` / `storage.files`. Drop dual-write code. Drop or deprecate `auth.users.storage_quota_bytes`. **Provenance columns (`created_by`, `updated_by`) stay** — they were populated from D0 and are now the sole source of authorship signal. | Low — but the point of no return |
|
||
|
||
Approximate total: 4–6 weeks of focused work, depending on test
|
||
coverage depth.
|
||
|
||
## Out of scope for v1 (worth noting so we don't accidentally invite scope creep)
|
||
|
||
- **Timeboxed session policy** — drives that auto-lock after N
|
||
minutes. Big middleware lift.
|
||
- **End-to-end encryption policy** — client-side encryption with
|
||
server holding only ciphertext. Massive scope.
|
||
- **Per-drive sync targets** — sync client design ("1 drive = 1
|
||
target like 'family-computer'"). Schema-friendly today (drive has
|
||
stable UUID), implementation is its own design.
|
||
- **Cross-drive search UI** — start with per-drive scoping. "Search
|
||
everywhere" comes later.
|
||
- **Drive templates** — "create a drive pre-populated with these
|
||
folders" is nice but not foundation work.
|
||
<!--
|
||
Per-drive trash was originally listed as deferred until D7. It is
|
||
now in-scope and ratified in §12 — moved into D2 (trash permission
|
||
checks become drive-role-aware once the membership API lands).
|
||
The storage cost is near-zero because trash is soft-delete-in-
|
||
place on `storage.files` / `storage.folders`, which already carry
|
||
`drive_id` after Phase A.
|
||
-->
|
||
|
||
|
||
## Open questions to revisit before D0 lands
|
||
|
||
1. **Naming of the default personal drive**.
|
||
- Locked: "Personal" (i18n key, neutral, doesn't break on username
|
||
rename).
|
||
- But: should it be renameable by the user? Default-rename allowed?
|
||
I'd say yes — drive name is just a label.
|
||
|
||
2. **`auth.users.storage_quota_bytes` — drop or keep?**
|
||
- Drop entirely (cleanest).
|
||
- Keep as the **default initial quota** for newly-created personal
|
||
drives (so admins still have a single tunable).
|
||
- I lean toward keep-as-default-initial.
|
||
|
||
3. **Can a user be a member of their own personal drive in more than one
|
||
way?** (e.g. directly AND via a group they belong to)
|
||
- Should the membership rows allow that? Or dedup?
|
||
- Easy answer: allow; permission resolution naturally unions, so
|
||
duplicate paths to the same role are idempotent.
|
||
|
||
4. **What happens when you DELETE a folder/file in a shared drive
|
||
you have only editor role on?**
|
||
- Today: ReBAC checks Delete permission on the resource.
|
||
- With drives: editor role implies Create + Update + Comment but
|
||
**not** Delete (per the role-bundle table above).
|
||
- So editor cannot delete by default. Should they? Some teams want
|
||
"editor can do anything except change drive settings". Worth a
|
||
dedicated decision before D2 — possibly add a separate "can
|
||
delete" toggle, or split editor into `editor` / `contributor`.
|
||
|
||
5. **Drive icons / colour customisation** — visual differentiator
|
||
between drives in the sidebar. Out of scope for v1 but worth
|
||
noting; the `drives` table can carry a small `display` JSONB column
|
||
that accumulates this kind of cosmetic config without schema
|
||
churn.
|
||
|
||
6. **WebDAV path resolution edge case**: when a user is a member of a
|
||
shared drive and someone shares a single file inside it explicitly
|
||
with them via a per-resource grant, what path do they see in their
|
||
client?
|
||
- The drive's path (they have access via membership), full stop.
|
||
- Per-resource grants are additive; they don't create a separate
|
||
"shared with me" listing for files that already live in a drive
|
||
the user can access.
|
||
- Confirm during D2 that this is what users expect.
|
||
|
||
7. **Search-everywhere from the file picker**. When a user goes to
|
||
share a file, today the picker lists their own files. With drives,
|
||
should the picker default to "current drive" or "all drives I have
|
||
access to"? UX call, not a foundation decision.
|
||
|
||
8. **`async_tree_etag_queue` — drive-pinning assumption audit**.
|
||
v0.7.0 introduced `migrations/20260626000000_tree_etag_statement_triggers.sql`
|
||
+ `20260627000000_async_tree_etag_queue.sql` for ETag
|
||
propagation up folder ancestry. The queue likely keys off
|
||
folder path / owner; before D0 starts, confirm it doesn't
|
||
embed assumptions that fold `drive_id` away (e.g. computing
|
||
ancestry across a path that crosses a drive boundary).
|
||
5-minute audit to lock the question down.
|
||
|
||
9. **NC credential ↔ drive binding default**. Section 9 leaves
|
||
open whether the Login Flow v2 picker defaults to issuing
|
||
`{user}~{uuid}` Basic Auth usernames (option 1) or
|
||
`auth.app_passwords.drive_id`-bound app-passwords (option 2)
|
||
for non-personal drives. Decide before D1 ships; the answer
|
||
determines whether `auth.app_passwords` gets a new column or
|
||
not.
|
||
|
||
10. **On-disk storage mirror — does the file path under
|
||
`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 (`<storage>/<user_id>/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/<hash[..2]>/<hash>` and is
|
||
already wrapper-agnostic; the concern is only the
|
||
metadata-projection / thumbnail-cache trees if they're
|
||
path-shaped.
|
||
|
||
11. **Promoting sibling root folders — UX after migration**.
|
||
Users whose home was a single `My Folder - <username>` end
|
||
up with one drive (Personal). Users with SQL-added siblings
|
||
end up with multiple drives, one named per the original
|
||
folder name. The drive name carries over verbatim — should
|
||
the migration log who got more than one drive, so an
|
||
operator can DM them and explain the new picker? Operational
|
||
nicety, not a correctness concern.
|
||
|
||
12. **Deleted-user tombstone for provenance**. §14 sets
|
||
`created_by` / `updated_by` to NULL when the referenced user
|
||
is deleted (`ON DELETE SET NULL`). The UI then shows "Unknown
|
||
user", which is correct but information-poor — for compliance
|
||
/ audit / "who left this?" purposes, a small `deleted_users`
|
||
tombstone table (`id, last_known_username, deleted_at`) would
|
||
let the UI render "Bob (deleted 2026-04-12)" instead of just
|
||
"Unknown user". Out of scope for v1 but easy to add later
|
||
without schema rework (NULL `created_by` stays NULL; a
|
||
parallel lookup against the tombstone table on display).
|
||
|
||
## Existing code to reuse
|
||
|
||
- **ReBAC `Resource` enum** at `src/domain/services/authorization.rs:74`
|
||
already has a tagged-union shape; adding `Drive(Uuid)` is one variant
|
||
+ `from_parts` arm + `type_str` arm.
|
||
- **ReBAC `Permission` enum** already has the bundle we need
|
||
(`Read`, `Create`, `Update`, `Delete`, `Share`, `Comment`). The
|
||
role-to-bundle mapping for drives lives in a new `drive_role.rs`
|
||
helper.
|
||
- **`PgAclEngine` Moka cache** at
|
||
`src/infrastructure/services/pg_acl_engine.rs:88` handles ReBAC
|
||
grant caching. The drive-role check piggybacks on the same cache
|
||
with a separate keyspace (`drive_perm:<user>:<drive>:<perm>`).
|
||
- **`SubjectGroupService::list_transitive_users`** at
|
||
`src/application/services/subject_group_service.rs:385` already
|
||
expands a group to its transitive user members. The drive-membership
|
||
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 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
|
||
`src/interfaces/api/handlers/webdav_handler.rs:188` are the two
|
||
callsites that learn about drives. Both gain a "drive context"
|
||
parameter resolved from the URL prefix (`/files/<u>/` or
|
||
`{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives/<uuid>/`
|
||
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
|
||
respective callsites.
|
||
- **Migration timestamp convention**: as of v0.7.0 the head
|
||
migration is `20260702000000_drop_dead_file_indexes.sql`, and
|
||
`20260701000000_content_search_index.sql` already exists (the
|
||
Tantivy index). D0's migration becomes `20260801000000_drives.sql`
|
||
(or whatever date D0 actually starts) — the original
|
||
`20260701000000_drives.sql` slot is taken.
|
||
|
||
## Verification
|
||
|
||
Each PR carries its own verification block. The bar across all of
|
||
them: **(a)** new behaviour proven by a focused test, **(b)** the
|
||
existing Hurl + Playwright baselines still pass green (`bash
|
||
tests/api/run.sh && bash tests/webdav/run.sh && cd tests/e2e && npm
|
||
test`), **(c)** `cargo fmt && cargo clippy --all-features
|
||
--all-targets -- -D warnings` clean.
|
||
|
||
### D-Prep
|
||
- **Unit**: `role_bundle(role)` returns the expected permission set
|
||
for each role; `roles_implying(permission)` returns the expected
|
||
role list; round-trip a grant insert→read→compare for every role.
|
||
- **Data audit**: query `access_grants` and confirm that >99% of
|
||
rows cluster into the standard bundles (the empirical figure that
|
||
unlocked Sequence A). Log the <1% edge cases per row for review.
|
||
- **Migration round-trip**: roll forward against a populated DB →
|
||
every prior `access_grants` row is represented by exactly one
|
||
`role_grants` row → roll back → original `access_grants` rows
|
||
recovered byte-identically.
|
||
- **API**: new Hurl test `tests/api/role_grants.hurl` —
|
||
- `POST /api/grants` with `role='editor'` creates a single row
|
||
with the expected bundle when expanded.
|
||
- `PUT /api/grants/{id}` with `role='viewer'` is atomic (no race
|
||
window where the user has zero permissions).
|
||
- Compat shim: `POST /api/grants` with the legacy `permission`
|
||
field still works for one release and maps to the closest role.
|
||
- Audit log emits `role_grant.created`, `role_grant.role_changed`,
|
||
`role_grant.revoked` events with the role name carried through.
|
||
- **UI smoke (Playwright)**: My Shares dialog renders roles instead
|
||
of permission checkboxes; share modal preset buttons map to roles;
|
||
changing a member's role from editor → viewer fires exactly one
|
||
PATCH and the UI reflects the change without a race-window blank
|
||
state.
|
||
- **All existing Hurl + WebDAV + Playwright tests still pass** — the
|
||
refactor must be invisible to every non-grant-handling test.
|
||
|
||
### D0
|
||
- **Unit**: `Drive` entity (kind + `default_for_user` CHECK
|
||
constraints), `default_for_user` partial unique index. Personal-
|
||
drive `add_member` / `remove_member` / `delete_drive` all refuse
|
||
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 drive has `root_folder_id IS NOT NULL` and the row it
|
||
points at has `parent_id IS NULL AND drive_id = <self>` (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 - <username>` 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 - <username>` (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`;
|
||
search query filtered by `drive_id ∈ caller's drives` returns the
|
||
expected hits; cross-drive isolation confirmed (search as Alice,
|
||
Bob's content never appears).
|
||
- **API**: `tests/api/drives_foundation.hurl` — admin lists
|
||
`/api/drives`, sees their default personal drive with the correct
|
||
quota, `kind='personal'`, `default_for_user` matching their own
|
||
uuid. Creating a folder still works; the folder's `drive_id`
|
||
matches the personal drive.
|
||
- **All upload paths set `drive_id`**: targeted Hurl tests for
|
||
multipart, native chunked, NC chunked, delta upload, instant
|
||
upload by hash. Each verifies the resulting file row has the
|
||
expected `drive_id`.
|
||
|
||
### D1
|
||
- **Routing**: `cargo build` clean; WebDAV dispatcher routes
|
||
`/webdav/drives/<uuid>/...` correctly; `/webdav/<path>` still
|
||
resolves to the caller's default drive (back-compat).
|
||
- **NC client back-compat**: a real NC sync client pointed at
|
||
`/remote.php/dav/files/admin/` continues syncing the user's default
|
||
personal drive without reconfiguration. The chroot POC's `~`
|
||
username (or app-password binding) lands a sync into the chosen
|
||
drive transparently.
|
||
- **Manual smoke**: open `/`, get redirected to
|
||
`/drive/<default-uuid>`. Click sidebar drive switcher → URL
|
||
updates, listing reloads. Drive picker shows all of the caller's
|
||
drives (default first), each with its quota usage.
|
||
- **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises
|
||
sidebar → URL → listing → cross-drive isolation (folders in
|
||
drive A don't appear in drive B's listing).
|
||
|
||
### D2
|
||
- **Membership API**: `POST /api/drives/{id}/members` with user, with
|
||
group, and with role changes; refuses on personal drives;
|
||
shared-drive last-owner protection. Group expansion (transitive
|
||
members count toward the owner total).
|
||
- **Trash per drive**: trash listing scopes correctly to drive(s)
|
||
the caller can read; mutations refuse without owner role; the
|
||
`storage.trash_items` VIEW surfaces `drive_id`.
|
||
- **Updated NC + WebDAV regression baselines** — the 105+ scenarios
|
||
in `BASELINE_TESTS_NC_WEBDAV.md` still pass green; per-drive
|
||
trash and membership changes don't regress existing protocol
|
||
behaviour.
|
||
|
||
### D3–D7
|
||
- Per-PR verification authored when the PR is drafted. Each PR adds
|
||
at least one new Hurl/Playwright test for the headline capability
|
||
and proves zero regression on the previous baselines.
|
||
|
||
### Cross-cutting regression — runs on every Drive PR
|
||
|
||
A standing checklist independent of the headline capability of each
|
||
PR:
|
||
|
||
1. `bash tests/api/run.sh` green (>100 scenarios).
|
||
2. `bash tests/webdav/run.sh` green (NC + native WebDAV baselines).
|
||
3. `cd tests/e2e && npm test` green (Playwright).
|
||
4. `tests/api/storage_cleanup_check.sh` clean.
|
||
5. No new `cargo clippy` warnings.
|
||
6. Tantivy index returns no cross-drive results for any caller.
|
||
7. `/api/dedup/stats` shows blob ref-counts consistent with the
|
||
number of files referencing each blob across all drives.
|
||
|
||
## UI design — outline for D1 and D3
|
||
|
||
The Drive concept reshapes three load-bearing UI elements. Each
|
||
gets a small design pass alongside the relevant PR — the bullets
|
||
here lock the **intended shape**; the pixel work happens when D1 /
|
||
D3 draft.
|
||
|
||
### Sidebar — drives as children of a "Drive" section
|
||
|
||
Today the sidebar shows a single root view (the user's home
|
||
folder). Post-Drive it gains a top-level **Drive** section whose
|
||
children are the drives the caller has access to:
|
||
|
||
```
|
||
📁 Drive
|
||
⭐ Personal ← kind='personal', default_for_user=caller (★ marks default)
|
||
Family Archive ← kind='personal', secondary (no star)
|
||
Engineering ← kind='shared', caller is owner/editor/viewer
|
||
2025 Marketing Sprint ← kind='shared'
|
||
Recent
|
||
Favorites
|
||
Shared with me
|
||
Trash
|
||
```
|
||
|
||
- The user's **default personal drive** is marked with a star (or
|
||
bolded — UI choice). Clicking it lands on the default landing
|
||
view (today's "Files" experience).
|
||
- Other drives — secondary personals, shared drives — appear below
|
||
with the same visual weight. Clicking switches the context (URL
|
||
changes to `/drive/<uuid>/...`, listing reloads).
|
||
- The sidebar is collapsible per-drive; users with many drives can
|
||
hide the drives they aren't actively in.
|
||
- **No drive icons / colours in v1** (deferred to a future polish
|
||
PR — see "Out of scope" item 5).
|
||
- The "Drive" section header itself is non-interactive (just a
|
||
grouping label); the action affordance is "Create shared drive"
|
||
via the `+` button at the end of the list (admin / group-owner
|
||
scoped; lands in D3).
|
||
|
||
### Breadcrumb — drive-rooted
|
||
|
||
Today's breadcrumb is path-rooted: `Home / Docs / Q3 / Report.pdf`.
|
||
Post-Drive the breadcrumb starts at the selected drive:
|
||
|
||
```
|
||
[Personal ▾] / Docs / Q3 / Report.pdf
|
||
```
|
||
|
||
- The drive name is the **first** element. Clicking it returns to
|
||
that drive's root listing.
|
||
- The `▾` chevron next to the drive name opens a quick-switcher
|
||
picker (same list as the sidebar). Lets users jump between drives
|
||
without using the sidebar.
|
||
- For paths inside shared drives, the drive name still leads:
|
||
`[Engineering ▾] / Specs / Q3 OKRs.md`.
|
||
- The breadcrumb's overflow / truncation behaviour for deep paths is
|
||
unchanged from today — only the root element is new.
|
||
- **Drive name follows server-side rename** — the breadcrumb queries
|
||
the drive's current `name`; renaming the drive updates the
|
||
breadcrumb on next reload without any client-side cache work.
|
||
|
||
### "Owners" section — review and redesign
|
||
|
||
Today's UI has an "Owner" surface in several places (file details
|
||
sidebar, share dialog, folder properties). It currently shows "the
|
||
user who owns this file" — a single name.
|
||
|
||
Post-Drive, ownership has multiple shapes:
|
||
|
||
- For a file in a personal drive: still one owner (the drive's sole
|
||
user-owner). Display stays the same — "Owner: Ed".
|
||
- For a file in a shared drive: ownership is the drive's **owner-
|
||
role members** (possibly multiple users + groups). Display
|
||
becomes "Owner: Engineering team (3 members + 2 groups)".
|
||
- For a drive itself (when displayed in a settings view): same as
|
||
above, but in a list form: "Owners: Ed, Alice, Engineering
|
||
group".
|
||
|
||
Open questions for the D3 PR to settle:
|
||
- **Show the drive name as the effective owner?** "Owner:
|
||
Engineering" reads cleanly but conflates "the drive" with "the
|
||
drive's owners" — clearer for end users, less precise.
|
||
- **Should the file details sidebar expand owners on click?**
|
||
Click "Engineering team" → see the owner roster. Useful for
|
||
large drives where the owner list doesn't fit inline.
|
||
- **Audit/My Shares dialog**: the "Shared with me" view currently
|
||
groups by sharer. Post-Drive it can group by drive instead
|
||
(your shared-drive access shows once per drive, not once per
|
||
file). UX win — decide which grouping is default and whether
|
||
both are togglable.
|
||
|
||
These three UI surfaces are independent enough that they can ship
|
||
in separate PRs (sidebar in D1, breadcrumb in D1, owners review
|
||
in D3 alongside the create-shared-drive flow). The sidebar and
|
||
breadcrumb are essentially mechanical given the new model; the
|
||
owners review is the only one with genuine design questions.
|
||
|
||
## File map (anticipated, not yet created)
|
||
|
||
```
|
||
migrations/
|
||
20260730000000_role_grants.sql ← D-Prep (rename of access_grants
|
||
to role_grants with role bundles)
|
||
20260801000000_drives.sql ← D0 (drives table only;
|
||
membership is rows in role_grants)
|
||
20260901000000_drop_user_id_on_resources.sql ← D7
|
||
|
||
src/domain/entities/
|
||
drive.rs ← D0
|
||
role.rs ← D-Prep (role enum + bundle map)
|
||
|
||
src/domain/services/
|
||
authorization.rs (modify) ← D-Prep adds `Manage` Permission +
|
||
`Resource::Drive(Uuid)` variant
|
||
|
||
src/application/services/
|
||
drive_service.rs ← D0–D3 (CRUD + policy enforcement,
|
||
membership operations translate
|
||
to role_grants writes)
|
||
|
||
src/infrastructure/repositories/pg/
|
||
drive_pg_repository.rs ← D0
|
||
role_grant_pg_repository.rs ← D-Prep (replaces
|
||
access_grant_pg_repository)
|
||
|
||
src/infrastructure/services/pg_acl_engine.rs (modify)
|
||
reads role_grants only ← D-Prep
|
||
Resource::Drive routing ← D0
|
||
|
||
src/interfaces/api/handlers/
|
||
grant_handler.rs (modify) ← D-Prep (accepts role parameter)
|
||
drive_handler.rs ← D0 (list), D2 (members), D3 (create/delete shared)
|
||
|
||
src/interfaces/api/handlers/webdav_handler.rs (modify)
|
||
drive-aware path resolution ← D1
|
||
|
||
src/interfaces/nextcloud/webdav_handler.rs (modify)
|
||
drive-aware path resolution ← D1
|
||
|
||
static/js/views/drive/ ← D1 (sidebar switcher, drive view)
|
||
static/js/model/drives.js ← D1 (REST client)
|
||
static/css/components/driveSwitcher.css ← D1
|
||
```
|
||
|
||
## Glossary (for the next reader)
|
||
|
||
- **Drive** — a top-level container that owns folders and files.
|
||
- **Personal drive** — the auto-created drive a user gets at
|
||
registration. One per internal user. `kind='personal'`.
|
||
- **Shared drive** — a drive whose membership includes at least one
|
||
group (or multiple users). `kind='shared'`.
|
||
- **Drive member** — a row in `storage.role_grants` with
|
||
`resource_type='drive'`, carrying a subject (user or group)
|
||
and a role. There is **no** separate `drive_members` table — see
|
||
the D-Prep prerequisite at the top of this plan for the storage
|
||
pivot.
|
||
- **Owner / editor / viewer** — drive roles. Map to ReBAC
|
||
permission bundles for resources inside the drive.
|
||
- **Drive policy** — JSONB key on the drive that toggles a sharing /
|
||
movement restriction.
|
||
- **Drive context** in WebDAV — the drive whose root the request is
|
||
rooted at. Native WebDAV resolves it from the URL prefix
|
||
(`/webdav/<path>` → caller's personal drive,
|
||
`/webdav/drives/<uuid>/<path>` → explicit drive). NC resolves
|
||
it from the credential (Basic Auth `{user}~{uuid}` username, or
|
||
the `auth.app_passwords.drive_id` binding — see §9).
|
||
- **Wrapper folder** — historical name for
|
||
`My Folder - <username>`, the folder created at registration
|
||
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.
|