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:
@@ -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 مجوز هر روت
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user