# گِیتِ مجوز برای 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 است؛ اگر افتاد تنها اجرایش کن و اگر سبز شد به این تغییر ربطی ندارد.