Files
clinicpro/.claude/prompt/service-catalog-permission-gate.md
hamedandClaude Opus 5 1d10f8c907 docs(api): correct the subscription/my enforcement claim
The previous note said GET /api/v1/subscription/my has no gate because it
returns 200 with every permission off. That was wrong. It gates deliberately
with a degraded payload instead of a 403: without subscription.view the
response drops the active subscription and used_trial, and effective_plan
keeps only features, max_secretaries and max_resources — no plan identity,
no billing. Verified against the running app both ways.

The 403 it does not throw is the point: FeatureGate and useSubscription need
capability flags on every page, so a 403 would break the whole panel.

Also adds .claude/prompt/service-catalog-permission-gate.md for the one real
gap, with the per-route analysis that was previously deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:38:36 +03:30

271 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# گِیتِ مجوز برای ServiceCatalogController
## پروژه
`clinicpro` (backend + پنل ادمین). cross-repo نیست — بررسی شد، هیچ‌کدام از دو کلاینت
دیگر این اندپوینت‌ها را مصرف نمی‌کنند.
## زمینه
بعد از یکی‌سازیِ مجوزها در `PermissionCatalog`، یک عبورِ سیستماتیک روی همهٔ routeها با
منشیِ واقعی و **همهٔ مجوزها خاموش** انجام شد. ۱۰ منبع از ۱۰ منبع درست ۴۰۳ دادند، ولی
`GET /api/v1/service-categories/tree` با همهٔ مجوزها خاموش هم `200` برگرداند.
علتش این است که `ServiceCatalogController` روی کلاسش فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
دارد و **هیچ‌کدام از ۱۵ routeاش** گِیت مجوز ندارند.
## مشکل / هدف
هر کاربرِ لاگین‌کرده‌ای که محیطِ فعال دارد — از جمله منشی با `services` کاملاً خاموش —
می‌تواند در **محیط خودش** دستهٔ سرویس بسازد، ویرایش و حذف کند، گروه بسازد، اقلام گروه
را جایگزین کند و روابط و override‌های شعبه‌ای سرویس را بازنویسی کند.
### دامنهٔ دقیق ریسک — این IDOR نیست
مالکیتِ محیط **کاملاً enforce شده** است. هر route از `requireCategory` / `requireGroup` /
`requireItem` رد می‌شود و همه به `owned()` می‌رسند:
```php
private function owned(User $user, ?object $entity, string $message): object
{
[$entityType, $entityId] = $this->branches->pair($user);
if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404);
}
return $entity;
}
```
پس دادهٔ هیچ محیطی به محیط دیگر نشت نمی‌کند. مسئله **بالا رفتن سطح دسترسی داخل همان
محیط** است: منشی‌ای که مالک صریحاً `services` را برایش خاموش کرده، همچنان کاتالوگ سرویس
همان کلینیک را می‌نویسد. شدت: **متوسط**، نه بحرانی.
### ⚠ دلیلی که قبلاً برای دست‌نزدن گفته شد، غلط بود
در گزارش قبلی نوشته شد «endpointهایش در جریانِ ثبت نوبت مصرف می‌شوند و بستنشان
نوبت‌دهی منشی را می‌شکند». این ادعا **بررسی نشده بود و رد شد**:
۱. `service-item/{uuid}/groups`، `item-group/*`، `service-item/{uuid}/relations` و
`service-selection/validate` در **هیچ‌کدام** از سه کلاینت مصرف‌کننده ندارند. تنها
ارجاعشان hookهای بدون مصرف‌کننده در `assets/admin/hooks/useServiceCatalog.ts` است
(`useServiceGroups`، `useServiceRelations`، `useSelectionPreview` — هیچ کامپوننتی
صداشان نمی‌زند). در `nobat724_front` و `clinic-pro-tauri` هم صفر ارجاع.
۲. مهم‌تر: کنترلرِ خواهرِ همین دامنه، `ClinicServiceController`، **همین حالا** هر
خواندنِ سرویس را پشت `services.view` بسته است — از جمله
`GET /api/v1/service-items/{sectionUuid}` که مودالِ ثبت نوبت از آن سرویس می‌خواند.
یعنی هر جریانی که به سرویس نیاز دارد **از قبل** به `services.view` نیاز دارد.
بستنِ کاتالوگ چیز تازه‌ای نمی‌شکند.
پس مسیر درست همان گِیتِ ساده است، نه استثنا و نه fallback.
### تنها مصرف‌کنندگانِ واقعی
| hook | صفحه/کامپوننت | route |
|---|---|---|
| `useCatalogCategories` | `CatalogCategoriesPage` (`/admin/service-categories`) | tree، create/update/delete دسته |
| `useCategoryIncludes` | `CatalogCategoriesPage` + `ServiceCategoryTab` | includes: list/add/remove |
| `useCatalogCategories` | `ServiceCategoryTab` داخل `ServiceDetailPage` | tree |
هر دو صفحه از قبل پشت `permission={['services','view']}` در `App.tsx` هستند. یعنی
گِیتِ فرانت هست و فقط گِیتِ سمت API غایب است.
---
## معیار پذیرش
- ✅ موفق: منشی با `services.view = true``GET /api/v1/service-categories/tree` جواب
`200` و همان درخت قبلی. صفحهٔ `/admin/service-categories` مثل امروز کار می‌کند.
- ✅ موفق: منشی با `services.update = true``PATCH /api/v1/service-category/{uuid}`
جواب `200`.
- ❌ خطا: منشی با `services` کاملاً خاموش → **هر ۱۵ route** جواب `403` با
`ERR_FORBIDDEN_001`. امروز `tree` جواب `200` می‌دهد؛ همین تفاوتْ تستِ اصلی است.
- ❌ خطا: منشی با `services.view = true` ولی `services.create = false`
`POST /api/v1/service-category` جواب `403`، در حالی که `tree` همچنان `200`.
- ⚠️ مرزی: پزشکِ عضو کلینیک با `services.update = false` → نوشتن‌ها `403`، خواندن‌ها
`200` (پیش‌فرضِ نقشش `view: true` است).
- ⚠️ مرزی: مالکِ کلینیک، پزشکِ مطبِ شخصی و ادمین بدون تغییر عبور می‌کنند —
`denyUnlessGranted` فقط نقشِ خودش را محدود می‌کند.
- ⚠️ مرزی: مودالِ ثبت نوبت برای منشیِ دارای `services.view` نباید عوض شود. این را
واقعاً باز کن و ببین، نه از روی کد حدس بزن.
---
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/ClinicService/Controller/ServiceCatalogController.php` | ۱۵ routeِ بدون گِیت |
| `src/ClinicService/Controller/ClinicServiceController.php` | **الگوی مرجع**`denyServices()` |
| `assets/admin/pages/CatalogCategoriesPage.tsx` | `canUpdate` واحد برای هر سه دکمه |
| `assets/admin/components/ServiceCategoryTab.tsx` | prop به نام `canEdit` |
| `assets/admin/pages/ServiceDetailPage.tsx` | `canEdit={canUpdate}` را پاس می‌دهد |
| `docs/api/clinic-services.md` | سند اندپوینت‌ها |
| `docs/api/secretary.md` | جدول enforcement — سطر `services` |
---
## وضعیت فعلی
### کلاس بدون هیچ گِیتی جز احراز هویت
```php
#[OA\Tag(name: 'Treatment')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ServiceCatalogController extends BaseController
```
### نمونهٔ نوشتنِ باز — ساخت دسته
```php
#[Route('/api/v1/service-category', name: 'service_category_create', methods: ['POST'])]
public function createCategory(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : '';
// ← هیچ denyUnlessGranted‌ای اینجا نیست
```
### الگوی مرجع در کنترلرِ خواهر — همین دامنه
```php
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود می‌کند). */
private function denyServices(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}
```
و در عمل per-action صدا زده می‌شود: ۵ بار `view`، ۲ بار `create`، ۲ بار `update`.
### فرانت — یک توگل برای هر سه عمل
```tsx
// CatalogCategoriesPage.tsx:33
const canUpdate = can('services', 'update');
// همین یکی هم دکمهٔ «افزودن» را کنترل می‌کند، هم ویرایش، هم حذف
```
---
## وظایف
### ۱. گِیتِ per-action روی هر ۱۵ route
`denyServices()` را عیناً مثل `ClinicServiceController` به `ServiceCatalogController`
اضافه کن (همان دو checker از constructor تزریق می‌شوند) و اولین خط هر action صدایش بزن.
نگاشت — هر ۱۵ تا، بدون استثنا:
| Action | Route | مجوز |
|---|---|---|
| `tree` | `GET /service-categories/tree` | `view` |
| `listIncludes` | `GET /service-category/{uuid}/includes` | `view` |
| `listGroups` | `GET /service-item/{uuid}/groups` | `view` |
| `validateSelection` | `POST /service-selection/validate` | `view` |
| `createCategory` | `POST /service-category` | `create` |
| `addInclude` | `POST /service-category/{uuid}/includes` | `create` |
| `createGroup` | `POST /service-item/{uuid}/groups` | `create` |
| `updateCategory` | `PATCH /service-category/{uuid}` | `update` |
| `updateGroup` | `PATCH /item-group/{uuid}` | `update` |
| `replaceGroupItems` | `PUT /item-group/{uuid}/items` | `update` |
| `replaceRelations` | `PUT /service-item/{uuid}/relations` | `update` |
| `replaceBranchOverrides` | `PUT /service-item/{uuid}/branch-overrides` | `update` |
| `deleteCategory` | `DELETE /service-category/{uuid}` | `delete` |
| `removeInclude` | `DELETE /service-category/{uuid}/includes/{childUuid}` | `delete` |
| `deleteGroup` | `DELETE /item-group/{uuid}` | `delete` |
`validateSelection` عمداً `view` است نه `create`: چیزی نمی‌سازد و فقط یک انتخاب را
اعتبارسنجی می‌کند؛ POST بودنش به‌خاطر حجمِ بدنه است، نه اثرِ جانبی.
**گِیت قبل از هر کار دیگری بیاید** — قبل از `json_decode`، قبل از `requireCategory`.
وگرنه ترتیبِ خطاها ۴۰۴/۴۲۲ را جای ۴۰۳ برمی‌گرداند و همان oracle‌ای می‌شود که گِیت
قرار بود ببندد.
**نحوه تست:** منشیِ تست `0912000209` / `QaTest@1234`، محیط کلینیک
`c3f1feac-4f56-45da-a3c4-b2182f4d6902`.
```bash
TOK=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"0912000209","password":"QaTest@1234"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
curl -sk -X POST https://clinic-pro.ddev.site/api/v1/auth/switch-context \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"db_uuid":"c3f1feac-4f56-45da-a3c4-b2182f4d6902","db_type":"clinic"}'
curl -sk -o /dev/null -w "%{http_code}\n" \
https://clinic-pro.ddev.site/api/v1/service-categories/tree -H "Authorization: Bearer $TOK"
```
> ⚠ **قبل از دست‌زدن به مجوزها backup بگیر و در پایان بایت‌به‌بایت برگردان:**
> ```bash
> ddev mysql -N -e "SELECT id, permission FROM doctor_secretaries WHERE secretary_id=15;" > /tmp/bk.tsv
> ```
> و **توجه:** `JSON_SET` روی مسیرِ تودرتویی که والدش وجود ندارد بی‌صدا کاری نمی‌کند.
> برای خاموش‌کردن حتماً کلِ آبجکت را بنویس:
> ```sql
> JSON_SET(permission,'$.resources.services',
> JSON_OBJECT('view',false,'create',false,'update',false,'delete',false))
> ```
> و یادت باشد **حذفِ کلید یعنی «پیش‌فرضِ نقش را بگیر»، نه «ممنوع»** — چون
> `getPermissions()` با رجیستری merge می‌کند.
### ۲. هم‌ترازیِ فرانت با گِیتِ per-action
الان یک `canUpdate` هر سه دکمه را کنترل می‌کند. با گِیتِ per-action، منشیِ دارای
`update` ولی بدون `create` دکمهٔ «افزودن» را می‌بیند و ۴۰۳ می‌گیرد.
در `CatalogCategoriesPage.tsx` و `ServiceCategoryTab.tsx`:
```tsx
const canCreate = can('services', 'create');
const canUpdate = can('services', 'update');
const canDelete = can('services', 'delete');
```
و هر دکمه به مجوزِ خودش وصل شود. `ServiceCategoryTab` به‌جای `canEdit: boolean` باید
هر سه را بگیرد؛ `ServiceDetailPage` هم مقادیر درست را پاس بدهد.
**نحوه تست:** vitest برای `CatalogCategoriesPage` — با `can` که فقط `update` را `true`
برمی‌گرداند، دکمهٔ افزودن نباید رندر شود ولی دکمهٔ ویرایش باید.
### ۳. تست backend
فایل تازه `tests/ClinicService/ServiceCatalogPermissionTest.php`، هم‌سبک با
`tests/Secretary/SecretaryResourceEnforcementTest.php`:
- منشی با `services` خاموش → هر ۱۵ route جواب `403`. **حلقه روی فهرست routeها بزن**،
نه سه‌تا نمونه؛ همین تست است که جلوی routeِ تازهٔ بی‌گِیت را در آینده می‌گیرد.
- `view` روشن → خواندن‌ها `200`، نوشتن‌ها همچنان `403`.
- مالکِ کلینیک با همان مجوزهای خاموش → همه‌چیز `200`.
### ۴. مستندات
- `docs/api/clinic-services.md`: برای هر ۱۵ route ستون Permission اضافه شود.
- `docs/api/secretary.md`: در جدول enforcement، سطر `services` باید
`ServiceCatalogController` را هم نام ببرد و هشدارِ «گِیت ندارد» حذف شود.
---
## نکات مهم
- **الگو تازه نساز.** `denyServices()` عیناً از `ClinicServiceController` کپی شود؛ همان
دامنه، همان دو checker، همان معنا. trait مشترک هم لازم نیست — دو متدِ چهارخطی در یک
دامنه، abstraction نمی‌خواهد.
- **مالکیتِ محیط از قبل درست است؛ دست نزن.** `owned()` و `requireItem()` کارشان را
می‌کنند. این تسک فقط لایهٔ مجوز را اضافه می‌کند، نه لایهٔ tenant.
- `denyUnlessGranted` برای نقش‌های غیرمنشی/غیرعضو `true` است، پس مالک و ادمین خودبه‌خود
عبور می‌کنند. «مالک هرگز نباید بتواند خودش را قفل کند» باید برقرار بماند.
- **hookهای بی‌مصرف را در همین تسک حذف نکن.** `useServiceGroups`، `useServiceRelations`
و `useSelectionPreview` مصرف‌کننده ندارند، ولی حذفشان کارِ این پرامپت نیست و ریسکِ
بی‌دلیل اضافه می‌کند. فقط در گزارش ذکرشان کن.
- بعد از تغییر، هر دو سوییت کامل سبز بمانند: `ddev exec php bin/phpunit` و
`npx vitest run`. خط پایه: ۱۵۲۸ تست backend و ۷۹۵ تست frontend.
- `AppointmentEditPage.serviceMode` زیر بار موازی flaky است؛ اگر افتاد تنها اجرایش کن
و اگر سبز شد به این تغییر ربطی ندارد.