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:
hamed
2026-08-07 18:46:16 +03:30
co-authored by Claude Opus 5
parent be93e15bad
commit 52c45443c5
4 changed files with 258 additions and 4 deletions
+44 -3
View File
@@ -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>" }` — الزامی.
+1 -1
View File
@@ -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 |