Merge branch 'main' into idp-auto-redirect
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user