diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 50387be0..b5637a1f 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -421,6 +421,8 @@ The envelope is always returned in full (`{version, resources}`); it is never fl Unknown resources and unknown actions in a PATCH body are silently ignored (اعتبارسنجی از `PermissionCatalog::filterPatch`)، so a client cannot invent permission keys. `subscription` و `clinic_doctors` حالا در رجیستری هستند ولی پیش‌فرضشان برای پزشکِ عضو خاموش است — عملیاتِ مالکِ کلینیک‌اند. +**خواندنِ پرونده و تگ.** `PatientController::resolveScope` و `TenantTagController::guardTagView` تا پیش از این فقط منشی را بررسی می‌کردند، پس پزشکِ عضو با `patients.view` خاموش به‌جای `403` یک `200` با فهرست **خالی** می‌گرفت (نشتی نبود — tenant scoping خالی‌اش می‌کرد — ولی یک مجوز دو رفتار داشت). حالا هر دو نقش قرینه‌اند. تگ‌ها مثل قبل با `tags.view` **یا** `patients.view` باز می‌شوند، برای هر دو نقش. + **اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند. نقطهٔ واحد `App\Clinic\Security\ClinicDoctorAccessChecker` (`denyUnlessGranted` / `memberClinicId`) که **فقط پزشکِ عضوِ کلینیک در محیطِ فعالِ کلینیک** را محدود می‌کند؛ مالک/ادمین/منشی/پزشکِ مطبِ شخصی دست‌نخورده عبور می‌کنند. کنترلرهایی که tenant را نقش‌محور حل می‌کنند (Inventory/Tag/Staff/Discount/Sms) با `memberClinicId` پزشکِ عضو را به دادهٔ کلینیک می‌برند (نه مطبِ شخصی). نبودِ مجوز → `403`. در پنل، سایدبار/Route/دکمه‌های CRUD با `usePermissions().can` برای محیطِ `scope=clinic` گِیت می‌شوند. --- diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 00935f8b..768908eb 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -145,7 +145,7 @@ Create a secretary for a doctor. | Resource | Enforced in | Action → endpoint | | --- | --- | --- | | `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status | -| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای؛ افزودن/ویرایشِ زیرآیتم‌ها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete | +| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای — همین قاعده برای پزشکِ عضوِ **فعالِ** کلینیک هم اعمال می‌شود؛ افزودن/ویرایشِ زیرآیتم‌ها = `update`؛ **حذفِ** یادداشت/سند/رکورد/تماس/پیام = `delete` — جدا از `update`) | view/create/update/delete | | `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیف‌پول + پرداختِ جلسه) | view/create/update/delete | | `addresses` | `AddressController::list` (`GET /api/v1/addresses`). فقط `view`؛ نوشتن‌ها owner-only‌اند. تا پیش از این این فهرست روی `appointment_settings.view` سوار بود و توگلِ آدرس‌ها بی‌اثر بود | view | | `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete | diff --git a/src/Clinic/Security/ClinicDoctorAccessChecker.php b/src/Clinic/Security/ClinicDoctorAccessChecker.php index 497f1764..0702fd28 100644 --- a/src/Clinic/Security/ClinicDoctorAccessChecker.php +++ b/src/Clinic/Security/ClinicDoctorAccessChecker.php @@ -68,6 +68,24 @@ class ClinicDoctorAccessChecker return $this->permissions->can($user, $clinic, $resource, $action); } + /** + * پزشکِ عضوی که همکاری‌اش **فعال** است ولی این مجوز را ندارد. + * + * ردیفِ غیرفعال عمداً `false` می‌دهد: «پایان همکاری» با «مجوز خاموش» یکی نیست. + * اولی را دامنهٔ داده با ۴۰۴ می‌بندد (پرونده اصلاً در محیط او نیست و وجودش هم + * فاش نمی‌شود)؛ دومی ۴۰۳ است. جمع‌کردنشان در یک پاسخ، وجودِ رکورد را لو می‌دهد. + */ + public function isActiveMemberDenied(User $user, string $resource, string $action): bool + { + $context = $this->contextResolver->resolve($user); + if (!$context->isClinic()) { + return false; + } + + return $this->permissions->isActiveMember($user, $context->clinic) + && !$this->permissions->can($user, $context->clinic, $resource, $action); + } + /** 403 اگر پزشکِ عضو مجاز نباشد؛ سایر کاربران بدون تغییر عبور می‌کنند. */ public function denyUnlessGranted(User $user, string $resource, string $action): void { diff --git a/src/Clinic/Security/ClinicDoctorPermissionChecker.php b/src/Clinic/Security/ClinicDoctorPermissionChecker.php index 0c521a5b..66ce122e 100644 --- a/src/Clinic/Security/ClinicDoctorPermissionChecker.php +++ b/src/Clinic/Security/ClinicDoctorPermissionChecker.php @@ -21,6 +21,26 @@ class ClinicDoctorPermissionChecker private readonly DoctorRepository $doctorRepo, ) {} + /** + * پزشکی که ردیفِ همکاری‌اش با این کلینیک **فعال** است. + * + * مالک و ادمین «عضو» حساب نمی‌شوند — آن‌ها اصلاً با این مجوزها سنجیده نمی‌شوند. + * ردیفِ غیرفعال یعنی پایان همکاری، که با «مجوز خاموش» یکی نیست. + */ + public function isActiveMember(User $user, Clinic $clinic): bool + { + if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) { + return false; + } + + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null || !$clinic->hasDoctor($doctor)) { + return false; + } + + return $this->permRepo->getOrCreate($clinic, $doctor)->isActive(); + } + public function can(User $user, Clinic $clinic, string $resource, string $action): bool { if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) { diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 8520b96b..72e0e0ac 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -1284,6 +1284,16 @@ class PatientController extends BaseController return PatientRecordScope::unknown(); } + // قرینهٔ همان قاعده برای پزشکِ عضوِ کلینیک. تا پیش از این فقط منشی اینجا + // بررسی می‌شد، پس پزشکِ عضو با `patients.view` خاموش به‌جای ۴۰۳، ۲۰۰ با + // فهرست خالی می‌گرفت — یک مجوز، دو رفتار. + // + // isActiveMemberDenied و نه canOrNonMember: ردیفِ غیرفعال یعنی پایان + // همکاری، که باید مثل قبل ۴۰۴ بدهد نه ۴۰۳ — وگرنه وجودِ پرونده لو می‌رود. + if ($this->clinicDoctorAccess->isActiveMemberDenied($user, 'patients', 'view')) { + return PatientRecordScope::unknown(); + } + return $this->scopeResolver->resolve($user); } diff --git a/src/Tag/Controller/TenantTagController.php b/src/Tag/Controller/TenantTagController.php index 6b291a00..1ed2e662 100644 --- a/src/Tag/Controller/TenantTagController.php +++ b/src/Tag/Controller/TenantTagController.php @@ -38,11 +38,24 @@ class TenantTagController extends BaseController private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, ) {} - /** تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. */ + /** + * تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. + * + * هر نقش با مجوزهای خودش سنجیده می‌شود. تا پیش از این فقط منشی بررسی می‌شد و + * پزشکِ عضوِ کلینیک — برخلاف نوشتن‌ها که هر دو checker را دارند — از خواندن رد + * می‌شد؛ یک مجوز با دو رفتار. + */ private function guardTagView(User $user): void { - if (!$this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view') - && !$this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view')) { + $secretaryOk = $this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view') + || $this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view'); + + // ردیفِ غیرفعال (پایان همکاری) اینجا محدود نمی‌شود؛ دامنهٔ داده خودش + // خالی‌اش می‌کند. فقط عضوِ فعالِ بی‌مجوز رد می‌شود. + $memberOk = !$this->clinicDoctorAccess->isActiveMemberDenied($user, 'tags', 'view') + || !$this->clinicDoctorAccess->isActiveMemberDenied($user, 'patients', 'view'); + + if (!$secretaryOk || !$memberOk) { throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403); } } diff --git a/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php index 17b85ba0..ed510ea6 100644 --- a/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php +++ b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php @@ -89,4 +89,89 @@ class ClinicDoctorPermissionEnforcementTest extends ApiTestCase $this->authJson('GET', '/api/v1/inventory-items', $doctorUser); $this->assertSame(200, $this->responseCode()); } + // ── خواندنِ پرونده و تگ — قرینهٔ منشی ──────────────────────────────────── + + /** + * تا پیش از این فقط منشی در PatientController::resolveScope بررسی می‌شد، پس + * پزشکِ عضو با `patients.view` خاموش به‌جای ۴۰۳، ۲۰۰ با فهرست خالی می‌گرفت. + */ + public function testPatientListDeniedWhenPatientsViewOff(): void + { + [$doctorUser, $perm] = $this->makeMemberDoctor(); + $perm->mergePermissions(['resources' => ['patients' => ['view' => false]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/patients', $doctorUser); + $this->assertSame(403, $this->responseCode()); + } + + /** پیش‌فرضِ پزشکِ عضو `patients.view = true` است. */ + public function testPatientListAllowedByDefault(): void + { + [$doctorUser] = $this->makeMemberDoctor(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/patients', $doctorUser); + $this->assertSame(200, $this->responseCode()); + } + + /** تگ‌ها با tags.view یا patients.view باز می‌شوند — همان قاعدهٔ منشی. */ + public function testTagListAllowedViaPatientsViewEvenWhenTagsViewOff(): void + { + [$doctorUser, $perm] = $this->makeMemberDoctor(); + $perm->mergePermissions(['resources' => [ + 'patients' => ['view' => true], + 'tags' => ['view' => false], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/tenant-tags', $doctorUser); + $this->assertSame(200, $this->responseCode()); + } + + public function testTagListDeniedWhenNeitherTagsNorPatientsViewGranted(): void + { + [$doctorUser, $perm] = $this->makeMemberDoctor(); + $perm->mergePermissions(['resources' => [ + 'patients' => ['view' => false], + 'tags' => ['view' => false], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/tenant-tags', $doctorUser); + $this->assertSame(403, $this->responseCode()); + } + + public function testTagListAllowedWithTagsViewAlone(): void + { + [$doctorUser, $perm] = $this->makeMemberDoctor(); + $perm->mergePermissions(['resources' => [ + 'patients' => ['view' => false], + 'tags' => ['view' => true], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/tenant-tags', $doctorUser); + $this->assertSame(200, $this->responseCode()); + } + + /** + * «پایان همکاری» با «مجوز خاموش» یکی نیست: ردیفِ غیرفعال نباید ۴۰۳ بدهد، + * وگرنه وجودِ پرونده لو می‌رود. دامنهٔ داده خودش آن را می‌بندد. + */ + public function testDeactivatedMemberIsNotAnswered403OnTheList(): void + { + [$doctorUser, $perm] = $this->makeMemberDoctor(); + $perm->setActive(false); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/patients', $doctorUser); + + $this->assertNotSame( + 403, + $this->responseCode(), + 'ردیفِ غیرفعال باید از مسیرِ دامنهٔ داده بسته شود، نه با ۴۰۳ مجوز', + ); + } + }