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>
15 KiB
گِیتِ مجوز برای 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 = 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 |
وضعیت فعلی
کلاس بدون هیچ گِیتی جز احراز هویت
#[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 است؛ اگر افتاد تنها اجرایش کن و اگر سبز شد به این تغییر ربطی ندارد.