fix(security): enforce ownership on 4 IDOR read/bind endpoints (M2-M5)

- M2 GET /insurance/{id}: was unguarded; now owner-or-admin (403 otherwise) —
  stops reading another doctor's negotiated price by id enumeration.
- M3 GET /clinic-pro/doctor-address/{id}: add the same owner/admin check the
  sibling PATCH/DELETE already had.
- M4 POST/PATCH /service-item: staff_uuid must belong to the caller's tenant
  (entity_type/entity_id) → 422; stops binding another tenant's staff.
- M5 appointment-settings list endpoints (date-override/holidays/
  available-locations): add the per-doctor ownership check the sibling
  single-record endpoints already enforce.

Regressions (6 negative cases fail without the fixes):
DoctorInsuranceOwnershipTest, DoctorAddressOwnershipTest,
ServiceItemStaffOwnershipTest, AppointmentSettingsListOwnershipTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 20:17:52 +03:30
co-authored by Claude Opus 4.8
parent 23ca56b293
commit fe6383314e
12 changed files with 236 additions and 16 deletions
+3 -3
View File
@@ -248,7 +248,7 @@ Override a specific date — mark it inactive (day off) or give it custom sessio
Get all date overrides for a doctor.
**Permission:** `AUTH` (class-level)
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -425,7 +425,7 @@ Mark a date range as holiday — all slots blocked, no overrides apply.
Get all holidays for a doctor.
**Permission:** `AUTH` (class-level)
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
### Response `200`
```json
@@ -573,7 +573,7 @@ The `SlotCalculatorService` calculates available slots in this priority order:
### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}`
**Permission:** Public
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise).
Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to.
+2
View File
@@ -147,6 +147,8 @@
}
```
> `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربط‌دادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال می‌شود.
---
## DELETE /api/v1/service-item/{uuid}
+1 -1
View File
@@ -225,7 +225,7 @@ Add an insurance to a doctor's accepted list.
Get a doctor-insurance link.
**Permission:** `PUBLIC`
**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (otherwise `403 ERR_AUTH_006`). Prevents reading another doctor's negotiated price by id enumeration.
### Response `200`
DoctorInsurance object.
+4 -4
View File
@@ -55,10 +55,10 @@ _None outstanding._
| # | Task | File:line | Cat | How to test |
|---|------|-----------|-----|-------------|
| ✅M1 | Mass-assign: claim approve/pay lets owning tenant set arbitrary `approved_rials`/`paid_rials` ~~+ force status~~ | src/Billing/Controller/BillingController.php | security-massassign | **DONE** — status-jump part was already guarded by `canTransitionTo` (false alarm). Added bounds: approved ∈ [0, claimed], paid ∈ [0, approved] → 422. `tests/Billing/ClaimAmountBoundsTest`. |
| M2 | IDOR read: `GET /insurance/{id}` (showDoctorInsurance) leaks any doctor's insurance incl. negotiated price by id enumeration | src/Insurance/Controller/InsuranceController.php:499 | security-idor | GET insurance/{id} as non-owner → 403 |
| M3 | IDOR read: `showAddress` loads any DoctorAddress by id, no owner check | src/Doctor/Controller/DoctorController.php:559 | security-idor | GET doctor-address/{id} as non-owner → 403 |
| M4 | IDOR write: ClinicService `createItem`/`updateItem` bind staff via global `staffRepo->findByUuid()`, no entity-scope check (cross-tenant staff binding) | src/ClinicService/Controller/ClinicServiceController.php:157,195 | security-idor | Bind other clinic's staff_uuid → 403/validation |
| M5 | IDOR read: AppointmentSettings list endpoints leak any doctor's config — `listOverrides`, `listHolidays`, `availableLocations` (no ownership on {doctorUuid}) | src/Appointment/Controller/AppointmentSettingsController.php:150,256,349 | security-idor | GET each list for unowned doctor → 403 |
| M2 | IDOR read: `GET /insurance/{id}` (showDoctorInsurance) leaks any doctor's insurance incl. negotiated price by id enumeration | src/Insurance/Controller/InsuranceController.php:499 | security-idor | **DONE** — auth+ownership (owner/admin). `tests/Insurance/DoctorInsuranceOwnershipTest` |
| M3 | IDOR read: `showAddress` loads any DoctorAddress by id, no owner check | src/Doctor/Controller/DoctorController.php:559 | security-idor | **DONE** — ownership mirror of PATCH/DELETE. `tests/Doctor/DoctorAddressOwnershipTest` |
| M4 | IDOR write: ClinicService `createItem`/`updateItem` bind staff via global `staffRepo->findByUuid()`, no entity-scope check (cross-tenant staff binding) | src/ClinicService/Controller/ClinicServiceController.php:157,195 | security-idor | **DONE** — staff must match tenant (entity_type/id) → 422. `tests/ClinicService/ServiceItemStaffOwnershipTest` |
| M5 | IDOR read: AppointmentSettings list endpoints leak any doctor's config — `listOverrides`, `listHolidays`, `availableLocations` (no ownership on {doctorUuid}) | src/Appointment/Controller/AppointmentSettingsController.php:150,256,349 | security-idor | **DONE** — ownership added to listOverrides/listHolidays/availableLocations. `tests/Appointment/AppointmentSettingsListOwnershipTest` |
| M6 | OTP send-code has no per-mobile/per-uuid cap, only per-IP (5/hr) → SMS flood from rotating IPs | src/Auth/Controller/AuthController.php:136-153 · OtpService.php:59-77 · rate_limiter.yaml:4-7 | security-ratelimit | Request many codes one mobile across IPs → per-number cap enforced |
| M7 | Refresh token not rotated on use (same raw token 30d), never re-checks user status | src/Auth/Controller/AuthController.php:477-480 · TokenService.php:33-45 | security-auth | Call refresh twice → new token issued each time, suspended user rejected |
| M8 | N+1: secretary list lazy-loads secretary/doctor ManyToOne per row | src/Secretary/Controller/SecretaryController.php:192,213 | perf-nplus1 | Profiler secretary list → ~2 queries/row |