fix(permissions): make the addresses resource real instead of decorative
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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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' => 'حذف آدرس',
|
||||
],
|
||||
],
|
||||
'clinic_info' => [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user