From 294ca19a4612b6c208eef572c4ab9ec53695e664 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 19:16:02 +0330 Subject: [PATCH] fix(permissions): make the addresses resource real instead of decorative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full role-by-role sweep (9 roles x 18 endpoints against the running app) showed the addresses toggles in the owner's permission form controlled nothing. Grep confirms it: no gate anywhere referenced 'addresses'. The panel's address list was gated on appointment_settings.view instead — the same borrowed-permission pattern already fixed for resources and treatment. GET /api/v1/addresses now gates on addresses.view. The resource drops to view-only. Creating, updating and deleting an address in ClinicController is explicitly owner-or-admin ($clinic->getUser()->getId() !== $user->getId()), so those three actions could never be delegated to a secretary or an invited doctor no matter what the form said. Both role defaults narrow to ['view' => true] to match, and stored JSON keeps its old keys harmlessly since merge only reads registry keys. This widens secretary access: addresses.view defaults to true while appointment_settings.view defaults to false, so secretaries who could not list addresses now can. That is deliberate and costs no confidentiality — the same addresses are already served anonymously from GET /api/v1/clinic/{uuid}/addresses, which is whitelisted in security.yaml. Verified live in three states: default 200, addresses.view off 403, and addresses off with appointment_settings on still 403, proving the borrow is gone. Co-Authored-By: Claude Opus 5 --- docs/api/clinic.md | 2 +- docs/api/permission.md | 11 +++++- docs/api/secretary.md | 3 +- src/Clinic/Entity/ClinicDoctorPermission.php | 2 +- src/Doctor/Controller/AddressController.php | 6 ++- src/Secretary/Entity/DoctorSecretary.php | 2 +- src/Shared/Security/PermissionCatalog.php | 10 +++-- .../SecretaryResourceEnforcementTest.php | 39 +++++++++++++++++++ tests/Shared/PermissionCatalogTest.php | 12 ++++++ 9 files changed, 75 insertions(+), 12 deletions(-) diff --git a/docs/api/clinic.md b/docs/api/clinic.md index 590770b4..50387be0 100644 --- a/docs/api/clinic.md +++ b/docs/api/clinic.md @@ -401,7 +401,7 @@ The envelope is always returned in full (`{version, resources}`); it is never fl "treatment": { "view": true, "update": true }, "payments": { "view": true, "create": false, "update": false, "delete": false }, "insurances": { "view": true, "create": false, "update": false, "delete": false }, - "addresses": { "view": true, "create": false, "update": false, "delete": false }, + "addresses": { "view": true }, "clinic_info": { "view": true, "update": false }, "services": { "view": true, "create": false, "update": false, "delete": false }, "inventory": { "view": false, "create": false, "update": false, "delete": false }, diff --git a/docs/api/permission.md b/docs/api/permission.md index 383117b1..9e685b95 100644 --- a/docs/api/permission.md +++ b/docs/api/permission.md @@ -82,8 +82,15 @@ `clinic_info` · `services` · `inventory` · `staff` · `tags` · `discounts` · `sms` · `appointment_settings` · `resources` · `clinic_doctors` · `subscription` -اکشن‌ها یکسان نیستند: `subscription` فقط `view/create` دارد، و -`clinic_info` / `appointment_settings` / `treatment` فقط `view/update`. +اکشن‌ها یکسان نیستند: `subscription` فقط `view/create` دارد، +`clinic_info` / `appointment_settings` / `treatment` فقط `view/update`، و +`addresses` **فقط `view`**. + +منبعِ `addresses` عمداً `create/update/delete` ندارد: ساخت و ویرایش و حذفِ آدرس در +`ClinicController` صریحاً owner-or-admin است +(`$clinic->getUser()->getId() !== $user->getId()`) و قابل واگذاری به منشی یا پزشکِ +عضو نیست. تا پیش از این هر سهٔ آن توگل‌ها در فرمِ مالک بودند و هیچ چیزی را کنترل +نمی‌کردند. ### Errors diff --git a/docs/api/secretary.md b/docs/api/secretary.md index ebdda652..00935f8b 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -134,7 +134,7 @@ Create a secretary for a doctor. مجموعهٔ منابع را دیگر این فایل تعیین نمی‌کند: منبعِ واحد `App\Shared\Security\PermissionCatalog` است و از `GET /api/v1/permission-catalog` هم خوانده می‌شود — [permission.md](permission.md). فهرستِ فعلی: `appointments`, `patients`, `treatment`, `payments`, `insurances`, `addresses`, `clinic_info`, `services`, `inventory`, `staff`, `tags`, `discounts`, `sms`, `appointment_settings`, `resources`, `clinic_doctors`, `subscription`. -منبعِ `subscription` فقط `view/create` دارد؛ `clinic_info`, `appointment_settings` و `treatment` فقط `view/update`. منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو) و در کاتالوگ با `clinic_only: true` علامت خورده. +منبعِ `subscription` فقط `view/create` دارد؛ `clinic_info`, `appointment_settings` و `treatment` فقط `view/update`؛ و `addresses` فقط `view` (نوشتنِ آدرس owner-only است). منبعِ `clinic_doctors` **فقط در حالت کلینیک** معنا دارد (پزشک مستقل نه toggle نه منو) و در کاتالوگ با `clinic_only: true` علامت خورده. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند و **هر دو شکلِ ورودی** را می‌پذیرد: با envelope (`{version, resources:{…}}`) و نقشهٔ تخت (`{patients:{…}}`). تا پیش از این فقط شکلِ اول خوانده می‌شد و صفحهٔ ادمین که تخت می‌فرستد بی‌صدا بی‌اثر بود. منبع یا اکشنِ خارج از رجیستری بی‌صدا کنار گذاشته می‌شود؛ بقیهٔ کلیدهای همان درخواست اعمال می‌شوند. @@ -147,6 +147,7 @@ Create a secretary for a doctor. | `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status | | `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 | | `inventory` | `InventoryController` (items + packages) | view/create/update/delete | | `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتن‌ها با `tags.*`) | view/create/update/delete | diff --git a/src/Clinic/Entity/ClinicDoctorPermission.php b/src/Clinic/Entity/ClinicDoctorPermission.php index 2fabadde..00398fbd 100644 --- a/src/Clinic/Entity/ClinicDoctorPermission.php +++ b/src/Clinic/Entity/ClinicDoctorPermission.php @@ -39,7 +39,7 @@ class ClinicDoctorPermission 'services' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], 'clinic_info' => ['view' => true, 'update' => false], 'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], - 'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'addresses' => ['view' => true], 'resources' => ['view' => true, 'create' => true, 'update' => true, 'delete' => true], 'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], diff --git a/src/Doctor/Controller/AddressController.php b/src/Doctor/Controller/AddressController.php index 867d0ab0..f19700ea 100644 --- a/src/Doctor/Controller/AddressController.php +++ b/src/Doctor/Controller/AddressController.php @@ -35,8 +35,10 @@ class AddressController extends BaseController #[Route('/api/v1/addresses', name: 'address_list', methods: ['GET'])] public function list(#[CurrentUser] User $user): JsonResponse { - $this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'view'); - $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', 'view'); + // منبعِ خودش، نه `appointment_settings` قرضی: توگلِ «آدرس‌ها» در فرمِ مالک + // تا پیش از این هیچ چیزی را کنترل نمی‌کرد. + $this->secretaryAccess->denyUnlessGranted($user, 'addresses', 'view'); + $this->clinicDoctorAccess->denyUnlessGranted($user, 'addresses', 'view'); return $this->success(array_map( static fn (DoctorAddress $a): array => $a->toArray(), diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index 0d621dce..fb2ebadf 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -40,7 +40,7 @@ class DoctorSecretary 'treatment' => ['view' => true, 'update' => false], 'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], 'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], - 'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'addresses' => ['view' => true], 'clinic_info' => ['view' => true, 'update' => false], 'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], 'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], diff --git a/src/Shared/Security/PermissionCatalog.php b/src/Shared/Security/PermissionCatalog.php index d6f42d3a..e2e33aaa 100644 --- a/src/Shared/Security/PermissionCatalog.php +++ b/src/Shared/Security/PermissionCatalog.php @@ -65,13 +65,15 @@ final class PermissionCatalog 'delete' => 'حذف بیمه', ], ], + /** + * فقط `view`. ساخت/ویرایش/حذفِ آدرس در ClinicController به‌صورت صریح + * owner-or-admin است (`$clinic->getUser()->getId() !== $user->getId()`) + * و اصلاً قابل واگذاری نیست؛ سه توگلِ قبلی هیچ‌وقت چیزی را کنترل نمی‌کردند. + */ 'addresses' => [ 'label' => 'آدرس‌ها', 'actions' => [ - 'view' => 'مشاهده آدرس‌ها', - 'create' => 'ایجاد آدرس', - 'update' => 'ویرایش آدرس', - 'delete' => 'حذف آدرس', + 'view' => 'مشاهده آدرس‌ها', ], ], 'clinic_info' => [ diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index a8decc60..c79763fc 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -376,4 +376,43 @@ class SecretaryResourceEnforcementTest extends ApiTestCase return [$secretary, $rel, $clinic, $doctor]; } + // ── آدرس‌ها ────────────────────────────────────────────────────────────── + + /** پیش‌فرضِ منشی `addresses.view = true` است. */ + public function testAddressListAllowedByDefault(): void + { + [$secretary] = $this->makeClinicSecretary(); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/addresses', $secretary); + $this->assertSame(200, $this->responseCode()); + } + + public function testAddressListDeniedWhenAddressesViewOff(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => ['addresses' => ['view' => false]]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/addresses', $secretary); + $this->assertSame(403, $this->responseCode()); + } + + /** + * تا پیش از این، فهرست آدرس روی `appointment_settings.view` سوار بود و توگلِ + * «آدرس‌ها» هیچ چیزی را کنترل نمی‌کرد. حالا تنظیمات نوبت‌دهی درش را باز نمی‌کند. + */ + public function testAppointmentSettingsNoLongerOpensTheAddressList(): void + { + [$secretary, $rel] = $this->makeClinicSecretary(); + $rel->mergePermissions(['resources' => [ + 'addresses' => ['view' => false], + 'appointment_settings' => ['view' => true, 'update' => true], + ]]); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/addresses', $secretary); + $this->assertSame(403, $this->responseCode()); + } + } diff --git a/tests/Shared/PermissionCatalogTest.php b/tests/Shared/PermissionCatalogTest.php index 38eab7f7..04a8e322 100644 --- a/tests/Shared/PermissionCatalogTest.php +++ b/tests/Shared/PermissionCatalogTest.php @@ -119,4 +119,16 @@ class PermissionCatalogTest extends TestCase $api[array_search('clinic_doctors', array_column($api, 'key'), true)]['clinic_only'], ); } + + /** + * ساخت/ویرایش/حذفِ آدرس در ClinicController صریحاً owner-or-admin است و قابل + * واگذاری نیست؛ پس رجیستری نباید توگلی نشان دهد که هیچ‌وقت اثر ندارد. + */ + public function testAddressesExposesOnlyView(): void + { + $this->assertSame( + ['view'], + array_keys(PermissionCatalog::RESOURCES['addresses']['actions']), + ); + } }