Merge branch 'main' into idp-auto-redirect

This commit is contained in:
Markus Schmidt
2026-07-18 20:16:54 +02:00
committed by GitHub
180 changed files with 23338 additions and 2554 deletions
+4 -3
View File
@@ -234,13 +234,14 @@ jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "calendar"
# ─────────────────────────────────────────────────────────────
# Step 8c – Viewer Bob is denied on the unified list endpoint —
# `Share` is required, Viewer's bundle excludes it → 404
# anti-enum shape (same treatment as any other resource type).
# `Share` is required, Viewer's bundle excludes it. Bob has Read
# on the calendar → graduated denial returns 403 (see
# [[project_authz_require_graduated_denial]]).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=calendar&resource_id={{calendar_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
+119 -7
View File
@@ -412,10 +412,12 @@ HTTP 200
jsonpath "$[?(@.id == '{{share_book_id}}')].is_readonly" == true
# Step 21 — Viewer bundle has no Create permission — Bob's
# contact write still 404s. Same minimal-body reasoning as
# Step 18b: keep the request valid at the wire layer so any
# rejection has to come from the AuthZ engine.
# Step 21 — Viewer bundle has no Create permission. Bob has Read
# on the address book (viewer role) so graduated denial returns
# 403, not 404 (see [[project_authz_require_graduated_denial]]).
# Same minimal-body reasoning as Step 18b: keep the request valid
# at the wire layer so any rejection has to come from the AuthZ
# engine.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
@@ -423,7 +425,7 @@ Content-Type: application/json
"full_name": "Viewer Cannot Write"
}
HTTP 404
HTTP 403
# Step 21b — Unified list-on-resource: Alice queries
@@ -445,11 +447,121 @@ jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "address_boo
# Step 21c — Viewer Bob is denied on the unified list endpoint —
# `Share` isn't in the Viewer bundle → 404 anti-enum shape.
# `Share` isn't in the Viewer bundle. Bob has Read → graduated
# denial returns 403 (see [[project_authz_require_graduated_denial]]).
GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 21d–21g — Regression pin for AuthZ audit #13 (2026-07-12).
#
# `ContactService::delete_contact` used to `authz.require(Update)`
# on the address book instead of `Delete`. Editor role bundle
# (Read + Comment + Create + Update) satisfies Update → any
# Editor grantee on a shared address book could delete individual
# contacts. Fix: swap the required Permission on delete_contact
# + delete_group to `Delete`. Sibling `CalendarService::delete_event`
# was the ground-truth pattern.
#
# The pin promotes Bob to Editor (so his bundle includes Update
# but NOT Delete — exactly the pre-fix bypass condition), seeds a
# canary contact as Alice, has Bob attempt DELETE, then confirms
# Alice still sees the contact. Pre-fix would 204; post-fix 403.
# ─────────────────────────────────────────────────────────────
# 21d — Promote Bob from Viewer to Editor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "editor"
}
HTTP 200
# 21e — Alice seeds a canary contact in the shared book.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"full_name": "audit-13 delete-permission canary"
}
HTTP 201
[Captures]
audit13_contact_id: jsonpath "$.id"
# 21f — Bob (Editor) DELETE the canary → 403. Editor has Read
# so graduated denial fires with `visibility=visible`. Pre-fix
# this returned 204 because `require(Update)` succeeded on the
# Editor bundle.
DELETE {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{bob_token}}
HTTP 403
# 21g — Alice re-fetches to confirm the canary is still there
# (Bob's DELETE really was refused, not just responded to).
GET {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{audit13_contact_id}}"
# ─────────────────────────────────────────────────────────────
# Step 21h–21i — Regression pin for AuthZ audit #19 (2026-07-12).
#
# `ContactService::create_contact` + `create_contact_from_vcard`
# + `create_group` used to `authz.require(Update)` on the address
# book, which the Contributor bundle (Read + Create) does NOT
# satisfy — so Contributor grantees were blocked from adding
# contacts via REST or CardDAV PUT despite holding the intended
# Create permission. Not a bypass, an over-restrictive gate.
# Fix: `Permission::Create`. Sibling `#13` above closed the
# mirror bug on the delete verbs.
#
# The pin demotes Bob from Editor (Step 21d) to Contributor —
# Contributor is the minimal role that MUST succeed post-fix and
# FAILED pre-fix. Bob then POSTs a contact via REST; pre-fix this
# 403'd, post-fix returns 201.
# ─────────────────────────────────────────────────────────────
# 21h — Demote Bob from Editor to Contributor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "contributor"
}
HTTP 200
# 21i — Bob (Contributor) creates a contact → 201. Pre-fix, the
# service required Update which Contributor's bundle doesn't hold,
# so this 403'd and the CardDAV surface was equally blocked.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"full_name": "audit-19 contributor-can-create canary"
}
HTTP 201
[Captures]
audit19_contact_id: jsonpath "$.id"
# Step 22 — Alice revokes the grant.
+132
View File
@@ -0,0 +1,132 @@
# =============================================================
# OxiCloud — Dedup admin gate + URL move
# =============================================================
# Regression pin for AuthZ audit #24 + #25 (2026-07-12).
#
# `dedup_handler.rs` previously rolled its own admin check on
# `/api/dedup/stats` and `/api/dedup/recalculate` — a bespoke
# `if auth_user.role != "admin" { 403 with hand-rolled JSON }`
# with no audit line on rejection. That's the same drift class
# the admin middleware layer refactor closed elsewhere on
# 2026-07-17.
#
# Fix:
# 1. Both endpoints moved to `/api/admin/dedup/*` where the
# `/api/admin` middleware gate covers them by construction.
# URL declares admin intent up front.
# 2. Inline role check removed from the handlers — reaching
# them at all means the caller is admin.
# 3. `recalculate` emits `dedup.integrity_recalculated` on
# success (audit #25). Not asserted here (no log-scrape
# harness in Hurl); the shape is pinned in the handler
# code and covered by the `audit` tracing target contract.
#
# This test pins:
# * Admin can hit both endpoints at the new URL → 200.
# * Non-admin (bob) hits both → 403 (middleware layer).
# * The OLD URLs `/api/dedup/stats` and `/api/dedup/recalculate`
# are no longer registered → 404. Trips if someone
# re-introduces the routes to `dedup_router` without also
# removing them from `admin_handler::admin_routes()`.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login + bob (re-)provisioning.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# Anti-enum registration.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "dedup_bob",
"email": "dedup_bob@example.com",
"password": "DedupBobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "dedup_bob", "password": "DedupBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin can hit the new URL. `stats` returns a
# `StatsResponse`-shaped body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
jsonpath "$.bytes_saved" isNumber
jsonpath "$.total_logical_bytes" isNumber
jsonpath "$.total_physical_bytes" isNumber
# ─────────────────────────────────────────────────────────────
# Step 2 — Admin can trigger the integrity recalculation.
# Response shape mirrors `stats`. Server-side, this
# also emits the `dedup.integrity_recalculated` audit
# event (not asserted from Hurl).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
# ─────────────────────────────────────────────────────────────
# Step 3 — Bob (non-admin) is denied. The `/api/admin/*`
# middleware layer emits `AuthError::AccessDenied` →
# 403. No hand-rolled 403 body from the handler; the
# handler doesn't even run.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{bob_token}}
HTTP 403
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 4 — The old URLs are no longer registered. Trips if a
# future refactor re-adds them to `dedup_router` without
# removing them from `admin_handler::admin_routes()` (or
# vice versa). Anti-enum catch-all in the `/api/*` router
# returns 404 for unknown paths.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 404
POST {{base_url}}/api/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 404
+1 -1
View File
@@ -14,7 +14,7 @@
# (proves blob NOT prematurely deleted — bug 3 detection)
# 4. Permanently delete file 2 → blob and thumbnail cleaned up
#
# NOTE: The /api/dedup/stats endpoint counts CDC chunk rows in
# NOTE: The /api/admin/dedup/stats endpoint counts CDC chunk rows in
# storage.blobs and derives bytes_saved from chunk_manifests.
# Both tables may be 0 when the CDC path is disabled or the
# server uses the legacy blob path — so we avoid stats-based
+58 -20
View File
@@ -111,16 +111,25 @@ HTTP 201
small_file_id: jsonpath "$.id"
# Confirm `drives.used_bytes` reflects the new file. The hook is
# fire-and-forget on a tokio task, so the SQL UPDATE may not have
# landed by the time `POST /api/files/upload` returned. Retry the
# `GET /api/drives` until the cached value catches up — bounded
# wait keeps a slow CI machine from flaking.
# Force freshness on `drives.used_bytes`:
# 1. The fire-and-forget delta hook may not have landed yet
# (200 ms delay to let the tokio task register — see
# `bug_trigger_sweep_vs_spawn_hook_race`).
# 2. Force a reconciliation sweep. That's the ONLY path that
# invalidates `readable_cache` / `default_drive_cache` after
# Ed's 2026-07-17 design call: the sweep is the escape hatch
# for tests / operators that need immediate cache freshness;
# per-write invalidation would nuke the cache on every upload.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -145,13 +154,19 @@ file: file,fixtures/hello-copy.txt; text/plain
HTTP 201
# `used_bytes` climbs to 64 (32 + 32). Same retry shape as the
# first assertion since the second delta is also fire-and-forget.
# `used_bytes` climbs to 64 (32 + 32). Same trigger-sweep pattern
# as the first assertion — the delta is fire-and-forget and the
# listing cache lags until the sweep invalidates it.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -173,7 +188,18 @@ HTTP 507
# `used_bytes` is unchanged — the failed upload didn't charge the
# drive. (Cumulative usage is still 64; the 5 MiB write never
# registered a row.)
# registered a row.) Trigger the sweep again to guarantee cache
# freshness — the 5 MiB attempt was refused pre-write so no
# delta was queued, but the previous sweep's invalidation was
# consumed by the intervening GET which re-populated the cache
# with the pre-refused-write value. Sweep + re-check for
# determinism.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
@@ -211,13 +237,17 @@ HTTP 201
# Unlimited drive's `used_bytes` climbs to the file's exact size
# (5 MiB = 5_242_880 bytes). Same retry block because the delta
# hook is fire-and-forget here too.
# (5 MiB = 5_242_880 bytes). Trigger-sweep pattern (see above).
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
@@ -384,7 +414,15 @@ HTTP 200
# `used_bytes` on the tight drive is unchanged — the two refused
# operations above never wrote anything.
# operations above never wrote anything. Trigger-sweep so the
# check reads live SQL (see the class doc on the earlier
# sweep + GET pair for the design rationale).
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
+27 -19
View File
@@ -30,9 +30,13 @@
# 1. Baseline — drive not frozen → owner can upload / rename /
# delete / trash / share (proves the fixture is writable).
# 2. Admin freezes the drive via PATCH policies.
# 3. Every mutation attempt returns 404 (anti-enum):
# upload, rename, delete, trash-restore, permanent delete,
# create public link, rename the drive itself.
# 3. Every mutation attempt is refused. The engine's graduated
# denial returns 403 to the owner (who can Read their own
# drive) — anti-enum only kicks in for callers with no Read
# at all, whose 404 shape is exercised by the cross-tenant
# tests in `webdav_permissions.hurl` / `permissions.hurl`.
# Cases: upload, rename, delete, trash-restore, permanent
# delete, create public link, rename the drive itself.
# 4. Read still works: GET /api/drives, GET /api/folders,
# download the file, list trash.
# 5. Admin unfreezes.
@@ -203,9 +207,13 @@ jsonpath "$[?(@.id=='{{personal_drive_id}}')].policies.read_only" == true
# ─────────────────────────────────────────────────────────────
# Step 8 — MUTATIONS BLOCKED. Upload → 404 (Create).
# Anti-enum: NotFound not 403, same shape as "no such
# folder." The engine gate emits an audit line with
# Step 8 — MUTATIONS BLOCKED. Upload → 403 (Create).
# Graduated denial: owner can Read their own frozen
# drive, so the engine returns `access_denied` → 403
# rather than the anti-enum 404 (hiding a drive from
# its owner would be absurd). Cross-tenant callers with
# no Read on the drive still see 404 by the same code
# path. The engine gate emits an audit line with
# `reason = drive_read_only` — inspectable in server
# logs, not asserted here (no log-scraping harness).
# ─────────────────────────────────────────────────────────────
@@ -215,11 +223,11 @@ Authorization: Bearer {{owner_token}}
folder_id: {{personal_root_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 9 — Rename file A → 404 (Update). Endpoint is
# Step 9 — Rename file A → 403 (Update). Endpoint is
# `PUT /api/files/{id}/rename` (not PATCH — the file
# service exposes rename as a distinct verb, mirroring
# the folder side). WebDAV MOVE would fire the same
@@ -230,30 +238,30 @@ Authorization: Bearer {{owner_token}}
Content-Type: application/json
{ "name": "renamed_during_freeze.txt" }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 10 — Delete file A → 404 (Delete).
# Step 10 — Delete file A → 403 (Delete).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/trash/files/{{file_a_id}}
Authorization: Bearer {{owner_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 11 — Restore file B from trash → 404 (Update on the
# Step 11 — Restore file B from trash → 403 (Update on the
# soft-deleted row is a mutation like any other).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/trash/{{file_b_id}}/restore
Authorization: Bearer {{owner_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 12 — Permanent delete of file B → 404 (Delete).
# Step 12 — Permanent delete of file B → 403 (Delete).
# Note: the background retention purge SQL filter is
# tested via source-review + a unit test on the
# `delete_expired_bulk` query, not here — advancing
@@ -265,11 +273,11 @@ HTTP 404
DELETE {{base_url}}/api/trash/{{file_b_id}}
Authorization: Bearer {{owner_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 13 — Share creation → 404 (Share). Goes through
# Step 13 — Share creation → 403 (Share). Goes through
# `share_service::create_shared_link` which calls
# `authz.require(Share, Resource::File)` → engine gate.
# ─────────────────────────────────────────────────────────────
@@ -281,11 +289,11 @@ Content-Type: application/json
"item_type": "file"
}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 14 — Grant (per-resource, not public link) → 404 (Share).
# Step 14 — Grant (per-resource, not public link) → 403 (Share).
# Same engine gate — Share permission on File is
# refused regardless of which endpoint asks for it.
# ─────────────────────────────────────────────────────────────
@@ -298,7 +306,7 @@ Content-Type: application/json
"role": "viewer"
}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
+28 -28
View File
@@ -564,34 +564,35 @@ jsonpath "$[*].id" contains {{team_drive_id}}
# `Permission::Create` on the parent folder — bundled
# with `owner`/`editor`/`contributor` role_grants only,
# NOT with `viewer`. `POST /api/files/upload` shares the
# same `save_file_with_blob` gate, so a Viewer probe
# must land 404 (anti-enum: same shape as no-such-folder)
# + `authz.denied` audit line. Also verify the batch /
# overwrite paths refuse — the whole chain from
# drive-membership to file write is exercised here.
# same `save_file_with_blob` gate. Bob has Read on the
# drive (viewer role cascades) → graduated denial returns
# 403 (see [[project_authz_require_graduated_denial]]).
# Also verify the batch / overwrite paths refuse — the
# whole chain from drive-membership to file write is
# exercised here.
# ─────────────────────────────────────────────────────────────
# 22b.i — Fresh file: 404.
# 22b.i — Fresh file: 403.
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{team_root_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
HTTP 403
# 22b.ii — Overwrite attempt on the Editor-era upload: still 404.
# 22b.ii — Overwrite attempt on the Editor-era upload: still 403.
# `save_file_with_blob` catches the duplicate name at the
# `Create`-permission check before the upsert races (which
# would otherwise 409). The audit shape stays 404.
# would otherwise 409).
POST {{base_url}}/api/files/upload
Authorization: Bearer {{bob_token}}
[MultipartFormData]
folder_id: {{team_root_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
HTTP 403
# 22b.iii — Alice's Editor-era file is untouched.
@@ -698,8 +699,8 @@ 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).
# Bob is Viewer (has Read on the drive) → graduated denial
# returns 403 on every member-mutation verb.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{bob_token}}
@@ -709,7 +710,7 @@ Content-Type: application/json
"role": "editor"
}
HTTP 404
HTTP 403
PATCH {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
@@ -717,13 +718,13 @@ Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "role": "viewer" }
HTTP 404
HTTP 403
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -739,7 +740,7 @@ Content-Type: application/json
HTTP 200
# 26a — Editor POST /api/drives/{id}/members → 404.
# 26a — Editor POST /api/drives/{id}/members → 403 (Editor has Read).
POST {{base_url}}/api/drives/{{team_drive_id}}/members
Authorization: Bearer {{bob_token}}
Content-Type: application/json
@@ -748,39 +749,39 @@ Content-Type: application/json
"role": "viewer"
}
HTTP 404
HTTP 403
# 26b — Editor PATCH a member → 404.
# 26b — Editor PATCH a member → 403.
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
HTTP 403
# 26c — Editor DELETE a member → 404.
# 26c — Editor DELETE a member → 403.
DELETE {{base_url}}/api/drives/{{team_drive_id}}/members/user/{{carol_user_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# 26d — Editor renames the drive (root folder) → 404.
# 26d — Editor renames the drive (root folder) → 403.
# Folder rename normally requires `Permission::Update` (which
# Editor has on every folder in the drive via the engine's drive
# precheck). The folder service promotes the requirement to
# `Permission::Manage` when the target folder has `parent_id IS
# NULL` — i.e. it's a drive root — so the drive-rename surface is
# Owner-only per drive.md §6, without changing the public folder
# endpoint shape. Anti-enum: refusal returns 404 (not 403).
# endpoint shape. Editor has Read → graduated denial → 403.
PUT {{base_url}}/api/folders/{{team_root_folder_id}}/rename
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "name": "team-drive-editor-renamed" }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -883,7 +884,7 @@ HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 30 — Drive delete (D3b).
# - Non-Owner → 404 (Bob is Viewer post-Step 28).
# - Non-Owner → 403 (Bob is Viewer post-Step 28, has Read).
# - Owner on non-empty drive → 409 (the editor-created-folder
# from Step 27 is still live).
# - Owner after the folder is trashed → 204.
@@ -892,12 +893,11 @@ HTTP 404
# we exercise its 405 below.
# ─────────────────────────────────────────────────────────────
# 30a — Viewer (Bob) cannot delete the drive → 404, anti-enum same as
# the member-mutation refusals.
# 30a — Viewer (Bob) cannot delete the drive → 403 (has Read).
DELETE {{base_url}}/api/drives/{{team_drive_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# 30b — Owner (Alice) on a non-empty drive → 409 with the canonical
+112 -16
View File
@@ -137,14 +137,15 @@ jsonpath "$.grants[0].role" == "viewer"
# ─────────────────────────────────────────────────────────────
# Step 7 — Viewer cannot rename (no update grant).
# Step 7 — Viewer cannot rename (no Update grant). Dave has Read
# (viewer role) → graduated denial returns 403.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
Authorization: Bearer {{dave_token}}
Content-Type: application/json
{ "name": "bob-tried-again" }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -260,14 +261,15 @@ jsonpath "$[0].role" == "viewer"
# ─────────────────────────────────────────────────────────────
# Step 16 — Demoted Bob can no longer rename.
# Step 16 — Demoted Bob (now Viewer) can no longer rename. Read
# is still granted → graduated denial returns 403.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename
Authorization: Bearer {{dave_token}}
Content-Type: application/json
{ "name": "bob-tried-after-demote" }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -594,34 +596,37 @@ Authorization: Bearer {{adam_token}}
HTTP 200
# ── Mutations still denied (Viewer has no Update/Create/Delete) ─
# ── Mutations still denied (Viewer has no Update/Create/Delete).
# Viewer has Read → graduated denial returns 403 (not 404
# anti-enum, which is reserved for Phase 2A above where Adam
# had no Read at all).
POST {{base_url}}/api/folders
Authorization: Bearer {{adam_token}}
Content-Type: application/json
{ "name": "adam-attack-2", "parent_id": "{{perm_folder_id}}" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
Authorization: Bearer {{adam_token}}
Content-Type: application/json
{ "name": "adam-rename-as-viewer" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
Authorization: Bearer {{adam_token}}
Content-Type: application/json
{ "name": "adam-file-rename-as-viewer" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
Authorization: Bearer {{adam_token}}
Content-Type: image/png
file,fixtures/blue-image.png;
HTTP 404
HTTP 403
POST {{base_url}}/api/files/upload
Authorization: Bearer {{adam_token}}
@@ -629,17 +634,17 @@ Authorization: Bearer {{adam_token}}
folder_id: {{perm_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
HTTP 403
DELETE {{base_url}}/api/files/{{perm_file_id}}
Authorization: Bearer {{adam_token}}
HTTP 404
HTTP 403
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
Authorization: Bearer {{adam_token}}
HTTP 404
HTTP 403
# ── Viewer cannot start a chunked upload (no Create grant) ──
POST {{base_url}}/api/uploads
@@ -653,7 +658,7 @@ Content-Type: application/json
"chunk_size": 3000000
}
HTTP 404
HTTP 403
# ════════════════════════════════════════════════════════════════════
@@ -813,16 +818,107 @@ Authorization: Bearer {{adam_token}}
HTTP 204
# ── Delete still denied (Editor excludes Delete) ────────────
# ── Regression pin for AuthZ audit #17 (2026-07-12). ─────────
# The chunked-upload `complete` handler used to call plain
# `upload_file_streaming` at finalize — no `_with_perms` check.
# A grant revoked between session-open and finalize stayed
# effective until the last chunk landed (up to 24h JWT TTL,
# forever with app-passwords). Fix: swap to
# `upload_file_streaming_with_perms` so `authz.require(Create,
# Folder)` re-runs at complete time.
#
# Sequence:
# 1. Adam (Editor) opens a session — pre-check passes.
# 2. Adam PATCHes the single chunk (chunk upload is unauth'd,
# always allowed).
# 3. Alice DEMOTES Adam to Viewer (Viewer bundle has Read but
# no Create).
# 4. Adam POST /complete → 403 (pre-fix: 201 + file created).
# 5. Cleanup: cancel the orphaned session + re-promote Adam
# to Editor so the following steps aren't disturbed.
# 1 — Open session while Editor.
POST {{base_url}}/api/uploads
Authorization: Bearer {{adam_token}}
Content-Type: application/json
{
"filename": "audit17-post-revoke.mp4",
"folder_id": "{{perm_folder_id}}",
"content_type": "video/mp4",
"total_size": 2760653,
"chunk_size": 3000000
}
HTTP 201
[Captures]
audit17_upload_id: jsonpath "$.upload_id"
# 2 — Send the single chunk (session pre-authorised).
PATCH {{base_url}}/api/uploads/{{audit17_upload_id}}?chunk_index=0
Authorization: Bearer {{adam_token}}
Content-Type: application/octet-stream
file,fixtures/free_video_over_1MB.mp4;
HTTP 200
# 3 — Alice demotes Adam Editor → Viewer (Create removed).
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{adam_user_id}}" },
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
"role": "viewer"
}
HTTP 200
# 4 — Finalize now fails: engine re-checks Create at complete
# time. Adam still has Read (viewer role) → graduated denial
# returns 403; pre-fix returned 201 with a phantom file.
POST {{base_url}}/api/uploads/{{audit17_upload_id}}/complete
Authorization: Bearer {{adam_token}}
HTTP 403
# 5a — The session is orphaned (chunks on disk, no completion).
# Cancel it as Adam (still owns the session, so the `_with_perms`
# gate on DELETE-session lets him through).
DELETE {{base_url}}/api/uploads/{{audit17_upload_id}}
Authorization: Bearer {{adam_token}}
HTTP 204
# 5b — Restore Adam to Editor so subsequent steps behave as
# before this regression pin was inserted.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{alice_token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{adam_user_id}}" },
"resource": { "type": "folder", "id": "{{perm_folder_id}}" },
"role": "editor"
}
HTTP 200
# ── Delete still denied (Editor excludes Delete). Editor has
# Read → graduated denial returns 403.
DELETE {{base_url}}/api/files/{{perm_file_id}}
Authorization: Bearer {{adam_token}}
HTTP 404
HTTP 403
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
Authorization: Bearer {{adam_token}}
HTTP 404
HTTP 403
# ════════════════════════════════════════════════════════════════════
+16 -12
View File
@@ -367,34 +367,38 @@ HTTP 200
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].resource_type" == "folder"
jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].permissions" contains "read"
# ── Mutations still denied (Viewer has no Update/Create/Delete) ─
# ── Mutations still denied (Viewer has no Update/Create/Delete).
# Henry has Read via nested-group cascade → graduated denial
# returns 403 (see [[project_authz_require_graduated_denial]]).
# Anti-enum 404 stays reserved for the earlier phase where the
# cascade hadn't given Henry any Read at all.
POST {{base_url}}/api/folders
Authorization: Bearer {{henry_token}}
Content-Type: application/json
{ "name": "henry-attack-2", "parent_id": "{{perm_folder_id}}" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/folders/{{perm_folder_id}}/rename
Authorization: Bearer {{henry_token}}
Content-Type: application/json
{ "name": "henry-rename-as-viewer" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/files/{{perm_file_id}}/rename
Authorization: Bearer {{henry_token}}
Content-Type: application/json
{ "name": "henry-file-rename-as-viewer" }
HTTP 404
HTTP 403
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/icon
Authorization: Bearer {{henry_token}}
Content-Type: image/png
file,fixtures/blue-image.png;
HTTP 404
HTTP 403
POST {{base_url}}/api/files/upload
Authorization: Bearer {{henry_token}}
@@ -402,17 +406,17 @@ Authorization: Bearer {{henry_token}}
folder_id: {{perm_folder_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 404
HTTP 403
DELETE {{base_url}}/api/files/{{perm_file_id}}
Authorization: Bearer {{henry_token}}
HTTP 404
HTTP 403
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
Authorization: Bearer {{henry_token}}
HTTP 404
HTTP 403
# Viewer cannot start a chunked upload (no Create grant).
POST {{base_url}}/api/uploads
@@ -426,7 +430,7 @@ Content-Type: application/json
"chunk_size": 3000000
}
HTTP 404
HTTP 403
# ════════════════════════════════════════════════════════════════════
@@ -520,16 +524,16 @@ HTTP 200
[Asserts]
jsonpath "$[?(@.id=='{{henry_chunked_file_id}}')].name" == "henry-chunked-video.mp4"
# Editor still cannot delete.
# Editor still cannot delete. Editor bundle carries Read → 403.
DELETE {{base_url}}/api/files/{{perm_file_id}}
Authorization: Bearer {{henry_token}}
HTTP 404
HTTP 403
DELETE {{base_url}}/api/folders/{{perm_folder_id}}
Authorization: Bearer {{henry_token}}
HTTP 404
HTTP 403
# ════════════════════════════════════════════════════════════════════
+45 -14
View File
@@ -3,18 +3,25 @@
# =============================================================
# C4 from BASELINE_TESTS_NC_WEBDAV.md.
#
# Deferred from Batch 1 because it needed the bob fixture
# that `nc_second_user_setup.hurl` now provides. Pins the
# behaviour of the existing rule in
# `interfaces/nextcloud/ocs_handler.rs::user_provisioning_response`:
# Post AuthZ audit #11 (2026-07-17), `user_provisioning_response`
# no longer rolls its own admin gate — it delegates to
# `AuthApplicationService::get_user_profile_by_username_with_perms`,
# which shares the visibility engine with the id-keyed REST
# endpoint at `/api/users/{id}`. Consequences for this test:
#
# if user.username != userid && user.role != "admin" {
# return Json(ocs_err(403, ...)).into_response();
# }
#
# i.e. you can read your own profile always; you can read
# anyone's profile if you're admin. Bob is not admin, so bob
# CANNOT read admin's profile (the symmetric assertion).
# - **admin → bob**: still 200 (admin bypass is one of the
# five visibility paths; see get_user_profile step 5).
# - **bob → admin**: with `OXICLOUD_EXPOSE_SYSTEM_USERS=true`
# (tests/common/server.env), both are internal so step 4
# of the visibility engine says the target is broadly
# visible via the system address book — bob CAN see
# admin's basic profile. Pre-fix, the bespoke gate returned
# `403 Insufficient privileges` and admin bypassed the
# expose gate silently; both anomalies are gone.
# - **bob → nonexistent**: `404 User not found`, anti-enum
# shape identical to "you can't see this user". Audit line
# `user_profile.rejected reason=target_username_not_found`
# fires server-side.
#
# Uses admin's app password for Basic Auth (same pattern as
# `nc_ocs_user_info.hurl`).
@@ -82,8 +89,12 @@ jsonpath "$.ocs.data.email" == "bob@example.com"
# ─────────────────────────────────────────────────────────────
# C4-symmetric — bob (non-admin) CANNOT read admin's profile
# (proves the admin-only branch isn't a no-op)
# C4-symmetric — post-audit-#11: bob CAN read admin's profile
# because the visibility engine's
# `expose_system_users` branch treats internal
# users as broadly visible via the system address
# book. The bespoke `403 Insufficient privileges`
# the pre-fix handler emitted is gone.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
[BasicAuth]
@@ -91,7 +102,27 @@ GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 403
jsonpath "$.ocs.meta.statuscode" == 100
jsonpath "$.ocs.data.id" == "{{username}}"
# ─────────────────────────────────────────────────────────────
# C4-antienum — bob queries a genuinely nonexistent username.
# Response body is the SAME shape as any denial
# case: `statuscode=404 status="failure"`. The
# NC client cannot distinguish "user doesn't
# exist" from "you have no visibility on that
# user" (were expose_system_users off) — which
# is the anti-enumeration invariant this fix
# was meant to preserve.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/ocs/v1.php/cloud/users/nonexistent-audit-11-canary?format=json
[BasicAuth]
{{bob_nc_user}}: {{bob_nc_pw}}
HTTP 200
[Asserts]
jsonpath "$.ocs.meta.statuscode" == 404
jsonpath "$.ocs.meta.status" == "failure"
+14 -12
View File
@@ -204,38 +204,38 @@ jsonpath "$[*].id" contains "{{playlist_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11 – Bob cannot rename the playlist. Viewer's bundle is
# Read-only (no Update), so `require_playlist_perm(Update)` denies
# with the 404 anti-enum shape.
# Read-only (no Update). Bob has Read → graduated denial returns
# 403 (see [[project_authz_require_graduated_denial]]).
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "name": "hijacked" }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 12 – Bob cannot delete the playlist. Viewer's bundle
# excludes Delete → 404.
# excludes Delete → 403 (Read granted).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/playlists/{{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 13 – Bob cannot re-share the playlist. Viewer's bundle
# excludes Share → 404 on the legacy /share endpoint (which now
# routes through `authz.require(Share)`).
# excludes Share → 403 (Read granted). The legacy /share endpoint
# routes through `authz.require(Share)`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists/{{playlist_id}}/share
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "user_id": "{{alice_user_id}}", "can_write": true }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -277,13 +277,14 @@ jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "playlist"
# ─────────────────────────────────────────────────────────────
# Step 14c – Bob (Viewer only) is denied on the unified list
# endpoint: `Share` is required, Viewer's bundle excludes it →
# 404 anti-enum shape.
# endpoint: `Share` is required, Viewer's bundle excludes it.
# Bob has Read → graduated denial returns 403 (see
# [[project_authz_require_graduated_denial]]).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}}
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -346,13 +347,14 @@ jsonpath "$.description" == "renamed by editor bob"
# ─────────────────────────────────────────────────────────────
# Step 19 – Editor still cannot Share (Share stays Owner-only).
# Bob has Read (Editor bundle) → graduated denial returns 403.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/playlists/{{playlist_id}}/share
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{ "user_id": "{{alice_user_id}}", "can_write": false }
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
+3 -1
View File
@@ -164,6 +164,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/recent.hurl" \
"$API_DIR/batch_folder_copy.hurl" \
"$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/dedup_admin_gate.hurl" \
"$API_DIR/default_caldav_carddav.hurl" \
"$API_DIR/dav_error_mapping.hurl" \
"$API_DIR/carddav_vcard_properties.hurl" \
@@ -207,7 +208,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/webdav_drive_root.hurl" \
"$API_DIR/webdav_permissions.hurl" \
"$API_DIR/webdav_nested_move_cascade.hurl" \
"$API_DIR/wopi_authz.hurl"
"$API_DIR/wopi_authz.hurl" \
"$API_DIR/wopi_shared_drive.hurl"
#bash "$API_DIR/dedup_bulk_upload.sh"
+79 -7
View File
@@ -25,6 +25,22 @@
# =============================================================
# ─────────────────────────────────────────────────────────────
# Pre-setup — anonymous request pin.
#
# `DELETE /api/admin/search/cache` with NO credentials must land as
# 401 Unauthorized (from `auth_middleware`, before the admin gate
# even runs). Kept at the very top of the file so no earlier
# request has populated any auth state that could accidentally
# authenticate this request. `[Options] cookie-storage-clear` was
# tried earlier but isn't supported in Hurl 8.0.1, so we rely on
# ordering instead — this DELETE runs FIRST, before any login.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/search/cache
HTTP 401
# ─────────────────────────────────────────────────────────────
# Setup — admin login + bob (re-)provisioning
# ─────────────────────────────────────────────────────────────
@@ -110,7 +126,7 @@ Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.files" count >= 1
jsonpath "$.files" count >= 1
body contains "{{needle_file_id}}"
@@ -124,7 +140,7 @@ Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.files" count == 0
jsonpath "$.files" count == 0
jsonpath "$.folders" count == 0
@@ -152,6 +168,29 @@ body not contains "unique-search-needle"
body not contains "{{needle_file_id}}"
# ─────────────────────────────────────────────────────────────
# 5b — REGRESSION: `/api/search/suggest` MUST also refuse to
# surface admin's file to bob. Pre-fix (AuthZ audit #1,
# 2026-07-12) the suggest endpoint had NO `AuthUser`
# extractor and its underlying `suggest_files_by_name` /
# `suggest_folders_by_name` filtered only on
# `NOT is_trashed AND name ILIKE $1` — any authenticated
# user (including externals) could autocomplete names and
# full `path` values across every tenant on the instance.
# Fix: added `caller_id` to both repo queries via the
# shared `CALLER_CAN_READ_DRIVE` predicate (`role_grants`
# + `caller_group_ids`). This assertion is the anti-
# regression pin.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/search/suggest?query=unique-search-needle
Authorization: Bearer {{bob_token}}
HTTP 200
[Asserts]
body not contains "unique-search-needle"
body not contains "{{needle_file_id}}"
# ─────────────────────────────────────────────────────────────
# 6 — CONTENT-search cross-drive isolation (docs/plan/drive.md §11).
# The cross-user check above (step 5) verifies the NAME-search
@@ -209,7 +248,7 @@ HTTP 200
# Bob has no access to admin's drive → Tantivy's Must-clause
# filters every doc that doesn't carry one of Bob's drive_ids,
# so the file vanishes entirely.
jsonpath "$.files" count == 0
jsonpath "$.files" count == 0
jsonpath "$.folders" count == 0
body not contains "{{canary_file_id}}"
body not contains "ContentIndexCanaryXyzzy2026Drive"
@@ -221,11 +260,44 @@ body not contains "ContentIndexCanaryXyzzy2026Drive"
# other field names below MUST stay absent: a future field
# called `hidden_count`/`filtered`/etc. that reveals matches
# Bob can't see would be the regression.
jsonpath "$.total_count" == 0
jsonpath "$.has_more" == false
jsonpath "$.total_count" == 0
jsonpath "$.has_more" == false
jsonpath "$.hidden_count" not exists
jsonpath "$.filtered" not exists
jsonpath "$.total" not exists
jsonpath "$.filtered" not exists
jsonpath "$.total" not exists
# ─────────────────────────────────────────────────────────────
# 6b — Regression pin for AuthZ audit #14 (2026-07-12).
# `DELETE /api/admin/search/cache` calls moka `invalidate_all()`
# on the shared results cache — one call cold-starts every
# subsequent search for every tenant. Pre-fix, this lived at
# `/api/search/cache` gated only by the top-level auth
# middleware: any authenticated caller (including external /
# magic-link accounts) could DELETE it in a loop and hold the
# results cache empty indefinitely (sustained DoS). Fix: gate
# on `require_admin` AND move the URL to `/api/admin/...` so
# the taxonomy declares the intent up front. Moved 2026-07-17.
#
# Bob (regular user) → 403; missing token → 401; admin → 200.
# The 200 confirms the admin path still works (no regression
# on the operator debug lever the endpoint remains for).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/search/cache
Authorization: Bearer {{bob_token}}
HTTP 403
# The unauthenticated 401 case is pinned at the top of the file
# (before any login has run) — see the pre-setup block. Placing it
# there instead of here avoids relying on Hurl's cookie / auth
# behaviour, which `cookie-storage-clear` (unsupported in 8.0.1)
# would otherwise be needed to reset.
DELETE {{base_url}}/api/admin/search/cache
Authorization: Bearer {{admin_token}}
HTTP 200
# ─────────────────────────────────────────────────────────────
+80 -6
View File
@@ -190,8 +190,12 @@ HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 9 — Provision a Viewer of the shared drive (`tpd_viewer`),
# then assert the per-drive empty refuses for Viewer / Editor
# / non-member callers. Each refusal is 404 (anti-enum).
# then assert the per-drive empty refuses for Viewer /
# Editor / non-member callers. Graduated denial (see
# [[project_authz_require_graduated_denial]]): the Viewer
# and Editor tests get 403 because they hold Read on the
# drive; the non-member fallback keeps the 404 anti-enum
# shape (no Read = no existence oracle).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
@@ -249,17 +253,19 @@ Authorization: Bearer {{owner_token}}
HTTP 204
# Test 4 — Viewer cannot empty the drive's trash.
# Test 4 — Viewer cannot empty the drive's trash. Viewer has Read
# on the drive → graduated denial returns 403.
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
Authorization: Bearer {{viewer_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 10 — Test 5: Editor cannot either.
# Promote tpd_viewer to Editor; same refusal. Confirms
# `Delete` isn't in the Editor bundle.
# `Delete` isn't in the Editor bundle. Editor has Read →
# graduated denial returns 403.
# ─────────────────────────────────────────────────────────────
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{viewer_user_id}}
Authorization: Bearer {{owner_token}}
@@ -272,7 +278,7 @@ HTTP 200
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
Authorization: Bearer {{viewer_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -315,6 +321,74 @@ HTTP 200
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
# ─────────────────────────────────────────────────────────────
# Step 11b — Regression pin for AuthZ audit #10 (2026-07-12).
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}`
# once did `err_str.contains("not found")` to decide "already
# gone" vs real failure — an authz denial (which returns a
# `NotFound`-shaped DomainError to preserve anti-enum on the
# listing side) matched the substring and got synthesised
# into a 200 `{"success": true}` response. Response lied;
# no mutation happened.
#
# Post-fix: both handlers route through
# `AppError::from(e).into_response()`, so authz denials
# surface as the graduated 403 / 404 shape and body is
# never a success envelope.
#
# The Editor (from Step 10 promotion) holds Read on the
# canary — graduated denial returns 403 with a
# `AccessDenied`-shape body, NOT a success envelope. If a
# future refactor reintroduces the substring hack this
# assertion trips before it lands in prod.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{viewer_token}}
HTTP 200
[Captures]
# The shared drive's trash holds exactly one item at this point (the
# canary owner trashed after Step 9), so `$.items[0]` is unambiguous
# — no filter needed. `TrashResourceItemDto` wraps the underlying
# resource in `.resource` (untagged File | Folder | Drive enum) and
# the trash key equals the original resource id (see
# `storage.trash_items` view), so `.resource.id` is exactly what
# `POST /api/trash/{id}/restore` and `DELETE /api/trash/{id}` accept.
# The `[?(...)]` + `nth 0` shape (see the sibling
# feedback_hurl_jsonpath_filter_empty memory) collapses on a single
# match and returns a scalar hurl can't index, so we avoid it here.
canary_trash_id: jsonpath "$.items[0].resource.id"
POST {{base_url}}/api/trash/{{canary_trash_id}}/restore
Authorization: Bearer {{viewer_token}}
HTTP 403
[Asserts]
body not contains "\"success\":true"
DELETE {{base_url}}/api/trash/{{canary_trash_id}}
Authorization: Bearer {{viewer_token}}
HTTP 403
[Asserts]
body not contains "\"success\":true"
# The canary is still there — the two Editor attempts didn't mutate.
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{owner_token}}
HTTP 200
[Asserts]
# Owner sees TWO trash items at this point — the shared drive's
# canary (from Step 9) plus their personal drive's leftover from
# Step 4 (owner emptied only the shared drive's trash at Step 6).
# `contains` avoids depending on the sort order between them.
jsonpath "$.items[*].resource.id" contains "{{canary_trash_id}}"
# ─────────────────────────────────────────────────────────────
# Step 12 — Cleanup: drop the canary, then the shared drive itself
# (D3b's delete-drive guard refuses non-empty drives, so
+19 -7
View File
@@ -130,15 +130,27 @@ file: file,fixtures/hello.txt; text/plain
HTTP 201
# Wait for the drive-side fire-and-forget delta to settle.
# Acts as the synchronisation point: by the time `drives.used_bytes`
# reflects the upload, the sibling user-side delta task spawned in
# the same call has had its chance to run too.
# Force freshness on `drives.used_bytes`:
# 1. 200 ms delay to let the fire-and-forget tokio task from the
# upload above land its SQL write (see
# `bug_trigger_sweep_vs_spawn_hook_race`).
# 2. Trigger the reconciliation sweep — the ONLY path that
# invalidates `readable_cache` / `default_drive_cache` after
# Ed's 2026-07-17 design call (per-write invalidation would
# nuke the cache on every upload, defeating the point). Also
# acts as the synchronisation point for the user-envelope
# assertion below — the sweep is the authoritative
# ground-truth for both drive- and user-side counters.
POST {{base_url}}/api/admin/internal/trigger-sweep
Authorization: Bearer {{admin_token}}
[Options]
delay: 200ms
HTTP 200
GET {{base_url}}/api/drives
Authorization: Bearer {{owner_token}}
[Options]
retry: 10
retry-interval: 200ms
HTTP 200
[Asserts]
+50 -10
View File
@@ -6,9 +6,10 @@
#
# 1. Per-role gates through the drive-scope resolver: a Viewer on a
# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An
# Editor can. AuthZ denials return `NotFound` (anti-enum), so
# a probing caller can't tell a genuinely-missing folder from
# one they simply lack Create on.
# Editor can. AuthZ denials use graduated shape: a caller with
# Read on the target (Viewer here) gets 403 Forbidden — no point
# hiding existence from someone already reading it. A caller with
# no Read at all gets 404 (anti-enum), matching "no such folder".
#
# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the
# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move`
@@ -123,17 +124,22 @@ HTTP 207
# ─────────────────────────────────────────────────────────────
# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive.
# `authz.require(Create, Folder)` denial returns
# `DomainError::not_found` (anti-enum), which maps to 404.
# `authz.require(Create, Folder)` denies. Bob has Read
# on the drive (viewer role) → engine's graduated denial
# returns `DomainError::access_denied` → 403 Forbidden.
# Anti-enum still holds for callers with no Read at all
# (would surface as 404); this is the "you can see it,
# but can't touch it" branch.
# ─────────────────────────────────────────────────────────────
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 7 — Bob (VIEWER) CANNOT PUT a file.
# Step 7 — Bob (VIEWER) CANNOT PUT a file. Same 403 shape
# (Bob has Read on the drive).
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt
Authorization: Bearer {{bob_token}}
@@ -142,7 +148,7 @@ Content-Type: text/plain
viewer should not upload
```
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
@@ -158,13 +164,47 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder.
# MOVE requires Update on the source, which Viewer
# doesn't have. Same anti-enum 404 shape.
# doesn't have. Bob can Read the folder (viewer) → 403.
# ─────────────────────────────────────────────────────────────
MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 9b — Bob (VIEWER) CANNOT COPY the probe folder.
# COPY requires Create on the destination parent, which
# Viewer doesn't have. Bob has Read on both source and
# destination parent → 403 (graduated denial).
#
# This is the regression pin for AuthZ audit #2
# (2026-07-12): the COPY handler used to `map_err(|e|
# AppError::internal_error(format!("Failed to copy folder
# tree: {}", e)))?` on `copy_folder_tree_with_perms`,
# collapsing the `DomainError` engine returned on denial
# into HTTP 500 — an "exists-but-denied" oracle. Fix
# routes through `AppError::from` so the same denial
# surfaces as the correct 403 / 404 per graduated-denial
# policy.
# ─────────────────────────────────────────────────────────────
COPY {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-copy
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 9c — Bob (VIEWER) CANNOT DELETE the probe folder.
# DELETE requires Delete on the target, which Viewer
# doesn't have. Bob has Read → 403.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
+162
View File
@@ -0,0 +1,162 @@
# =============================================================
# OxiCloud — WOPI PutFile against a shared drive
# =============================================================
# Regression pin for AuthZ audit #18 (2026-07-12).
#
# `wopi_handler.rs::put_file` used to resolve the write's target
# drive via `drive_repo.find_default_for_user(claims_sub_uuid)` —
# ALWAYS the caller's own default personal drive, regardless of
# where the file being edited actually lived. Consequences for a
# shared-drive file:
#
# - If the file's path happened to collide with a personal-drive
# path, the write MISROUTED into the caller's personal drive
# (silent cross-drive data ejection).
# - Otherwise the parent-folder lookup inside
# `update_file_streaming_with_perms` missed and the request
# 500'd — a UX brick on shared-drive WOPI editing.
#
# Fix: resolve `drive_id` from the FILE's own parent folder via
# `drive_repo.drive_id_for_folder(file.folder_id)`. Same file →
# same drive → write lands in the shared drive it belongs to.
#
# This test:
# 1. Admin creates a shared drive (D3a shape).
# 2. Admin uploads `hello.txt` to the shared drive's root.
# 3. Admin mints a WOPI edit token.
# 4. Admin PutFile with fresh content → 200.
# Pre-fix this 500'd because the personal-drive-scoped
# parent-folder lookup couldn't find a folder named "" in
# admin's personal drive.
# 5. Admin GetFile → the shared drive holds the new content.
# Proves the write landed on the correct drive.
#
# Prereqs: `OXICLOUD_WOPI_ENABLED=true`, `OXICLOUD_WOPI_SECRET`
# pinned, mock discovery running (all wired in
# `tests/common/server.env` + run.sh — same as `wopi_authz.hurl`).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin creates a shared drive owned by themselves.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "wopi-shared-drive-audit-18",
"owner": { "type": "user", "id": "{{admin_user_id}}" }
}
HTTP 201
[Captures]
wopi_drive_id: jsonpath "$.id"
wopi_drive_root_id: jsonpath "$.root_folder_id"
# ─────────────────────────────────────────────────────────────
# Step 2 — Upload `hello.txt` to the shared drive's root.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{admin_token}}
[MultipartFormData]
folder_id: {{wopi_drive_root_id}}
file: file,fixtures/hello.txt; text/plain
HTTP 201
[Captures]
wopi_file_id: jsonpath "$.id"
[Asserts]
jsonpath "$.mime_type" == "text/plain"
# ─────────────────────────────────────────────────────────────
# Step 3 — Mint an editor URL. Admin has Update on their own
# shared drive → `can_write=true` in the token.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/wopi/editor-url?file_id={{wopi_file_id}}&action=edit
Authorization: Bearer {{admin_token}}
HTTP 200
[Captures]
wopi_edit_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 4 — CheckFileInfo — sanity check the token is redeemable
# and reports `UserCanWrite=true`. Not the audit-#18
# pin itself (this verb didn't touch the drive-lookup
# bug) but a quick "the setup is sound" gate before
# Step 5.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{wopi_file_id}}?access_token={{wopi_edit_token}}
HTTP 200
[Asserts]
jsonpath "$.UserCanWrite" == true
# ─────────────────────────────────────────────────────────────
# Step 5 — PutFile with fresh content → 200.
#
# PRE-FIX (before #18 close): this 500'd. The handler
# resolved drive_id via find_default_for_user(admin),
# got admin's personal drive, then
# `update_file_streaming_with_perms(path, personal_drive_id)`
# did a parent-folder-by-path lookup scoped to the
# personal drive — nothing at the shared-drive path
# existed there → error → 500 wrapper.
#
# POST-FIX: drive_id resolves from the file's own
# parent folder → shared drive → write lands in the
# correct drive.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/wopi/files/{{wopi_file_id}}/contents?access_token={{wopi_edit_token}}
Content-Type: application/octet-stream
```
audit-#18 shared-drive WOPI PutFile canary
```
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 6 — Round-trip proof: GetFile from the same token returns
# the NEW content, and it's coming from the shared
# drive (the only place `wopi_file_id` exists).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/wopi/files/{{wopi_file_id}}/contents?access_token={{wopi_edit_token}}
HTTP 200
[Asserts]
body contains "audit-#18 shared-drive WOPI PutFile canary"
# ─────────────────────────────────────────────────────────────
# Cleanup — delete the file, then delete the shared drive
# (D3b: empty-drive precondition holds since the file is gone).
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{wopi_file_id}}
Authorization: Bearer {{admin_token}}
HTTP 204
DELETE {{base_url}}/api/drives/{{wopi_drive_id}}
Authorization: Bearer {{admin_token}}
HTTP 204