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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 اضافه شد، این
|
||||
* تست قرمز میشود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user