diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index 3ede5ecd..395b810a 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -535,10 +535,16 @@ impl ContactUseCase for ContactService { let address_book_id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - // Check if user has write access to the address book + // AuthZ audit #19 (2026-07-12): previously required + // `Permission::Update`, which is NOT in the Contributor bundle + // (Read + Create) — Contributor grantees on a shared address + // book couldn't add contacts via REST or CardDAV PUT despite + // holding the intended Create permission. `Delete` uses Delete + // (audit #13, above); creation must use Create. Same fix + // applied to `create_contact_from_vcard` + `create_group`. let caller_id = Uuid::parse_str(&dto.user_id) .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; - self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create) .await?; // Convert DTOs to domain entities @@ -614,10 +620,13 @@ impl ContactUseCase for ContactService { let address_book_id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - // Check if user has write access to the address book + // AuthZ audit #19 — see the sibling `create_contact` above. + // This is the CardDAV `PUT contact.vcf` entry point; the fix + // unblocks Contributor grantees creating contacts through the + // CardDAV protocol as well as the REST surface. let caller_id = Uuid::parse_str(&dto.user_id) .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; - self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create) .await?; // Parse vCard data @@ -889,10 +898,10 @@ impl ContactUseCase for ContactService { let address_book_id = Uuid::parse_str(&dto.address_book_id) .map_err(|_| DomainError::validation_error("Invalid address book ID format"))?; - // Check if user has write access to the address book + // AuthZ audit #19 — see the sibling `create_contact` above. let caller_id = Uuid::parse_str(&dto.user_id) .map_err(|_| DomainError::validation_error("Invalid user ID format"))?; - self.require_address_book_perm(&address_book_id, &caller_id, Permission::Update) + self.require_address_book_perm(&address_book_id, &caller_id, Permission::Create) .await?; let group = ContactGroup::new(address_book_id, dto.name); diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl index 566132ad..7057b488 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -518,6 +518,52 @@ HTTP 200 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. DELETE {{base_url}}/api/grants/{{share_grant_id}} Authorization: Bearer {{token}}