feat: Implement permission gate for appointment and billing controllers

- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
This commit is contained in:
hamed
2026-08-08 10:27:13 +03:30
parent c452150a83
commit 934405c42d
14 changed files with 830 additions and 67 deletions
+154 -6
View File
@@ -25,12 +25,17 @@ authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ⚠️ باز — با تست baseline مهار شد |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
حین همین کار کشف شد.
یافته‌های ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
```bash
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
@@ -539,13 +544,156 @@ docs/api/treatment.md مجوز هر سه ا
## پیشنهادها
1. **بستنِ ده گَپِ یافتهٔ ۸** — با دیدنِ مصرفِ واقعیِ پنل تصمیم بگیر هر کدام کدام مجوز را بخواهد،
بعد ردیفش را از `KNOWN_GAPS` بردار. تست دوم خودش یادآوری می‌کند.
1. ~~**بستنِ ده گَپِ یافتهٔ ۸**~~ — انجام شد ۲۰۲۶-۰۸-۰۸. جزئیات پایین.
2. **مهاجرت CKEditor** به پکیج umbrella `ckeditor5` v45+ — تنها راهِ بستنِ یافتهٔ ۴.
3. **یک `ClinicStaff` در tenant دوم** — تا آدیت بعدی بتواند IDOR بین‌نقشیِ پرسنل را واقعاً بزند.
4. **گسترشِ `ApiLeastPrivilegeTest` به روت‌های نوشتنی** — الان فقط `GET` بدون path parameter را
پوشش می‌دهد. `POST`/`PATCH`/`DELETE` بدنهٔ معتبر می‌خواهند، ولی همان‌ها خطرناک‌ترند.
3. ~~**یک `ClinicStaff` در tenant دوم**~~ — انجام شد، ولی در fixture نه در DB زنده.
4. ~~**گسترشِ `ApiLeastPrivilegeTest` به روت‌های نوشتنی**~~ — انجام شد ۲۰۲۶-۰۸-۰۸.
5. **پاک‌سازیِ ۱۷ خطای `phpstan`** — بدهیِ قدیمی، بی‌ربط به امنیت، ولی مانعِ «سبز یعنی سبز» است.
6. **بستنِ یافتهٔ ۱۰** — گِیت را در ۳۴ روت به بالای واکشی ببر. ~۱۵ تای‌شان object-scoped
هستند و پیش‌چکِ منبع‌سطح می‌خواهند، پس تسک جدا لازم است.
---
# پیگیری ۲۰۲۶-۰۸-۰۸
جلسهٔ بعدی، با هدفِ بستنِ یافتهٔ ۸ و پیشنهادهای ۳ و ۴. دو یافتهٔ تازه حین کار پیدا شد.
## یافتهٔ ۸ — بسته شد، ولی سه ردیفش مثبت کاذب بود
فهرست ده‌تایی از نو با کد سنجیده شد. سه ردیف اصلاً گَپ نبودند:
| روت | چرا مثبت کاذب بود |
|---|---|
| `/api/v1/appointments/user` | `findByUser($user)` یعنی `a.user = خودِ کاربر`. نوبت‌های خودِ فرد به‌عنوان بیمار است، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است؛ گِیت‌زدنش رگرسیون بود. |
| `/api/v1/doctor-services` | `findActive()` بدون هیچ فیلترِ محیط — کاتالوگ سراسری، هم‌ردهٔ `specialties` و `tags`. |
| `/api/v1/insurances` | همان. گِیت‌زدنش یک مجوز را با نبودِ مجوزِ دیگری می‌شکست: منشیِ دارای `patients.create` فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گرفت. |
درسِ روشی: خروجیِ پویشِ رفتاری lead است نه یافته — حتی وقتی پویش را خودِ آدیت نوشته باشد. سه
ردیف بالا با «۲۰۰ داد پس نشت است» ثبت شده بودند، بی‌آنکه کوئریِ پشتشان خوانده شود.
هفت ردیف باقی‌مانده گَپ واقعی بودند و گِیت گرفتند. ولی چون هر سه کنترلر بی‌گِیت بودند و پویشِ
قبلی فقط `GET` بدون path parameter را می‌دید، **کلِ روت‌های نوشتنی‌شان هم باز بود**:
```
sec (بدون هیچ مجوزی) | POST /api/v1/billing/invoices | صورتحساب می‌ساخت
sec (بدون هیچ مجوزی) | POST /api/v1/billing/claims/{uuid}/pay | مطالبه را «پرداخت‌شده» می‌کرد
sec (بدون هیچ مجوزی) | POST /api/v1/my/appointment | نوبت ثبت می‌کرد
sec (بدون هیچ مجوزی) | GET /api/v1/my/appointment/patient-lookup | نام و کد ملی هر بیمار را می‌گرفت
```
پس محدوده به همهٔ روت‌های آن سه کنترلر باز شد: ۱۲ روت `BillingController` و ۵ روت
`MyAppointmentsController`.
**نگاشت مجوزها:**
- Billing خواندنی → `payments.view` · ساخت → `payments.create` · `finalize` و `transitionClaim``payments.update`
- `my/appointments`, `today-stats`, `my/clinic-doctors``appointments.view`
- `POST /my/appointment` و `patient-lookup``appointments.create`
`patient-lookup` عمداً `appointments.create` گرفت نه `patients.view`: کامنت خودِ متد می‌گوید
عمداً از گیتِ پروندهٔ بیمار جدا شده تا ثبت نوبت مستقل از اشتراک کار کند. با `patients.view`
همان قابلیت می‌شکست.
**مکانیزم:** `App\Shared\Controller\PermissionGateTrait` از دلِ `ResourcePermissionTrait` بیرون
کشیده شد؛ trait قبلی حالا رویش سوار است و فقط منبعِ پیش‌فرض و استثنای نوبت‌دهی‌اش را نگه داشته.
هیچ voter یا checker تازه‌ای ساخته نشد.
**یک تغییر رفتار که باید بدانی:** منشیِ **بدون رابطهٔ فعال** روی `/my/appointments` دیگر لیست
خالی نمی‌گیرد، `403` می‌گیرد. `SecretaryAccessChecker::can()` برای او `false` می‌دهد — همان
fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش» با «اجازه نداری» یکی نیست.
`SecretaryAppointmentScopeTest::testSecretaryWithNoAssignmentIsDenied` همین را تثبیت می‌کند.
## یافتهٔ ۹ 🟧 HIGH — `AppointmentPlanController` بدون گِیت
**۱. فایل:** `src/Appointment/Plan/Controller/AppointmentPlanController.php`
**۲. شرح:** دوقلوی یافتهٔ ۱. همان `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح‌کلاس، و همان
`requireItem()` — کپیِ کلمه‌به‌کلمهٔ تابعی که آدیت دیروز در `TreatmentProtocolController`
ناکافی خواند. آدیت قلِ اول را بست و دوقلویش را ندید، چون `GET .../segments` پارامتر دارد و
پویشِ `GET` روت‌های پارامتردار را رد می‌کرد.
**۳. ریسک:** منشیِ `services:false` بخش‌بندیِ هر سرویسِ محیط را می‌خواند و با `PUT` کاملاً
بازنویسی می‌کرد. بخش‌بندی طولِ نوبت و منابعِ لازم را تعیین می‌کند، پس بازنویسی‌اش یعنی
به‌هم‌ریختنِ برنامهٔ همهٔ نوبت‌های آن سرویس.
**۴. رفع:** `show``services.view`، `replace``services.update`، `preview`
`services.view` **یا** `appointments.view`. «یا» عمدی است: `preview` ورودیِ فرمِ ثبت نوبت است،
پس قرینهٔ `denyUnlessGrantedForBooking`.
**۵. مستندات:** `docs/api/appointment-plan.md` بخش «مجوزها».
## یافتهٔ ۱۰ 🟦 LOW — گِیت بعد از واکشی در ۳۴ روت نوشتنی
**۱. کشف چطور شد:** حین نوشتنِ پویشِ روت‌های نوشتنی. قاعدهٔ اولیه «منشیِ بی‌مجوز فقط `403`»
بود؛ ۷۵ روت قرمز شد. پس از خواندنِ کد معلوم شد بیشترشان گِیت **دارند**، ولی بعد از
`findByUuid()` می‌نشیند، پس uuidِ ناموجود اول `404` می‌گیرد.
**۲. ریسک:** enumeration oracle. کاربرِ بی‌مجوز از تفاوت `403`/`404` می‌فهمد کدام uuid در این
محیط وجود دارد. همان چیزی که یافتهٔ ۱ عمداً با «گیت پیش از `requireItem`» بست، ولی هیچ چیزی
در بقیهٔ کد اجبارش نمی‌کرد.
**۳. چرا رفع نشد:** حدود ۱۵ تای این چک‌ها **object-scoped** هستند، نه resource-scoped —
مثلاً `ClinicController::update` چکش `permChecker->can($user, $clinic, …)` است و بدون `$clinic`
اصلاً قابل صدا زدن نیست. بالا بردنشان یعنی افزودنِ یک پیش‌چکِ منبع‌سطحِ تازه به هر کدام، نه
جابه‌جا کردن دو خط. این طراحی است، نه reorder، و در محدودهٔ این جلسه نبود.
**۴. فهرست کامل** — گروه‌بندی بر اساس کنترلر:
- `AppointmentController``updateStatus`, `confirm`, `update`, `serviceReschedule`
- `AppointmentSettingsController``createSchedule`, `updateSchedule`, `deleteSchedule`,
`createOverride`, `updateOverride`, `deleteOverride`, `createHoliday`, `updateHoliday`,
`deleteHoliday`
- `ClinicController``update`, `detachDoctor`, `createAddress`, `updateAddress`, `deleteAddress`
- `ClinicDoctorPermissionController``updatePermissions`
- `ClinicInvitationController``inviteDoctor`, `resendInvitation`, `changeInvitationStatus`,
`deleteInvitation`
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`
- `ResourceBlockController``create`, `delete`
- `RepresentationController``update`
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`
سه روتِ `AppointmentSettingsController` و `SecretaryController::create` پارامتر مسیری ندارند و
هدفشان از بدنه می‌آید؛ رفتارشان همان است.
**۵. مهار:** `ApiLeastPrivilegeTest::testNoApiWriteRouteSkipsItsPermissionGate` قاعده‌اش دو
سطحی است — روتِ **بدون** path parameter باید `403` بدهد، روتِ پارامتردار `403` یا `404`. پس
روت‌های این فهرست عبور می‌کنند ولی هر روتِ نوشتنیِ تازه‌ای که `2xx` یا `422` بدهد قرمز می‌شود.
## پویش روت‌های نوشتنی — نتیجه
۷۵ روتِ `POST`/`PUT`/`PATCH`/`DELETE` با منشیِ بدونِ هیچ مجوزی زده شد. پس از triage:
- **۳۴** عمداً باز — لاگین و ثبت‌نام و OTP، کال‌بک درگاه، امتیاز و کامنت بیمار، پروفایل و
کیف‌پول خودِ کاربر، و کلِ جریان رزرو عمومی. همه با دلیل در `ALLOWED_WRITE`.
- **۲** عمداً `409` — حذف سرویس و بخش سرویس اصلاً ممکن نیست، چون نوبت و فاکتور به سرویس ارجاع
دارند.
- **۳۴** یافتهٔ ۱۰ بالا.
- **۵** گَپِ واقعی که همین جلسه بسته شد — روت‌های نوشتنیِ Billing و MyAppointments و
`service_segments_replace`.
## پرسنل tenant دوم — پوشش بسته شد
`tests/Staff/StaffCrossTenantTest.php` هر دو محیط را در fixture می‌سازد. چهار تست: شاهد مثبت
(پرسنل روی جلسهٔ محیط خودش `200`)، جلسهٔ محیط دیگر روی `GET`/`start`/`finish`، ناحیهٔ جلسهٔ
محیط دیگر روی `start`/`complete`/`skip`/`reopen`، و تستِ مرزی که uuidِ ناموجود و uuidِ محیطِ
دیگر باید **دقیقاً یک پاسخ** بدهند. همه `404 / ERR_NOT_FOUND_001` — بدون نشت و بدون oracle.
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed می‌رود و
هیچ‌وقت خودکار اجرا نمی‌شود.
## تغییرات این جلسه
```
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک (جدید)
src/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php ۱۲ روت → payments.view/create/update
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.view/create
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE؛ KNOWN_GAPS خالی شد
tests/Staff/StaffCrossTenantTest.php IDOR بین‌محیطیِ پرسنل (جدید)
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بی‌رابطه: خالی → ۴۰۳
docs/api/billing.md · appointment.md · appointment-plan.md مجوز هر روت
```
---