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>
This commit is contained in:
hamed
2026-08-07 18:38:36 +03:30
co-authored by Claude Opus 5
parent ccd869e442
commit 1d10f8c907
2 changed files with 273 additions and 1 deletions
@@ -0,0 +1,270 @@
# گِیتِ مجوز برای 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 است؛ اگر افتاد تنها اجرایش کن
و اگر سبز شد به این تغییر ربطی ندارد.