Files
clinicpro/.claude/prompt/service-catalog-permission-gate.md
T
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

15 KiB
Raw Blame History

گِیتِ مجوز برای 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() می‌رسند:

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 = trueGET /api/v1/service-categories/tree جواب 200 و همان درخت قبلی. صفحهٔ /admin/service-categories مثل امروز کار می‌کند.
  • موفق: منشی با services.update = truePATCH /api/v1/service-category/{uuid} جواب 200.
  • خطا: منشی با services کاملاً خاموش → هر ۱۵ route جواب 403 با ERR_FORBIDDEN_001. امروز tree جواب 200 می‌دهد؛ همین تفاوتْ تستِ اصلی است.
  • خطا: منشی با services.view = true ولی services.create = falsePOST /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

وضعیت فعلی

کلاس بدون هیچ گِیتی جز احراز هویت

#[OA\Tag(name: 'Treatment')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ServiceCatalogController extends BaseController

نمونهٔ نوشتنِ باز — ساخت دسته

#[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‌ای اینجا نیست

الگوی مرجع در کنترلرِ خواهر — همین دامنه

/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود می‌کند). */
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.

فرانت — یک توگل برای هر سه عمل

// 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.

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 بگیر و در پایان بایت‌به‌بایت برگردان:

ddev mysql -N -e "SELECT id, permission FROM doctor_secretaries WHERE secretary_id=15;" > /tmp/bk.tsv

و توجه: JSON_SET روی مسیرِ تودرتویی که والدش وجود ندارد بی‌صدا کاری نمی‌کند. برای خاموش‌کردن حتماً کلِ آبجکت را بنویس:

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:

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