fix(security): enforce doctor scope on POST my/appointment (H1)

createAppointment only checked the caller held an allowed role, then booked
onto whatever doctor_uuid the request named — a doctor could book onto any
other doctor's calendar, a clinic onto doctors outside it, a secretary outside
their scope. Add canBookForDoctor(): doctor→own only, clinic→member doctors,
secretary→active scope + appointments.create permission, admin→any.

Regression: tests/Appointment/BookingScopeTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 18:52:23 +03:30
co-authored by Claude Opus 4.8
parent ae06498a96
commit c084571bf0
4 changed files with 125 additions and 2 deletions
@@ -7,6 +7,7 @@ use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
@@ -59,6 +60,10 @@ class MyAppointmentsController extends BaseController
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
if (!$this->canBookForDoctor($user, $doctor)) {
return $this->error('FORBIDDEN', 'برای این پزشک مجاز به ثبت نوبت نیستید', 403);
}
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) {
$patient = new User($mobile);
@@ -269,6 +274,66 @@ class MyAppointmentsController extends BaseController
]);
}
/**
* Whether the acting user is allowed to book onto this doctor's calendar.
* The role gate alone is not enough: a doctor/clinic/secretary must be
* scoped to the target doctor, otherwise any staff user could book onto
* any doctor's calendar by passing an arbitrary doctor_uuid.
*/
private function canBookForDoctor(User $user, Doctor $doctor): bool
{
$roles = $user->getRoles();
if (in_array('ROLE_ADMIN', $roles, true)) {
return true;
}
if (in_array('ROLE_CLINIC', $roles, true)) {
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null && $clinic->getDoctors()->contains($doctor)) {
return true;
}
}
if (in_array('ROLE_DOCTOR', $roles, true)) {
$own = $this->doctorRepo->findByUser($user);
if ($own !== null && $own->getId() === $doctor->getId()) {
return true;
}
}
if (in_array('ROLE_SECRETARY', $roles, true) && $this->secretaryCanBookForDoctor($user, $doctor)) {
return true;
}
return false;
}
private function secretaryCanBookForDoctor(User $user, Doctor $doctor): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if (!$clinic->getDoctors()->contains($doctor)) {
return false;
}
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
$scopeDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($scopeDoctor !== null && $scopeDoctor->getId() === $doctor->getId()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $scopeDoctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
return false;
}
/**
* تعیین فیلتر نوبت‌ها برای منشی بر اساس scope فعال:
* Returns [type, entity, canView] یا null اگر رابطه‌ای پیدا نشد.