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
+22
View File
@@ -24,6 +24,28 @@
---
## مجوزها
از ۲۰۲۶-۰۸-۰۸:
- `GET /service-item/{uuid}/segments``services.view`
- `PUT /service-item/{uuid}/segments``services.update`
- `POST /appointment-plan/preview``services.view` **یا** `appointments.view`
منبعش `services` است نه `appointments`: بخش‌بندی یک خاصیتِ `ServiceItem` است و صفحه‌اش
داخل کاتالوگ خدمات می‌نشیند. همان استدلالِ پروتکل درمان در آدیت ۲۰۲۶-۰۸-۰۷.
`preview` استثناست و «یا» می‌گیرد، چون ورودیِ فرمِ ثبت نوبت است نه پیکربندیِ سرویس:
منشی‌ای که اجازهٔ ثبت نوبت دارد ولی کاتالوگ خدمات برایش بسته است، وگرنه نمی‌توانست
همان نوبتی را که مجاز است ثبت کند. قرینهٔ `ResourcePermissionTrait::denyUnlessGrantedForBooking`.
> **چرا اضافه شد:** این کنترلر دقیقاً همان شکلِ `TreatmentProtocolController` پیش از
> رفعِ یافتهٔ ۱ آدیت را داشت — `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح‌کلاس و یک
> `requireItem()` که فقط مالکیتِ محیط را می‌سنجد. مالکیت مجوز نیست: عبور از آن فقط
> ثابت می‌کند سرویس مالِ همین محیط است، نه اینکه این کاربر حق دست‌زدن به آن را دارد.
---
## `GET/PUT /api/v1/service-item/{uuid}/segments`
`PUT` جایگزینی کامل است. هر بخش:
+26 -8
View File
@@ -524,7 +524,13 @@ The doctor dashboard filter bar uses the paginated form, defaulting `statuses` t
Get all appointments for the authenticated user.
**Permission:** `AUTH`
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
> این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان
> بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
> داده شد: گِیتِ `appointments.view` یعنی منشی‌ای که جایی بیمار است نوبت‌های شخصی‌اش
> را نبیند. در `ApiLeastPrivilegeTest::ALLOWED_200` با همین دلیل ثبت است.
### Query Parameters
| Param | Type | Required | Description |
@@ -780,6 +786,9 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
## POST `/api/v1/my/appointment`
**Permission:** `appointments.create` — علاوه بر بررسی نقش (`ROLE_DOCTOR`/`ROLE_CLINIC`/`ROLE_SECRETARY`/`ROLE_ADMIN`).
تا پیش از ۲۰۲۶-۰۸-۰۸ فقط نقش بررسی می‌شد، پس منشیِ `appointments.create:false` هم نوبت ثبت می‌کرد.
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
@@ -854,9 +863,12 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
جستجوی بیمار با شماره موبایل **یا** کد ملی، پیش از ثبت نوبت. فرم ثبت نوبت با یکی از این دو معیار جستجو می‌کند؛ اگر بیمار یافت شد و کد ملی دارد، مستقیم استفاده می‌شود، وگرنه بقیهٔ مشخصات (نام و موبایل یا کد ملی) از کاربر گرفته می‌شود.
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`**Permission:** `appointments.create`
> برخلاف `GET /api/v1/patient/search-user`، این endpoint به فیچر `patient_records` اشتراک وابسته نیست و `ROLE_ADMIN` را هم می‌پذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.
>
> مجوزش عمداً `appointments.create` است نه `patients.view`: بخشی از فرمِ ثبت نوبت است،
> و با گیتِ پروندهٔ بیمار، منشی‌ای که فقط اجازهٔ نوبت‌دهی دارد فرمش را از دست می‌داد.
### Query Parameters
یکی از `mobile` یا `national_code` الزامی است. اگر هر دو ارسال شوند، `national_code` اولویت دارد.
@@ -897,7 +909,10 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
**Auth:** `IS_AUTHENTICATED_FULLY` (any role)
**Auth:** `IS_AUTHENTICATED_FULLY` **Permission:** `appointments.view`
> از ۲۰۲۶-۰۸-۰۸ گِیت دارد. پیش از آن منشیِ `appointments:false` با درخواست مستقیم به
> API همان فهرستی را می‌گرفت که توگل، دکمه‌اش را در پنل پنهان کرده بود.
**Role behavior:**
| Role | Scope |
@@ -913,10 +928,13 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).
**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
**Auth:** `IS_AUTHENTICATED_FULLY`**Permission:** `appointments.view`. A caller with no
resolvable scope (clinic/doctor row missing, or no assigned doctors) gets all-zero counts
rather than an unscoped, system-wide count. A plain patient gets counts over their own
appointments only.
> پیش از ۲۰۲۶-۰۸-۰۸ منشیِ بدون `appointments.view` به‌جای ۴۰۳ صفر می‌گرفت. صفرِ خاموش
> با «اجازه نداری» یکی نیست؛ حالا ۴۰۳ می‌گیرد.
### Query Parameters
| Param | Type | Default | Description |
@@ -1399,7 +1417,7 @@ active — the request simply carried no `clinic_uuid`.
## GET /api/v1/my/clinic-doctors
**Permission:** `IS_AUTHENTICATED_FULLY`
**Permission:** `IS_AUTHENTICATED_FULLY` + `appointments.view`
پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تب‌ها/تایم‌لاینِ صفحهٔ نوبت‌ها. برخلاف
`GET /api/v1/clinic/doctor-list/{clinicUuid}` که روی firewallِ عمومی است و **همهٔ** پزشکانِ
+27 -1
View File
@@ -52,11 +52,37 @@ ddev exec php bin/console app:billing:backfill-claims # ساخت م
---
## مجوزها
از ۲۰۲۶-۰۸-۰۸ هر روتِ این کنترلر پیش از هر واکشی، مجوزِ `payments` را با
`App\Shared\Controller\PermissionGateTrait` می‌سنجد. پیش از آن هیچ روتی گِیت مجوزی
نداشت: منشیِ `payments:false` هم پرداخت‌ها را می‌دید، هم صورتحساب می‌ساخت، هم وضعیت
مطالبه را عوض می‌کرد. جزئیات در `docs/security/AUDIT-2026-08-07.md` یافتهٔ ۸.
منبعِ مجوز برای صورتحساب و مطالبه یکی است — `payments` — چون هر دو زیر همان توگلِ
«مدیریت پرداخت‌ها»ی پنل نشسته‌اند و توگل جداگانه‌ای ندارند.
- `payments.view` — خواندن: صورتحساب، فهرست پرداخت‌ها و خلاصه‌شان، فهرست مطالبات،
مطالبات به تفکیک بیمار، گزارش بدهی بیمه، صورتحساب‌های یک بیمار.
- `payments.create` — ساخت: صورتحساب تازه، مطالبهٔ تازه.
- `payments.update` — تغییر وضعیت: نهایی‌کردن صورتحساب، و `submit`/`approve`/`reject`/`pay`
روی مطالبه.
گِیت پیش از `findByUuid()` می‌نشیند. ترتیب عمدی است: اگر بعدش بود، uuidِ ناموجود ۴۰۴
می‌داد و همان تفاوت ۴۰۳/۴۰۴ به کاربرِ بی‌مجوز می‌گفت کدام uuid در این محیط وجود دارد.
نقش‌های دیگر اثری نمی‌گیرند: هر دو checker برای ادمین، مالک کلینیک و پزشک مطب شخصی
pass-through هستند و فقط منشی و پزشکِ عضوِ کلینیک را محدود می‌کنند.
خطای رد: `403` با `ERR_FORBIDDEN_001`.
---
## POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده می‌شود (idempotent).
**Permission:** `AUTH` (مالک مراجعه)
**Permission:** `payments.create` + مالکیت محیطِ مراجعه
**Body:**
```json
+9 -1
View File
@@ -8,7 +8,15 @@
List all active doctor services.
**Permission:** `PUBLIC`
**Permission:** `AUTH` — بدون مجوزِ رجیستری، و این عمدی است.
> سند تا ۲۰۲۶-۰۸-۰۸ اینجا `PUBLIC` نوشته بود که با رفتار نمی‌خواند: مسیر پشت firewall
> است و درخواستِ بدون توکن `401` می‌گیرد.
>
> کاتالوگ سراسری است — `findActive()` بدون هیچ فیلترِ محیط. هم‌ردهٔ `specialties` و
> `tags`. آدیت ۲۰۲۶-۰۸-۰۷ آن را گَپِ `services.view` دانسته بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت
> کاذب تشخیص داده شد: این فهرست dropdown فرم‌ها را پر می‌کند، پس گِیت‌زدنش یک مجوز را
> با نبودِ مجوزِ دیگری می‌شکند. در `ApiLeastPrivilegeTest::ALLOWED_200` ثبت است.
### Query Parameters
| Param | Type | Required | Description |
+9 -1
View File
@@ -41,7 +41,15 @@ hardcode نمی‌شود. ویزیت همیشه `outpatient` است.
List all active insurances.
**Permission:** `PUBLIC`
**Permission:** `AUTH` — بدون مجوزِ رجیستری، و این عمدی است.
> سند تا ۲۰۲۶-۰۸-۰۸ اینجا `PUBLIC` نوشته بود که با رفتار نمی‌خواند: مسیر پشت firewall
> است و درخواستِ بدون توکن `401` می‌گیرد.
>
> کاتالوگ سراسری بیمه‌هاست — `findActive()` بدون فیلترِ محیط، جدا از قرارداد بیمهٔ
> tenant (`TenantInsurance`) که مجوز خودش را دارد. گِیت‌زدنش با `insurances.view` فرمِ
> ثبت بیمار را برای منشیِ دارای `patients.create` با کمبوی خالی می‌شکست. در
> `ApiLeastPrivilegeTest::ALLOWED_200` با همین دلیل ثبت است.
### Query Parameters
| Param | Type | Required | Description |
+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 مجوز هر روت
```
---
@@ -29,6 +29,17 @@ use OpenApi\Attributes as OA;
#[OA\Tag(name: 'My Appointments')]
class MyAppointmentsController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
/**
* همهٔ روت‌های این کنترلر زیرِ توگلِ «مدیریت نوبت‌ها»ی پنل‌اند: فهرست، آمار روز،
* تب‌های پزشک و خودِ فرمِ ثبت نوبت. `patient-lookup` هم بخشی از همان فرم است.
*/
private function permissionResource(): string
{
return 'appointments';
}
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppointmentRepository $appointmentRepo,
@@ -63,6 +74,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function myClinicDoctors(#[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$roles = $user->getRoles();
$doctors = [];
@@ -116,6 +129,8 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403);
}
$this->denyUnlessGranted($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
@@ -394,6 +409,10 @@ class MyAppointmentsController extends BaseController
return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403);
}
// بخشی از فرمِ ثبت نوبت است، پس همان `appointments.create` — نه `patients.view`.
// با گیتِ پرونده، منشی‌ای که فقط اجازهٔ نوبت‌دهی دارد فرمش را از دست می‌داد.
$this->denyUnlessGranted($user, 'create');
// Lookup by mobile OR national code — the booking form lets the user
// search either way. National code takes precedence when both are sent.
$mobile = InputValidator::toEnglishDigits(trim((string) $request->query->get('mobile', '')));
@@ -430,6 +449,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(500, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
@@ -620,6 +641,8 @@ class MyAppointmentsController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function todayStats(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$date = trim((string) $request->query->get('date', date('Y-m-d')));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
@@ -26,6 +26,38 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AppointmentPlanController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
/**
* بخش‌بندی یک خاصیتِ `ServiceItem` است و صفحه‌اش داخل کاتالوگ خدمات، پس همان
* منبعِ `services` — نه `appointments`. دقیقاً همان استدلالِ پروتکل درمان در
* آدیت ۲۰۲۶-۰۸-۰۷.
*/
private function permissionResource(): string
{
return 'services';
}
/**
* پیش‌نمایشِ برنامهٔ یک نوبت — ورودیِ فرمِ ثبت نوبت است، نه پیکربندیِ سرویس.
*
* قرینهٔ `ResourcePermissionTrait::denyUnlessGrantedForBooking`: منشی‌ای که
* اجازهٔ ثبت نوبت دارد ولی کاتالوگ خدمات برایش بسته است، وگرنه نمی‌توانست همان
* نوبتی را که مجاز است ثبت کند.
*/
private function denyUnlessGrantedForPlanning(User $user): void
{
$allowed =
($this->secretaryAccess->canOrNonSecretary($user, 'services', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'services', 'view'))
|| ($this->secretaryAccess->canOrNonSecretary($user, 'appointments', 'view')
&& $this->clinicDoctorAccess->canOrNonMember($user, 'appointments', 'view'));
if (!$allowed) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
public function __construct(
private readonly SegmentTemplateRepository $templates,
private readonly ServiceItemRepository $items,
@@ -38,6 +70,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$service = $this->requireItem($user, $uuid);
return $this->success(array_map(
@@ -55,6 +89,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_replace', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['segments'] ?? null)) {
@@ -198,6 +234,8 @@ class AppointmentPlanController extends BaseController
#[Route('/api/v1/appointment-plan/preview', name: 'appointment_plan_preview', methods: ['POST'])]
public function preview(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGrantedForPlanning($user);
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) {
@@ -16,6 +16,7 @@ use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Controller\PermissionGateTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -27,6 +28,17 @@ use OpenApi\Attributes as OA;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BillingController extends BaseController
{
use PermissionGateTrait;
/**
* صورتحساب و مطالبهٔ بیمه هر دو زیرِ «مدیریت پرداخت‌ها»ی پنل نشسته‌اند و توگل
* جداگانه‌ای ندارند، پس منبعِ مجوزشان یکی است.
*/
private function permissionResource(): string
{
return 'payments';
}
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly InvoiceRepository $invoiceRepo,
@@ -108,6 +120,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -132,6 +146,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices/{uuid}', methods: ['GET'])]
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
@@ -144,6 +160,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/invoices/{uuid}/finalize', methods: ['POST'])]
public function finalize(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$invoice = $this->invoiceRepo->findByUuid($uuid);
if ($invoice === null || $invoice->getEntityType() !== $entityType || $invoice->getEntityId() !== $entityId) {
@@ -164,6 +182,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/payments', methods: ['GET'])]
public function listPayments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -190,6 +210,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/payments/summary', methods: ['GET'])]
public function paymentsSummary(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -223,6 +245,8 @@ class BillingController extends BaseController
#[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])]
public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -262,6 +286,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims', methods: ['POST'])]
public function createClaim(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -286,6 +312,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims', methods: ['GET'])]
public function listClaims(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -324,6 +352,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/by-patient', methods: ['GET'])]
public function claimsByPatient(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -349,6 +379,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/by-patient/{patientUuid}', methods: ['GET'])]
public function claimsForPatient(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -439,6 +471,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])]
public function transitionClaim(string $uuid, string $action, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$claim = $this->claimRepo->findByUuid($uuid);
if ($claim === null || $claim->getEntityType() !== $entityType || $claim->getEntityId() !== $entityId) {
@@ -483,6 +517,8 @@ class BillingController extends BaseController
#[Route('/api/v1/billing/reports/insurance-debt', methods: ['GET'])]
public function insuranceDebt(#[CurrentUser] User $user): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -3,11 +3,9 @@
namespace App\Resource\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\PermissionGateTrait;
use App\Shared\Exception\AppException;
use Symfony\Contracts\Service\Attribute\Required;
/**
* گِیتِ مشترک کنترلرهای این دامنه.
@@ -18,20 +16,13 @@ use Symfony\Contracts\Service\Attribute\Required;
*
* تعطیلات از این قاعده مستثناست و `appointment_settings` می‌ماند — صفحه‌اش
* زیرمجموعهٔ تنظیمات نوبت‌دهی است، نه فهرست دستگاه‌ها.
*
* تزریق checkerها و خودِ `denyUnlessGranted` در `PermissionGateTrait` مشترک است؛
* اینجا فقط منبعِ پیش‌فرض و استثنای نوبت‌دهی می‌ماند.
*/
trait ResourcePermissionTrait
{
private SecretaryAccessChecker $secretaryAccess;
private ClinicDoctorAccessChecker $clinicDoctorAccess;
#[Required]
public function setResourceAccessCheckers(
SecretaryAccessChecker $secretaryAccess,
ClinicDoctorAccessChecker $clinicDoctorAccess,
): void {
$this->secretaryAccess = $secretaryAccess;
$this->clinicDoctorAccess = $clinicDoctorAccess;
}
use PermissionGateTrait;
/** کنترلری که منبعِ دیگری را گِیت می‌کند این را بازنویسی می‌کند. */
private function permissionResource(): string
@@ -39,15 +30,6 @@ trait ResourcePermissionTrait
return 'resources';
}
/** @param 'view'|'create'|'update'|'delete' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$resource = $this->permissionResource();
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
/**
* خواندنِ منبع **برای نوبت‌دهی** — نه برای پیکربندی‌اش.
*
@@ -0,0 +1,58 @@
<?php
namespace App\Shared\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use Symfony\Contracts\Service\Attribute\Required;
/**
* گِیتِ مجوزِ مشترکِ کنترلرها.
*
* هر دو checker با هم صدا زده می‌شوند چون هرکدام یک نقش را می‌بندد و بقیه را
* دست‌نخورده رد می‌کند: منشی با `SecretaryAccessChecker`، پزشکِ عضوِ کلینیک با
* `ClinicDoctorAccessChecker`. ادمین، مالک کلینیک و پزشک مطب شخصی از هیچ‌کدام
* اثر نمی‌گیرند.
*
* تزریق با `#[Required]` است نه constructor، تا کنترلری که constructor پرِ خودش
* را دارد برای گرفتن گِیت مجبور به بازنویسی امضایش نشود.
*/
trait PermissionGateTrait
{
private SecretaryAccessChecker $secretaryAccess;
private ClinicDoctorAccessChecker $clinicDoctorAccess;
#[Required]
public function setPermissionGateCheckers(
SecretaryAccessChecker $secretaryAccess,
ClinicDoctorAccessChecker $clinicDoctorAccess,
): void {
$this->secretaryAccess = $secretaryAccess;
$this->clinicDoctorAccess = $clinicDoctorAccess;
}
/**
* منبعِ پیش‌فرضِ این کنترلر در `PermissionCatalog`. کنترلری که فقط یک منبع را
* گِیت می‌کند این را بازنویسی می‌کند و بعد `denyUnlessGranted($user, $action)`
* صدا می‌زند؛ کنترلری که چند منبع دارد `denyUnlessGrantedOn()` را مستقیم می‌زند.
*/
abstract private function permissionResource(): string;
/** @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action */
private function denyUnlessGranted(User $user, string $action): void
{
$this->denyUnlessGrantedOn($user, $this->permissionResource(), $action);
}
/**
* گِیت روی یک منبعِ صریح — برای کنترلری که بیش از یک منبع را پوشش می‌دهد.
*
* @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action
*/
private function denyUnlessGrantedOn(User $user, string $resource, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
}
@@ -52,7 +52,15 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
$this->assertSame(1, $body['meta']['totalRecords']);
}
public function testSecretaryWithNoAssignmentSeesNothing(): void
/**
* منشیِ بدون رابطهٔ فعال دیگر لیست خالی نمی‌گیرد، ۴۰۳ می‌گیرد.
*
* تا ۲۰۲۶-۰۸-۰۷ این مسیر گِیت مجوز نداشت و پاسخ خالی از فیلترِ خودِ کوئری
* می‌آمد. با گِیتِ `appointments.view` (یافتهٔ ۸ آدیت)، `SecretaryAccessChecker`
* برای منشیِ بی‌رابطه `can()=false` می‌دهد — همان رفتار fail-closed مستندِ
* `SecretaryPermissionChecker`. «خالیِ خاموش» با «اجازه نداری» یکی نیست.
*/
public function testSecretaryWithNoAssignmentIsDenied(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
@@ -73,7 +81,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
// بدون رابطه‌ی فعال → resolveSecretaryFilter=null → لیست خالی
$this->assertSame(0, $body['meta']['totalRecords']);
$this->assertSame(403, $this->responseCode());
$this->assertSame('ERR_FORBIDDEN_001', $body['errors'][0]['code']);
}
}
+188 -24
View File
@@ -70,39 +70,102 @@ class ApiLeastPrivilegeTest extends ApiTestCase
// رفتار مستند: بدون مجوز فقط قابلیت‌های پلن می‌آید، نه وضعیت/تاریخ اشتراک.
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
'app_subscription_subscription_my' => 'نسخهٔ کاهش‌یافتهٔ عمدی',
// فهرست پزشکانِ تخصیص‌یافته به همین منشی — تستش
// SecretaryResourceEnforcementTest::testDoctorListReturnsOnlyAssignedDoctors
'app_appointment_myappointments_myclinicdoctors' => 'فقط پزشکانِ تخصیص‌یافته به خودِ منشی',
// نوبت‌های خودِ کاربر به‌عنوان بیمار (`a.user = خودِ او`)، نه دادهٔ محیط.
// مصرف‌کننده‌اش داشبورد بیمار در nobat724_front است. گِیتِ appointments.view
// اینجا یعنی منشی‌ای که جایی بیمار است نوبت‌های شخصی‌اش را نبیند.
'app_appointment_appointment_listbyuser' => 'نوبت‌های خودِ کاربر به‌عنوان بیمار',
// کاتالوگ‌های سراسری: `findActive()` بدون هیچ فیلترِ محیط. هم‌ردهٔ
// specialties و tags بالا؛ تنها فرقشان این است که پشت firewall نشسته‌اند.
// گِیت‌زدنشان یک مجوز را با نبودِ مجوزِ دیگری می‌شکند: منشیِ دارای
// patients.create فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گیرد.
'app_doctorservice_doctorservice_list' => 'کاتالوگ سراسری خدمات پزشک — دادهٔ مرجع',
'app_insurance_insurance_list' => 'کاتالوگ سراسری بیمه‌ها — دادهٔ مرجع',
];
/**
* بدهیِ شناخته‌شده — روت‌هایی که **باید** گِیت داشته باشند و ندارند.
*
* این‌ها در آدیت ۲۰۲۶-۰۸-۰۷ کشف شدند و عمداً همان جلسه رفع **نشدند**: هر سه
* کنترلرشان (`BillingController`، `MyAppointmentsController`،
* `DoctorServiceController`) هیچ checker مجوزی تزریق‌شده ندارند، و بستنشان
* بدون دانستن نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد.
* **از ۲۰۲۶-۰۸-۰۸ خالی است.** ده ردیفِ اولیه‌اش در آدیت ۲۰۲۶-۰۸-۰۷ ثبت شده
* بود؛ هفت‌تای‌شان گِیت گرفتند و سه‌تا پس از خواندنِ کد مثبت کاذب درآمدند و به
* `ALLOWED_200` رفتند.
*
* در DB تست، tenant خالی است پس پاسخشان خالی می‌آید؛ در tenant واقعی دادهٔ
* واقعی می‌دهند. نبودِ نشت در تست، دلیلِ امن‌بودن نیست.
*
* نقشِ این فهرست مثل baseline است: تست اجازه می‌دهد این‌ها ۲۰۰ بدهند، ولی
* **بزرگ‌ترشدنش** را نمی‌پذیرد. هر روتِ تازه‌ای که بدون گِیت اضافه شود، تست را
* قرمز می‌کند. حذف هر ردیف از اینجا یعنی آن گَپ بسته شد.
* فهرست عمداً باقی می‌ماند: نقشش baseline است، و خالی‌بودنش یعنی «همین حالا
* بدهی‌ای نداریم»، نه «این مکانیزم لازم نیست». ردیفِ تازه فقط با تصمیمِ آگاهانه
* اضافه شود.
*
* @var array<string, string> route name => منبعِ مجوزی که باید enforce شود
*/
private const KNOWN_GAPS = [
'app_appointment_appointment_listbyuser' => 'appointments.view',
'app_appointment_myappointments_myappointments' => 'appointments.view',
'app_appointment_myappointments_todaystats' => 'appointments.view',
'app_billing_billing_listpayments' => 'payments.view',
'app_billing_billing_paymentssummary' => 'payments.view',
'app_billing_billing_listclaims' => 'payments.view',
'app_billing_billing_claimsbypatient' => 'payments.view',
'app_billing_billing_insurancedebt' => 'payments.view',
'app_doctorservice_doctorservice_list' => 'services.view',
'app_insurance_insurance_list' => 'insurances.view',
private const KNOWN_GAPS = [];
/**
* روت‌های نوشتنی‌ای که منشیِ بی‌مجوز حق دارد از گِیت ردشان کند.
*
* سه دسته: (۱) اندپوینتِ عمومی یا پیش‌ازلاگین؛ (۲) اکشنی روی دادهٔ خودِ کاربر که
* منبعی در `PermissionCatalog` ندارد؛ (۳) رفتارِ عمدیِ مستند.
*
* @var array<string, string> route name => دلیل
*/
private const ALLOWED_WRITE = [
// ── پیش‌ازلاگین: اصلاً نقشی وجود ندارد که مجوز داشته باشد ─────────────
'app_auth_auth_login' => 'لاگین',
'app_auth_auth_sendcode' => 'ارسال کد تأیید',
'app_auth_auth_verifycode' => 'بررسی کد تأیید',
'app_auth_auth_register' => 'ثبت‌نام',
'app_auth_auth_otplogin' => 'ورود با رمز یک‌بارمصرف',
'app_auth_auth_resetpassword' => 'بازیابی رمز',
'app_auth_preregistration_submit' => 'پیش‌ثبت‌نام عمومی',
'app_payment_payment_callback' => 'کال‌بک درگاه — بدون توکن فراخوانی می‌شود',
'app_payment_payment_subscriptioncallback' => 'کال‌بک درگاه اشتراک',
// ── اکشن روی دادهٔ خودِ کاربر: منبعی در رجیستری ندارد ─────────────────
'app_auth_auth_changepassword' => 'تغییر رمزِ خودِ کاربر',
'app_auth_auth_switchcontext' => 'سوییچ محیطِ خودِ کاربر',
'app_auth_notificationmobile_requestotp' => 'موبایل اعلانِ خودِ کاربر',
'app_auth_notificationmobile_verify' => 'موبایل اعلانِ خودِ کاربر',
'app_auth_notificationmobile_remove' => 'موبایل اعلانِ خودِ کاربر',
'app_userprofile_userprofile_create' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_update' => 'پروفایل خودِ کاربر',
'app_userprofile_userprofile_uploadavatar' => 'آواتار خودِ کاربر',
'app_settlement_settlement_request' => 'تسویهٔ کیف‌پول خودِ کاربر',
'app_secretary_secretary_addiban' => 'شبای خودِ منشی',
'app_secretary_secretary_removeiban' => 'شبای خودِ منشی',
'app_doctor_doctor_create' => 'ساخت پروفایل پزشکِ خودِ کاربر — ۴۰۹ اگر قبلاً دارد',
'app_clinic_clinic_create' => 'ساخت کلینیکِ خودِ کاربر',
'app_doctor_doctorclaim_claim' => 'ادعای مالکیتِ پروفایل پزشک توسط خودِ فرد',
'app_clinicinvitation_clinicinvitation_acceptinvitation' => 'پذیرشِ دعوتِ خودِ فرد',
'app_clinicinvitation_clinicinvitation_rejectinvitation' => 'ردِ دعوتِ خودِ فرد',
// ── اکشن بیمار روی محتوای عمومی ──────────────────────────────────────
'app_rating_rating_rate' => 'امتیازدهی بیمار',
'app_rating_rating_createcomment' => 'ثبت نظر بیمار',
'app_rating_rating_deletecomment' => 'حذف نظرِ خودِ فرد',
'app_rating_rating_togglelike' => 'لایک بیمار',
// ── جریان رزرو عمومی: مصرف‌کننده‌اش nobat724_front است، نه پنل ────────
'app_appointment_appointment_book' => 'رزرو نوبت توسط خودِ بیمار',
'appointment_availability' => 'وقت‌های آزاد — ورودی رزرو عمومی',
'appointment_hold_create' => 'نگه‌داشتن موقت اسلات در جریان رزرو',
'appointment_hold_release' => 'آزادکردن اسلاتِ نگه‌داشته‌شده',
'appointment_confirm' => 'تأیید نهایی رزرو عمومی',
'appointment_rebook' => 'رزرو مجدد توسط خودِ بیمار',
'pricing_quote' => 'محاسبهٔ قیمت پیش از رزرو',
'app_payment_payment_initiateappointment' => 'پرداختِ نوبتِ خودِ بیمار',
// ── رفتار عمدیِ مستند ────────────────────────────────────────────────
// حذف سرویس/بخش اصلاً ممکن نیست و کنترلر بی‌قیدوشرط ۴۰۹ می‌دهد، پس هرگز
// به لایهٔ مجوز نمی‌رسد. دلیلش در خودِ ClinicServiceController نوشته شده:
// نوبت و فاکتور و سوابق پرداخت به سرویس ارجاع دارند.
'app_clinicservice_clinicservice_deleteitem' => 'حذف ممنوع — همیشه ۴۰۹',
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
// ── گِیت دارند ولی هدفشان از بدنه می‌آید، نه از path ──────────────────
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ می‌دهند. مثل روت‌های
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان می‌شد.
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده می‌شود',
];
/**
@@ -180,6 +243,107 @@ class ApiLeastPrivilegeTest extends ApiTestCase
));
}
/**
* مقدارِ جایگزینِ یک path parameter — طوری که روت **match شود** ولی رکوردی
* پیدا نشود.
*
* اگر مقدار با requirement نخواند، Symfony قبل از رسیدن به کنترلر ۴۰۴ می‌دهد و
* تست بی‌دلیل قرمز می‌شود. پس alternationهای ساده (`submit|approve|…`) اولین
* شاخه‌شان برداشته می‌شود، عددی‌ها `999999999` می‌گیرند و بقیه uuidِ صفر.
*/
private static function sampleValueFor(string $name, ?string $requirement): string
{
$nilUuid = '00000000-0000-0000-0000-000000000000';
if (preg_match('/^[a-z_]+(\|[a-z_]+)+$/i', (string) $requirement)) {
return explode('|', $requirement)[0];
}
if (preg_match('/^(\\\\d\+?|\[0-9\]\+)$/', (string) $requirement)) {
return '999999999';
}
// بدون requirement، نامِ پارامتر تنها سرنخِ نوعِ آرگومانِ کنترلر است. uuid
// فرستادن به `int $id` قبل از رسیدن به کنترلر ۵۰۰ می‌دهد، نه ۴۰۴.
if ($name === 'id' || str_ends_with($name, 'Id') || str_ends_with($name, '_id')) {
return '999999999';
}
return $nilUuid;
}
/**
* روتِ نوشتنی با پارامترهای جایگزین‌شده؛ null اگر پارامتری داشت که نمی‌شد
* مقدارِ مطمئنی برایش ساخت.
*/
private static function probePath(\Symfony\Component\Routing\Route $route): string
{
$path = $route->getPath();
return preg_replace_callback(
'/\{!?(\w+)\}/',
static fn(array $m) => self::sampleValueFor($m[1], $route->getRequirement($m[1])),
$path,
);
}
/**
* قرینهٔ تستِ بالا برای `POST`/`PUT`/`PATCH`/`DELETE`.
*
* بدنهٔ معتبر لازم نیست و عمداً فرستاده نمی‌شود. استدلالش همان یافتهٔ ۱ آدیت
* ۲۰۲۶-۰۸-۰۷ است، وارونه: آنجا `422` شاهدِ **عبور** از لایهٔ authorization بود،
* چون کد خطا از داخلِ writer می‌آمد. پس `2xx` یا `422` برای منشیِ بی‌مجوز یعنی
* گِیت نخورده و فقط اعتبارسنجی جلویش را گرفته.
*
* قاعده دو سطحی است، چون `404` دو معنای متفاوت دارد:
*
* - **روتِ بدون path parameter** باید دقیقاً `403` بدهد. چیزی برای واکشی وجود
* ندارد، پس هیچ توجیهی برای پاسخِ دیگر نیست.
* - **روتِ پارامتردار** `403` یا `404` هر دو قبول است. uuidِ ناموجود می‌فرستیم و
* بیشترِ کنترلرهای این پروژه اول رکورد را واکشی می‌کنند و بعد مجوز را
* می‌سنجند، پس `404` می‌دهند بی‌آنکه بی‌گِیت باشند.
*
* محدودیتِ صادقانهٔ سطح دوم: با `404` نمی‌شود «گِیت بعد از واکشی» را از «اصلاً
* گِیت ندارد» تفکیک کرد. تفکیکش رکوردِ واقعی در tenantِ همین منشی می‌خواهد،
* یعنی fixture به ازای هر روت. فهرستِ ۳۴ روتی که گِیتشان بعد از واکشی است در
* `docs/security/AUDIT-2026-08-07.md` (یافتهٔ ۱۰) ثبت شده تا بدهی گم نشود.
*/
public function testNoApiWriteRouteSkipsItsPermissionGate(): void
{
$secretary = $this->makePowerlessSecretary();
$router = self::getContainer()->get('router');
$ungated = [];
foreach ($router->getRouteCollection() as $name => $route) {
if (!str_starts_with($route->getPath(), '/api/')) {
continue;
}
$writeMethods = array_values(array_intersect(
$route->getMethods(),
['POST', 'PUT', 'PATCH', 'DELETE'],
));
if ($writeMethods === [] || isset(self::ALLOWED_WRITE[$name])) {
continue;
}
$hasPathParam = str_contains($route->getPath(), '{');
$probe = self::probePath($route);
$this->authJson($writeMethods[0], $probe, $secretary);
$code = $this->responseCode();
$accepted = $hasPathParam ? in_array($code, [403, 404], true) : $code === 403;
if (!$accepted) {
$ungated[] = sprintf('%s %s %s → %d', $name, $writeMethods[0], $probe, $code);
}
}
$this->assertSame([], $ungated, sprintf(
"این روت‌های نوشتنی به منشیِ بدونِ هیچ مجوزی گِیت مجوز را رد کردند.\n"
. "روتِ بدون parameter باید ۴۰۳ بدهد؛ روتِ پارامتردار ۴۰۳ یا ۴۰۴.\n"
. "اگر عمدی‌اند، با دلیل به ALLOWED_WRITE برو.\n%s",
implode("\n", $ungated),
));
}
/**
* بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
* تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
+224
View File
@@ -0,0 +1,224 @@
<?php
namespace App\Tests\Staff;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Entity\CatalogCategoryInclude;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Shared\Context\EntityContext;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
use App\Treatment\Entity\SessionAreaRecord;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Entity\TreatmentSession;
/**
* IDOR بین‌محیطی برای نقش پرسنل.
*
* آدیت ۲۰۲۶-۰۸-۰۷ این سناریو را در بخش «محدودیت پوشش» باز گذاشت: در DB زنده تنها
* یک `ClinicStaff` وجود داشت، پس «پرسنل کلینیک الف روی جلسهٔ کلینیک ب» هرگز اجرا
* نشد. اینجا هر دو محیط در fixture ساخته می‌شوند تا آن حفرهٔ پوشش دائمی بسته شود.
*
* انتظار در همهٔ پروب‌ها `404` است نه `403`: تفاوت «یافت نشد» و «اجازه نداری»
* خودش یک enumeration oracle است و به مهاجم می‌گوید کدام uuid در سیستم وجود دارد.
*/
class StaffCrossTenantTest extends ApiTestCase
{
private const LASER_SCHEMA = [
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
];
/**
* یک محیطِ کاملِ مستقل: کلینیک، پرسنل، پروتکل دوجلسه‌ای، پرونده و جلسهٔ فعال.
*
* @return array{staffUser: User, staff: ClinicStaff, session: TreatmentSession}
*/
private function tenant(string $clinicName): array
{
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
$clinic->setName($clinicName);
$this->em->persist($clinic);
$this->em->flush();
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر');
$type->setFieldSchema(self::LASER_SCHEMA);
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'Diode Laser');
$resource->setSupervisor($doctor);
$this->em->persist($resource);
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
$this->em->persist($section);
$parent = new CatalogCategory('clinic', (int) $clinic->getId(), 'توتال');
$this->em->persist($parent);
$area = new CatalogCategory('clinic', (int) $clinic->getId(), 'زیر بغل');
$this->em->persist($area);
$this->em->flush();
$this->em->persist(new CatalogCategoryInclude($parent, $area));
$service = new ServiceItem($section, 'لیزر توتال', 10_000_000);
$service->setCatalogCategory($parent)->setDurationMinutes(30);
$this->em->persist($service);
$staffUser = $this->createUser(['ROLE_USER', 'ROLE_STAFF']);
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
$staff->setUser($staffUser);
$this->em->persist($staff);
$protocol = new TreatmentProtocol($service);
$this->em->persist($protocol);
$protocol->replaceSteps([
new TreatmentProtocolStep($protocol, 1, 0),
new TreatmentProtocolStep($protocol, 2, 30),
]);
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
$this->em->persist($record);
$this->em->flush();
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
$case->addArea(new TreatmentCaseArea($case, $area, 0));
$appointment = $this->newAppointment($doctor, $record->getUser(), time() + 3600, time() + 5400, $clinic);
$appointment->setResource($resource);
$appointment->setStaff($staff);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$session = new TreatmentSession($case, 1);
$session->attachAppointment($appointment);
$case->addSession($session);
$case->addSession(new TreatmentSession($case, 2));
$this->em->persist($case);
$this->em->flush();
// نقش به‌تنهایی محیط نمی‌سازد؛ پرسنل باید محیط فعالش ست شده باشد.
static::getContainer()->get(UserActiveContextRepository::class)
->upsert($staffUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
return ['staffUser' => $staffUser, 'staff' => $staff, 'session' => $session];
}
/** تنها ناحیهٔ یک جلسهٔ شروع‌شده. */
private function areaRecord(TreatmentSession $session): SessionAreaRecord
{
$records = $this->em->getRepository(SessionAreaRecord::class)->findBy(['session' => $session]);
self::assertNotSame([], $records, 'جلسه باید دست‌کم یک ناحیه داشته باشد');
return $records[0];
}
/**
* شاهد مثبت: بدون این، «۴۰۴ در همه‌جا» می‌توانست معنیِ «مسیر اصلاً کار نمی‌کند»
* بدهد و تستِ جداسازی بی‌اثر شود.
*/
public function testStaffCanStartASessionInsideTheirOwnTenant(): void
{
$a = $this->tenant('کلینیک الف');
$body = $this->authJson(
'POST',
'/api/v1/dashboard/staff/treatment-session/' . $a['session']->getUuid() . '/start',
$a['staffUser'],
);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame($a['staff']->getUuid(), $body['data']['performed_by']['uuid']);
}
/** خواندن و نوشتنِ جلسهٔ محیط دیگر — هر چهار مسیر باید ۴۰۴ بدهند. */
public function testStaffCannotTouchASessionOfAnotherTenant(): void
{
$a = $this->tenant('کلینیک الف');
$b = $this->tenant('کلینیک ب');
$victim = $b['session']->getUuid();
$probes = [
['GET', "/api/v1/dashboard/staff/treatment-session/{$victim}"],
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/start"],
['POST', "/api/v1/dashboard/staff/treatment-session/{$victim}/finish"],
];
foreach ($probes as [$method, $uri]) {
$body = $this->authJson($method, $uri, $a['staffUser']);
self::assertSame(404, $this->responseCode(), "{$method} {$uri}");
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], "{$method} {$uri}");
}
}
/** ناحیهٔ جلسهٔ محیط دیگر — همان قاعده یک لایه پایین‌تر در aggregate. */
public function testStaffCannotTouchASessionAreaOfAnotherTenant(): void
{
$a = $this->tenant('کلینیک الف');
$b = $this->tenant('کلینیک ب');
// ناحیه‌ها هنگام شروعِ جلسه ساخته می‌شوند، پس اول محیط ب جلسه‌اش را شروع می‌کند.
$this->authJson(
'POST',
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid() . '/start',
$b['staffUser'],
);
self::assertSame(200, $this->responseCode());
$victim = $this->areaRecord($b['session'])->getUuid();
foreach (['start', 'complete', 'skip', 'reopen'] as $action) {
$uri = "/api/v1/dashboard/staff/session-area/{$victim}/{$action}";
$body = $this->authJson('POST', $uri, $a['staffUser'], ['parameters' => ['shots' => 10]]);
self::assertSame(404, $this->responseCode(), $uri);
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code'], $uri);
}
}
/**
* مرزی: uuidِ اصلاً ناموجود باید همان ۴۰۴ را بدهد که uuidِ محیطِ دیگر می‌دهد.
* اگر این دو فرق کنند، همان تفاوت به مهاجم می‌گوید کدام uuid واقعی است.
*/
public function testUnknownUuidIsIndistinguishableFromAnotherTenantsUuid(): void
{
$a = $this->tenant('کلینیک الف');
$b = $this->tenant('کلینیک ب');
$nil = '00000000-0000-0000-0000-000000000000';
$unknown = $this->authJson('GET', "/api/v1/dashboard/staff/treatment-session/{$nil}", $a['staffUser']);
$unknownCode = $this->responseCode();
$foreign = $this->authJson(
'GET',
'/api/v1/dashboard/staff/treatment-session/' . $b['session']->getUuid(),
$a['staffUser'],
);
self::assertSame($unknownCode, $this->responseCode());
self::assertSame($unknown['errors'][0]['code'], $foreign['errors'][0]['code']);
}
}