fix(contact): use Permission::Delete for deletion verb

This commit is contained in:
Edouard Vanbelle
2026-07-17 00:33:50 +02:00
parent 20b1ea1a6a
commit 38abe6766c
2 changed files with 76 additions and 4 deletions
+13 -4
View File
@@ -756,8 +756,14 @@ impl ContactUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has write access to the address book
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Update)
// AuthZ audit #13 (2026-07-12): previously required
// `Permission::Update`, which the Editor role bundle satisfies
// (Read + Comment + Create + Update). Every Editor grantee on a
// shared address book could delete individual contacts — a
// silent privilege escalation because the intent for CardDAV
// deletion is Delete, not Update. Sibling
// `CalendarService::delete_event` was the ground-truth pattern.
self.require_address_book_perm(contact.address_book_id(), &user_id, Permission::Delete)
.await?;
// Delete the contact
@@ -940,8 +946,11 @@ impl ContactUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Update)
// AuthZ audit #13 (2026-07-12): see the sibling `delete_contact`
// above — required `Update` (in the Editor bundle) instead of
// `Delete`, letting any Editor on a shared address book delete
// groups they shouldn't.
self.require_address_book_perm(group.address_book_id(), &user_id, Permission::Delete)
.await?;
// Delete the group
+63
View File
@@ -455,6 +455,69 @@ Authorization: Bearer {{bob_token}}
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 22 — Alice revokes the grant.
DELETE {{base_url}}/api/grants/{{share_grant_id}}
Authorization: Bearer {{token}}