fix(security): gate every ServiceCatalogController route on the services permission
The controller carried only IS_AUTHENTICATED_FULLY on the class and none of its 15 routes checked a permission. A secretary whose owner had turned `services` fully off could still create, rename and delete service categories, build item groups, replace group members, and rewrite service relations and per-branch overrides. Scope is intra-tenant privilege escalation, not IDOR: owned() and requireItem() already resolve every uuid against the caller's active environment, so no data crossed tenants. Gating is per-action (view/create/update/delete) and reuses denyServices() from ClinicServiceController in the same domain, so a secretary with `update` cannot create or delete. The call is the first statement in every action, before requireCategory/requireItem — placed after, an unknown uuid would answer 404 and leak whether the record exists. An earlier note claimed these endpoints were consumed by the booking flow and so could not be closed. That was wrong. service-selection/validate, the group routes and the relation routes have no consumer in any of the three API clients, and the sibling controller already puts every service read behind services.view — the booking modal reads service-items through it — so any flow needing services already needed the permission. The docs claimed appointment_settings.* for the includes routes, which was never enforced either; corrected to services.*. The test loops the whole route list rather than sampling, and a guard asserts the count of #[Route( equals the count of denyServices( so a future ungated route fails here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -461,6 +461,47 @@ override فقط وقتی اعمال میشود که `branch_uuid` به `valid
|
||||
**قیمت اینجا نیست.** `price_rials` از این اندپوینت حذف شده؛ ارسالش نادیده گرفته میشود
|
||||
و در پاسخ هم نمیآید.
|
||||
|
||||
## مجوزهای کاتالوگ — `ServiceCatalogController`
|
||||
|
||||
هر ۱۵ routeِ این کنترلر پشت منبعِ `services` است، per-action و با همان
|
||||
`denyServices()` کنترلرِ خواهر (`ClinicServiceController`). منشی و پزشکِ عضوِ کلینیک
|
||||
هرکدام با مجوزِ خودشان سنجیده میشوند؛ مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین عبور
|
||||
میکنند.
|
||||
|
||||
> تا پیش از این کنترلر **هیچ گِیتی نداشت** و فقط `IS_AUTHENTICATED_FULLY` روی کلاس بود؛
|
||||
> منشی با `services` کاملاً خاموش هم میتوانست کاتالوگِ محیط خودش را بنویسد. مالکیتِ
|
||||
> محیط همیشه enforce بوده (`owned()` / `requireItem()`)، پس دادهٔ محیط دیگری در دسترس
|
||||
> نبوده — مسئله بالا رفتن سطح دسترسی داخل همان محیط بود.
|
||||
|
||||
| متد | مسیر | مجوز |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/service-categories/tree` | `services.view` |
|
||||
| GET | `/api/v1/service-category/{uuid}/includes` | `services.view` |
|
||||
| GET | `/api/v1/service-item/{uuid}/groups` | `services.view` |
|
||||
| POST | `/api/v1/service-selection/validate` | `services.view` |
|
||||
| POST | `/api/v1/service-category` | `services.create` |
|
||||
| POST | `/api/v1/service-category/{uuid}/includes` | `services.create` |
|
||||
| POST | `/api/v1/service-item/{uuid}/groups` | `services.create` |
|
||||
| PATCH | `/api/v1/service-category/{uuid}` | `services.update` |
|
||||
| PATCH | `/api/v1/item-group/{uuid}` | `services.update` |
|
||||
| PUT | `/api/v1/item-group/{uuid}/items` | `services.update` |
|
||||
| PUT | `/api/v1/service-item/{uuid}/relations` | `services.update` |
|
||||
| PUT | `/api/v1/service-item/{uuid}/branch-overrides` | `services.update` |
|
||||
| DELETE | `/api/v1/service-category/{uuid}` | `services.delete` |
|
||||
| DELETE | `/api/v1/service-category/{uuid}/includes/{childUuid}` | `services.delete` |
|
||||
| DELETE | `/api/v1/item-group/{uuid}` | `services.delete` |
|
||||
|
||||
`service-selection/validate` عمداً `view` است نه `create`: چیزی نمیسازد و فقط یک انتخاب
|
||||
را اعتبارسنجی میکند؛ POST بودنش بهخاطر حجمِ بدنه است.
|
||||
|
||||
گِیت **اولین دستور هر action** است، پیش از `requireCategory`/`requireItem`. اگر بعد از آن
|
||||
میآمد، uuidِ ناشناخته ۴۰۴ میداد و وجود/نبودِ رکورد لو میرفت. نبودِ مجوز →
|
||||
`403 ERR_FORBIDDEN_001`.
|
||||
|
||||
تست: `tests/ClinicService/ServiceCatalogPermissionTest.php` — روی **کل** فهرست routeها
|
||||
حلقه میزند و یک تستِ نگهبان دارد که تعداد `#[Route(` و `denyServices(` را برابر میخواهد،
|
||||
تا routeِ تازهٔ بدون گِیت قرمز شود.
|
||||
|
||||
## دستهٔ درختی
|
||||
|
||||
`GET /api/v1/service-categories/tree` · `POST/PATCH/DELETE /api/v1/service-category[/{uuid}]`
|
||||
@@ -481,9 +522,9 @@ override فقط وقتی اعمال میشود که `branch_uuid` به `valid
|
||||
|
||||
| متد | مسیر | مجوز |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/service-category/{uuid}/includes` | `appointment_settings.view` |
|
||||
| POST | `/api/v1/service-category/{uuid}/includes` | `appointment_settings.update` |
|
||||
| DELETE | `/api/v1/service-category/{uuid}/includes/{childUuid}` | `appointment_settings.update` |
|
||||
| GET | `/api/v1/service-category/{uuid}/includes` | `services.view` |
|
||||
| POST | `/api/v1/service-category/{uuid}/includes` | `services.create` |
|
||||
| DELETE | `/api/v1/service-category/{uuid}/includes/{childUuid}` | `services.delete` |
|
||||
|
||||
**POST body:** `{ "child_category_uuid": "<uuid>" }` — الزامی.
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ Create a secretary for a doctor.
|
||||
| `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 |
|
||||
| `services` | `ClinicServiceController` (sections + items). ⚠ `ServiceCatalogController` (دستهبندی سرویسها، گروهها، روابط) گِیت **ندارد** — اندپوینتهایش در جریانِ ثبت نوبت هم مصرف میشوند و بستنِ یکجا نوبتدهی منشی را میشکند. owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||||
| `services` | `ClinicServiceController` (sections + items) و `ServiceCatalogController` (دستهٔ درختی، گروه انتخاب، روابط، override شعبهای — هر ۱۵ route، per-action؛ [clinic-services.md](clinic-services.md)). owner از محیطِ فعال با `SecretaryAccessChecker::resolveOwnerEntity` حل میشود چون `EntityContextResolver` منشی را نمیشناسد. گیتِ `services.*` پیش از گیتِ اشتراک اجرا میشود | view/create/update/delete |
|
||||
| `staff` | `StaffController` (resolveEntity منشیآگاه) | view/create/update/delete |
|
||||
| `discounts` | `DiscountController` (CRUD؛ `suggestions` جزو flowِ جلسه است و با discounts گِیت نمیشود) | view/create/update/delete |
|
||||
| `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update |
|
||||
|
||||
Reference in New Issue
Block a user