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:
@@ -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 است؛ اگر افتاد تنها اجرایش کن
|
||||
و اگر سبز شد به این تغییر ربطی ندارد.
|
||||
Reference in New Issue
Block a user