feat(antienum): 403 when sub can read, 404 otherwise

this is a UX improvement, always return a 404 not found when subject do not have any access on the resource
    but returns an explicit 403 forbidden is subject try a forbidden action on a resourse it can read

    regarding performance, the role is already in cache for the second call with read perm
This commit is contained in:
Edouard Vanbelle
2026-07-16 21:34:31 +02:00
parent 5b996bb218
commit 7aea383588
9 changed files with 252 additions and 153 deletions
+105 -35
View File
@@ -16,6 +16,28 @@ use crate::domain::services::authorization::{
ResourceKind, Role, Subject,
};
/// Discriminates the two denial shapes surfaced by
/// [`AuthorizationEngine::require_visible`] in the `authz.denied` audit line.
/// Log-aggregation consumers key off the string form via `as_str`; keep the
/// values stable — a new denial shape means a new variant, never a renamed
/// existing one.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum AuthzDenialVisibility {
/// Caller has `Read` on the resource — 403 Forbidden.
Visible,
/// Caller has no `Read` — 404 anti-enum.
Hidden,
}
impl AuthzDenialVisibility {
pub fn as_str(self) -> &'static str {
match self {
Self::Visible => "visible",
Self::Hidden => "hidden",
}
}
}
pub trait AuthorizationEngine: Send + Sync + 'static {
/// Returns true if `subject` has `permission` on `resource`, considering
/// owner short-circuit AND cascading from folder ancestors.
@@ -53,9 +75,28 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
Ok(allowed)
}
/// Convenience wrapper around `check`: returns `Ok(())` when allowed and
/// `DomainError::not_found` when denied (anti-enumeration — same error as
/// "resource doesn't exist" so attackers can't probe IDs by error shape).
/// Graduated-denial wrapper around `check`. Semantics:
///
/// - `permission` granted → `Ok(())`
/// - `permission` denied, `Read` also denied → `DomainError::not_found`
/// (404, anti-enumeration — same shape as "doesn't exist" so a probing
/// caller can't distinguish "wrong id" from "no access")
/// - `permission` denied, `Read` granted → `DomainError::access_denied`
/// (403 — the caller can already see the resource, so hiding existence
/// leaks nothing new; a clear 403 beats a confusing 404 for UX and for
/// API-first clients like rclone)
///
/// Special case: when `permission == Read`, the visibility gate collapses
/// onto itself — a `Read` denial IS a "hidden" outcome by definition, so
/// the method short-circuits to the strict anti-enum 404 without a second
/// DB round-trip. That's why there's only one method: strict Read-denial
/// and graduated write-denial fall out of the same signature.
///
/// Do NOT use this in search / enumeration paths where existence itself is
/// the attack vector — those must filter at the SQL/index layer, never
/// touch this method with per-row ids. Cross-tenant probes on ids the
/// caller has no prior read handle for degrade to the 404 shape naturally
/// (Read denied → `Hidden`).
async fn require(
&self,
subject: Subject,
@@ -80,39 +121,68 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
permission,
resource
);
Ok(())
return Ok(());
}
// Visibility probe. Short-circuit: when the target permission IS
// `Read` and the check above returned false, we already know Read is
// denied — visibility is `Hidden` by definition, no second DB hop.
// Otherwise probe Read; a DB-hop failure here degrades to `Hidden` so
// the caller sees the strict anti-enum shape (safe default).
let visibility = if permission == Permission::Read {
AuthzDenialVisibility::Hidden
} else if self
.check(subject, Permission::Read, resource)
.await
.unwrap_or(false)
{
AuthzDenialVisibility::Visible
} else {
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed
// through the `audit` tracing target so log aggregators can
// surface them separately from operational debug traffic.
// Span context (request_id, client_ip, user_id) is attached
// automatically by the request-scope span set in
// `interfaces/middleware/trace_span.rs`, so this log line
// doesn't need to duplicate those fields — they appear in
// the structured output of every log written inside the
// request span.
tracing::info!(
target: "audit",
event = "authz.denied",
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
"👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
subject,
permission,
resource
);
Err(DomainError::not_found(kind, id.to_string()))
AuthzDenialVisibility::Hidden
};
let (kind, id) = match resource {
Resource::Folder(id) => ("Folder", id),
Resource::File(id) => ("File", id),
Resource::Drive(id) => ("Drive", id),
Resource::Calendar(id) => ("Calendar", id),
Resource::AddressBook(id) => ("AddressBook", id),
Resource::Playlist(id) => ("Playlist", id),
};
// Audit-worthy: denials are the interesting signal. Routed through
// the `audit` tracing target so log aggregators can surface them
// separately from operational debug traffic. Span context
// (request_id, client_ip, user_id) comes from the request-scope
// span set in `interfaces/middleware/trace_span.rs`, so this line
// doesn't need to duplicate those fields.
//
// The `visibility` field discriminates the two denial shapes for
// operators grepping exists-but-denied vs fully-hidden. `visible`
// denials are the ones surfaced to the caller as 403 (and safe to
// detail in the UI); `hidden` denials are the 404 anti-enum path.
tracing::info!(
target: "audit",
event = "authz.denied",
visibility = visibility.as_str(),
subject_type = subject.type_str(),
subject_id = %subject.id(),
permission = permission.as_str(),
resource_type = resource.type_str(),
resource_id = %resource.id(),
"👮🏻‍♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}' (visibility={})",
subject,
permission,
resource,
visibility.as_str()
);
match visibility {
AuthzDenialVisibility::Visible => Err(DomainError::access_denied(
kind,
format!("Missing '{}' permission on {} {}", permission, kind, id),
)),
AuthzDenialVisibility::Hidden => Err(DomainError::not_found(kind, id.to_string())),
}
}
+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
# ─────────────────────────────────────────────────────────────
+10 -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,12 @@ 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 22 — Alice revokes the grant.
+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
+22 -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,17 @@ Authorization: Bearer {{adam_token}}
HTTP 204
# ── Delete still denied (Editor excludes Delete) ────────────
# ── 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
# ════════════════════════════════════════════════════════════════════
+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
# ─────────────────────────────────────────────────────────────
+26 -21
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,48 +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. Anti-enum 404 shape.
# 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`,
# which collapsed the `NotFound` that `authz.require`
# returns on denial into HTTP 500 — an "exists-but-denied"
# oracle. Fix routes through `AppError::from` so the same
# denial surfaces as 404, indistinguishable from a source
# path that simply doesn't exist.
# 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 404
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 9c — Bob (VIEWER) CANNOT DELETE the probe folder.
# DELETE requires Delete on the target, which Viewer
# doesn't have. Anti-enum 404 shape — same regression
# pin as 9b (`map_err → internal_error` collapsed
# the `NotFound` from authz.require into a 500 oracle).
# doesn't have. Bob has Read → 403.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
Authorization: Bearer {{bob_token}}
HTTP 404
HTTP 403
# ─────────────────────────────────────────────────────────────