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:
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
|
||||
/**
|
||||
* The appointment-settings list endpoints (date-overrides, holidays,
|
||||
* available-locations) must not leak another doctor's configuration.
|
||||
*/
|
||||
class AppointmentSettingsListOwnershipTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{0: string}> */
|
||||
public static function endpoints(): iterable
|
||||
{
|
||||
yield 'date-override list' => ['/api/v1/appointment-settings/date-override/list/'];
|
||||
yield 'holidays list' => ['/api/v1/appointment-settings/holidays/list/'];
|
||||
yield 'available-locations' => ['/api/v1/appointment-settings/available-locations/'];
|
||||
}
|
||||
|
||||
#[DataProvider('endpoints')]
|
||||
public function testStrangerForbidden(string $prefix): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('GET', $prefix . $doctor->getUuid(), $stranger);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
#[DataProvider('endpoints')]
|
||||
public function testOwnerAllowed(string $prefix): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
$this->authJson('GET', $prefix . $doctor->getUuid(), $doctor->getUser());
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Binding staff to a service item must be scoped: a tenant must not attach
|
||||
* another tenant's staff member via staff_uuid.
|
||||
*/
|
||||
class ServiceItemStaffOwnershipTest extends ApiTestCase
|
||||
{
|
||||
public function testCannotBindForeignStaff(): void
|
||||
{
|
||||
// owner A: their section + item
|
||||
$ownerA = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctorA = new Doctor($ownerA, 'دکتر A');
|
||||
$this->em->persist($doctorA);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctorA->getId(), 'بخش A');
|
||||
$item = new ServiceItem($section, 'خدمت');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
|
||||
// unrelated tenant B's staff
|
||||
$doctorB = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر B');
|
||||
$this->em->persist($doctorB);
|
||||
$this->em->flush();
|
||||
$foreignStaff = new ClinicStaff('doctor', $doctorB->getId(), 'پرسنل B');
|
||||
$this->em->persist($foreignStaff);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $ownerA, [
|
||||
'staff_uuid' => $foreignStaff->getUuid(),
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /clinic-pro/doctor-address/{id} must enforce ownership — a doctor must not
|
||||
* read another doctor's address by id enumeration.
|
||||
*/
|
||||
class DoctorAddressOwnershipTest extends ApiTestCase
|
||||
{
|
||||
private function makeAddress(): DoctorAddress
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر مالک');
|
||||
$this->em->persist($doctor);
|
||||
$address = DoctorAddress::forDoctor($doctor)->setName('مطب');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
public function testOtherDoctorCannotRead(): void
|
||||
{
|
||||
$address = $this->makeAddress();
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/clinic-pro/doctor-address/' . $address->getId(), $stranger);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnerCanRead(): void
|
||||
{
|
||||
$address = $this->makeAddress();
|
||||
$owner = $address->getDoctor()->getUser();
|
||||
|
||||
$this->authJson('GET', '/api/v1/clinic-pro/doctor-address/' . $address->getId(), $owner);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\DoctorInsurance;
|
||||
use App\Insurance\Entity\Insurance;
|
||||
use App\Insurance\Enum\InsuranceType;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/insurance/{id} must enforce ownership — a doctor must not read
|
||||
* another doctor's insurance record (incl. negotiated price) by id enumeration.
|
||||
*/
|
||||
class DoctorInsuranceOwnershipTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctorInsurance(): DoctorInsurance
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر مالک');
|
||||
$this->em->persist($doctor);
|
||||
$insurance = new Insurance('بیمه تست', InsuranceType::Basic);
|
||||
$this->em->persist($insurance);
|
||||
$di = new DoctorInsurance($doctor, $insurance);
|
||||
$this->em->persist($di);
|
||||
$this->em->flush();
|
||||
|
||||
return $di;
|
||||
}
|
||||
|
||||
public function testOtherDoctorCannotRead(): void
|
||||
{
|
||||
$di = $this->makeDoctorInsurance();
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/insurance/' . $di->getId(), $stranger);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnerCanRead(): void
|
||||
{
|
||||
$di = $this->makeDoctorInsurance();
|
||||
$owner = $di->getDoctor()->getUser();
|
||||
|
||||
$this->authJson('GET', '/api/v1/insurance/' . $di->getId(), $owner);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user