diff --git a/.claude/prompt/service-catalog-permission-gate.md b/.claude/prompt/service-catalog-permission-gate.md new file mode 100644 index 00000000..d9f1baba --- /dev/null +++ b/.claude/prompt/service-catalog-permission-gate.md @@ -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 است؛ اگر افتاد تنها اجرایش کن + و اگر سبز شد به این تغییر ربطی ندارد. diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 529dd937..7f13371c 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -156,7 +156,9 @@ Create a secretary for a doctor. | `sms` | `SmsWalletController` (balance/charge/logs/settings). endpointهای admin (قالب/ارسال) همچنان `ROLE_ADMIN` | view/create/update | | `appointment_settings` | `AppointmentSettingsController::denyDoctorAccess` → `SecretaryAccessChecker::canForDoctor` (اسکوپِ پزشکِ تخصیص‌یافته + توگل). `clinic_uuid` برای محیطِ کلینیک لازم است | view/update | | `clinic_doctors` (فقط کلینیک) | `ClinicController::detachDoctor` (delete)، `ClinicDoctorPermissionController` (view/update)، `ClinicInvitationController` (create/view/update/delete) via `SecretaryAccessChecker::canForClinic` | view/create/update/delete | -| `subscription` | `SubscriptionController::trial` (create)، `PaymentController::initiateSubscription` (create). ⚠ خواندنِ `GET /api/v1/subscription/my` گِیت **ندارد** — با همهٔ مجوزها خاموش هم ۲۰۰ می‌دهد؛ چون FeatureGate و useSubscription همه‌جا صداش می‌زنند، بستنش نیاز به بررسی جداگانه دارد | create | +| `subscription` | `SubscriptionController::my` (view — **پاسخِ کاهش‌یافته**، نه ۴۰۳)، `trial` (create)، `PaymentController::initiateSubscription` (create) | view/create | + +**استثنای `subscription.view` — پاسخِ کاهش‌یافته به‌جای ۴۰۳:** `GET /api/v1/subscription/my` عمداً ۴۰۳ نمی‌دهد. بدون این مجوز `subscription` و `used_trial` تهی برمی‌گردند و از `effective_plan` فقط `features` و `max_secretaries` و `max_resources` می‌ماند؛ با مجوز، پلنِ کامل (`uuid`, `name`, `level`, `active`) و اشتراکِ فعال هم می‌آید. دلیلش این است که `FeatureGate` و `useSubscription` در همهٔ صفحات به سقف‌ها و فلگ‌های قابلیت نیاز دارند؛ ۴۰۳ کل پنل را می‌شکست. اطلاعاتِ هویتی و مالیِ اشتراک پشت مجوز می‌ماند. نقش‌های غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور می‌کنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همه‌چیز `403`.