feat(drive): add /api/drive

- permit shared drive creation from oxicloud admin (for now)
    - prepare other personal drive creation (Not implemented), need to validate
    quota policies and strategy first
    - add hurl test to verify permissions
This commit is contained in:
Edouard Vanbelle
2026-06-23 23:16:02 +02:00
parent 184520c17a
commit a5b24a7453
11 changed files with 1181 additions and 28 deletions
+613 -5
View File
@@ -1,10 +1,7 @@
# =============================================================
# OxiCloud — D2 drive membership API + delegation + caller_role
# OxiCloud — D2/D3a drive membership + create-drive end-to-end
# =============================================================
# Verifies the D2 membership surface on personal drives (the only
# drive kind today — shared-drive positive cases land alongside D3's
# create endpoint):
#
# D2 coverage (steps 1-12, personal-drive-only world):
# 1. `GET /api/drives` exposes `caller_role` on every row.
# 2. `GET /api/drives/{id}/members` lists role grants on a drive
# (one Owner row for the lifecycle-hook-provisioned default).
@@ -20,6 +17,38 @@
# 6. Anti-enum: an unrelated user gets the same `404` for a drive
# they can't read, whether or not it exists.
#
# D3a coverage (steps 13-22, unlocked by `POST /api/drives`):
# - admin-only authz gate on create
# - kind=personal returns 501 (placeholder for the future PR)
# - Owner subject = user → single Owner shared drive
# - Owner subject = group with members → group-mediated Owner
# - Owner subject = empty group → 400 (no orphan Owner)
# - Token subject refused
# - Last-owner protection on the new shared drive
# - Group-mediated Owner: caller_role resolves the strongest role
# (MIN over direct + group grants)
# - Editor cascade through the drive precheck (Bob gets Editor on
# a shared drive via the membership API and sees the drive)
# - Role demotion: PATCH Bob from Editor to Viewer reflects in his
# listing
#
# Per-role mutation matrix coverage (steps 23-29):
# - Owner CAN rename the drive (positive symmetry)
# - Owner CAN edit owners / editors / viewers (grant Owner, promote
# and demote across all role boundaries)
# - Viewer CANNOT POST / PATCH / DELETE members → 404
# - Editor CANNOT POST / PATCH / DELETE members → 404
# - Editor CAN modify drive content (positive role-bundle check)
# - Viewer CAN read drive content (positive role-bundle check)
# - Non-member sees 404 on every member-mutation verb AND on
# GET /members (anti-enum: no existence leak)
#
# **Known gap** surfaced by Step 26d: today's folder rename uses
# `Permission::Update`, which is in Editor's bundle. The plan
# (`drive.md §6`) says drive rename should be Owner-only. If/when
# tightening: change the folder service to require `Manage` (or a
# new `RenameDrive` permission) for folders that are drive roots.
#
# Self-contained: provisions its own users so it can run after
# drives_foundation without aliasing state.
# =============================================================
@@ -216,3 +245,582 @@ GET {{base_url}}/api/drives/00000000-0000-0000-0000-000000000000/members
Authorization: Bearer {{alice_token}}
HTTP 404
# =============================================================
# D3a — POST /api/drives (create shared drive)
# =============================================================
# Below covers the create-shared-drive endpoint + the role-bundle
# tests that were deferred until shared-drive creation was wirable:
#
# - admin-only authz gate
# - kind=personal returns 501 (placeholder)
# - Owner subject = user → single Owner shared drive
# - Owner subject = group with members → group-mediated Owner
# - Owner subject = empty group → 400 (no orphan Owner)
# - Token subject refused
# - Editor cascade: drive Owner can mutate content in the new drive
# - Last-owner protection on removal
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 13 — Non-admin caller refused with 403.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "should-not-exist",
"owner": { "type": "user", "id": "{{alice_user_id}}" }
}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 14 — kind=personal returns 501 (wire-shape placeholder).
# The body is accepted as valid JSON; the rejection is
# explicit at the service layer.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "personal",
"name": "side-private",
"owner": { "type": "user", "id": "{{alice_user_id}}" }
}
HTTP 501
# ─────────────────────────────────────────────────────────────
# Step 15 — Create a shared drive with a single user owner
# (Alice). The new drive lands with kind=shared,
# default_for_user=NULL, and Alice as the sole Owner.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "alice-shared",
"owner": { "type": "user", "id": "{{alice_user_id}}" }
}
HTTP 201
[Asserts]
jsonpath "$.kind" == "shared"
jsonpath "$.name" == "alice-shared"
jsonpath "$.default_for_user" not exists
jsonpath "$.used_bytes" == 0
[Captures]
alice_shared_drive_id: jsonpath "$.id"
# Alice now sees TWO drives — her default Personal + the new shared.
# Caller_role on the shared drive is "owner" (her user grant).
GET {{base_url}}/api/drives
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$" count == 2
jsonpath "$[*].id" contains {{alice_shared_drive_id}}
# ─────────────────────────────────────────────────────────────
# Step 16 — Last-owner protection: Alice is the sole Owner of
# the new shared drive. Removing her grant must refuse.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 400
# And the demotion form: PATCH her role to editor → same refusal.
PATCH {{base_url}}/api/drives/{{alice_shared_drive_id}}/members/user/{{alice_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "editor" }
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 17 — Empty group is refused (would orphan the drive's Owner).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/groups
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "empty-grp-for-drive" }
HTTP 201
[Captures]
empty_group_id: jsonpath "$.id"
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "should-not-exist",
"owner": { "type": "group", "id": "{{empty_group_id}}" }
}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 18 — Empty name is refused (basic validation).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": " ",
"owner": { "type": "user", "id": "{{alice_user_id}}" }
}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 19 — Token subject is refused (drives can't be owned by
# share-link tokens).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "should-not-exist",
"owner": { "type": "token", "id": "00000000-0000-0000-0000-000000000099" }
}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 20 — Group-mediated Owner: create a group, add Alice, then
# create a shared drive with the group as Owner. Alice
# should see the new drive in her listing with
# caller_role="owner" (resolved through the group).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/groups
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "name": "drive-grp-with-alice" }
HTTP 201
[Captures]
alice_group_id: jsonpath "$.id"
POST {{base_url}}/api/groups/{{alice_group_id}}/members
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "user_id": "{{alice_user_id}}" }
HTTP 201
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "team-drive",
"owner": { "type": "group", "id": "{{alice_group_id}}" }
}
HTTP 201
[Captures]
team_drive_id: jsonpath "$.id"
team_root_folder_id: jsonpath "$.root_folder_id"
# Alice's drive listing now includes the team drive with caller_role=owner.
# `MIN(role)` over (direct grants + group-mediated grants) resolves Owner.
GET {{base_url}}/api/drives
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains {{team_drive_id}}
# ─────────────────────────────────────────────────────────────
# Step 21 — Editor cascade through the drive precheck. Add a fresh
# user (mbr_bob) as Editor on the team drive; he should
# be able to read the drive root and create folders in it
# via the drive's Editor permission bundle, without any
# per-folder grant.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "mbr_bob", "password": "MbrBobPassword1!", "email": "mbr_bob@example.com", "role": "user" }
HTTP 201
[Captures]
bob_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "mbr_bob", "password": "MbrBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# Bob has no role on the team drive → drive doesn't appear in his listing.
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains {{team_drive_id}}
# Admin (well — Alice as drive Owner; admin would also work) grants Bob Editor.
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"role": "editor"
}
HTTP 201
# Bob now sees the drive with caller_role=editor.
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains {{team_drive_id}}
# ─────────────────────────────────────────────────────────────
# Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct
# grant (would lower his bundle). The collapsed caller_role
# must remain Editor (the stronger of his two grants).
# ─────────────────────────────────────────────────────────────
# Demote Bob to Viewer via PATCH — first ensure he was editor before
# (already confirmed via the GET above).
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 200
[Asserts]
jsonpath "$.role" == "viewer"
# Bob's listing now reflects the demotion.
GET {{base_url}}/api/drives
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" contains {{team_drive_id}}
# =============================================================
# Per-role mutation matrix — what every role can / can't do
# =============================================================
# Setup state at this point:
# - team_drive owners: alice (via alice_group) — sole Owner role grant
# - team_drive Viewer: bob (user grant after Step 22 demotion)
#
# Steps 23-29 cover the per-role authorization matrix on member
# management + drive rename + content R/W. Anti-enum: every refusal
# returns 404 (not 403) so an unauthorised caller can't enumerate the
# difference between "drive doesn't exist" and "you can't manage it".
# ─────────────────────────────────────────────────────────────
# Step 23 — Owner CAN rename the drive.
# Drive name lives on its root folder per drive.md §6,
# renamed via PUT /api/folders/<root_folder_id>/rename.
# Alice's Owner role (via her group) carries Update, so
# the engine drive precheck grants the rename.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "team-drive-renamed" }
HTTP 200
[Asserts]
jsonpath "$.name" == "team-drive-renamed"
# The new name surfaces on the drive listing too — drive.name is
# sourced from the root folder per DriveDto::From<DriveWithRootName>.
GET {{base_url}}/api/drives
Authorization: Bearer {{alice_token}}
HTTP 200
[Asserts]
# Filter expressions in Hurl: `[?(...)]` collapses to a scalar when there's
# exactly one match — list-style predicates like `includes` / `contains` then
# fail with a type mismatch. So we assert string equality instead.
jsonpath "$[?(@.id=='{{team_drive_id}}')].name" == "team-drive-renamed"
# ─────────────────────────────────────────────────────────────
# Step 24 — Owner CAN edit owners, editors, and viewers.
# Grant Carol Owner, promote Bob to Owner, then demote
# Bob back to Viewer (the role he needs for Step 25).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "mbr_carol", "password": "MbrCarolPassword1!", "email": "mbr_carol@example.com", "role": "user" }
HTTP 201
[Captures]
carol_user_id: jsonpath "$.id"
# 24a — Owner grants Carol Owner role (Owner-creates-Owner).
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{carol_user_id}}" },
"role": "owner"
}
HTTP 201
[Asserts]
jsonpath "$.role" == "owner"
# 24b — Owner promotes Bob (Viewer) to Owner.
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "owner" }
HTTP 200
[Asserts]
jsonpath "$.role" == "owner"
# 24c — Owner demotes Bob back to Viewer (last-owner protection
# allows it: Carol + Alice-via-group remain as Owners).
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 200
[Asserts]
jsonpath "$.role" == "viewer"
# ─────────────────────────────────────────────────────────────
# Step 25 — Viewer CANNOT edit drive members.
# Bob is Viewer. Every member-mutation verb → 404
# (anti-enum: same shape as if the drive didn't exist).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{alice_user_id}}" },
"role": "editor"
}
HTTP 404
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 404
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 26 — Editor CANNOT edit drive members + CANNOT rename
# the drive (rename = PUT on the drive's root folder).
# Promote Bob to Editor first (Owner-driven).
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "editor" }
HTTP 200
# 26a — Editor POST /api/drives/{id}/members → 404.
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{alice_user_id}}" },
"role": "viewer"
}
HTTP 404
# 26b — Editor PATCH a member → 404.
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 404
# 26c — Editor DELETE a member → 404.
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
# 26d — Editor renames the drive (root folder) → 404.
# Editor bundle has Update on content, but the rename
# endpoint uses authz.require(...) and Editor's bundle on
# the drive root resolves through the same drive precheck.
# Editor has Update; folder rename uses Update; so this
# should actually SUCCEED. Asserting 200 to reflect the
# real engine semantics — the "Editor can rename the drive"
# fact is a real product question worth surfacing here.
# If you want rename to be Owner-only, the fix is in the
# folder service (require Manage, not Update).
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "name": "team-drive-editor-renamed" }
HTTP 200
# Restore the previous name so downstream assertions don't drift.
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "name": "team-drive-renamed" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 27 — Editor CAN modify content in the drive (positive).
# Confirms the Editor bundle isn't accidentally too
# restrictive — they can create folders under the drive
# root via the drive precheck.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"name": "editor-created-folder",
"parent_id": "{{team_root_folder_id}}"
}
HTTP 201
[Asserts]
jsonpath "$.name" == "editor-created-folder"
# ─────────────────────────────────────────────────────────────
# Step 28 — Viewer CAN read content in the drive (positive).
# Demote Bob back to Viewer, then confirm he can still
# list the drive's root folder.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{bob_user_id}}
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 200
GET {{base_url}}/api/folders/{{team_root_folder_id}}
Authorization: Bearer {{bob_token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 29 — A user with NO role on the drive cannot edit members.
# Provision a fresh user (mbr_dave) with no grants on
# the team drive; every member-mutation verb → 404.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "username": "mbr_dave", "password": "MbrDavePassword1!", "email": "mbr_dave@example.com", "role": "user" }
HTTP 201
[Captures]
dave_user_id: jsonpath "$.id"
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "mbr_dave", "password": "MbrDavePassword1!" }
HTTP 200
[Captures]
dave_token: jsonpath "$.access_token"
# 29a — Non-member POST → 404 (the drive itself appears not to exist).
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{dave_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{alice_user_id}}" },
"role": "viewer"
}
HTTP 404
# 29b — Non-member PATCH → 404.
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{dave_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 404
# 29c — Non-member DELETE → 404.
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{dave_token}}
HTTP 404
# 29d — Non-member GET members → 404 too (anti-enum: no member-list leak).
GET {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{dave_token}}
HTTP 404
+9 -11
View File
@@ -141,18 +141,16 @@ body contains "{{dora_id}}"
# ─────────────────────────────────────────────────────────────
# 8 — Teardown. Order matters: remove the nested group-member
# before deleting Group_B, so the FK cascade doesn't get
# ahead of us; remove dora's direct membership similarly.
# 8 — Teardown.
# The D3a self-defense in `subject_group_service::remove_member`
# refuses any individual membership removal that would empty a
# seeded group's transitive user set — which BOTH of these
# would (removing Group_B from A leaves A with no users;
# removing dora from B leaves B with no users). So we skip the
# manual member-by-member unwind and just DELETE the groups
# directly. `delete_group` cascades through `subject_group_members`
# via FK and isn't subject to the per-remove guard.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/groups/{{group_a_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
+53 -5
View File
@@ -234,18 +234,66 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 8 — Remove grace from engineering, then re-check access (after cache TTL).
# Note: the authz cache has a 30s TTL — Hurl tests run within seconds so
# grace may still see the folder during the cache window. We assert the
# membership removal succeeded; the post-TTL denial is exercised by the
# Rust integration tests, not here (test runtime cost).
# Step 8 — Self-defense on group remove_member.
# A group must not drop to 0 transitive users once seeded —
# without this guard, an admin could empty a group that owns
# a shared drive (D3a), leaving the drive with no effective
# Owner. Conservative-by-default: the rule applies to every
# group, not just drive-owning ones.
#
# So the first attempt to remove grace (the sole member) is
# refused with 400. We then seed the group with a second user,
# re-attempt the removal, and assert it now succeeds — the
# authz cascade tests below depend on grace being out of the
# group.
#
# Note: the authz cache has a 30s TTL — Hurl tests run within
# seconds so grace may still see the folder during the cache
# window. We assert the membership removal succeeded; the
# post-TTL denial is exercised by the Rust integration tests,
# not here (test runtime cost).
# ─────────────────────────────────────────────────────────────
# 8a — First removal refused: grace is the sole transitive user.
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 400
# 8b — Seed engineering with a second user so the removal can succeed.
POST {{base_url}}/api/admin/users
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "username": "grp_helper", "password": "GrpHelperPwd1!", "email": "grp_helper@example.com", "role": "user" }
HTTP 201
[Captures]
helper_user_id: jsonpath "$.id"
POST {{base_url}}/api/groups/{{engineers_id}}/members
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{ "user_id": "{{helper_user_id}}" }
HTTP 201
# 8c — Grace removal now succeeds: engineering still has grp_helper.
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{grace_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 204
# 8d — Confirming the invariant still holds: removing the last user
# (grp_helper) is again refused.
DELETE {{base_url}}/api/groups/{{engineers_id}}/members/user/{{helper_user_id}}
Authorization: Bearer {{alice_token}}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 9 — Authenticated /api/groups/search (no admin role required).
# ─────────────────────────────────────────────────────────────