diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md index 5c3d1c96..cd98bdc6 100644 --- a/docs/api/appointment-plan.md +++ b/docs/api/appointment-plan.md @@ -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` جایگزینی کامل است. هر بخش: diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 5fb34508..87071b75 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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ِ عمومی است و **همهٔ** پزشکانِ diff --git a/docs/api/billing.md b/docs/api/billing.md index c8f5177b..02bf05a5 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -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 diff --git a/docs/api/doctor-service.md b/docs/api/doctor-service.md index 57751d15..ecfe1c5e 100644 --- a/docs/api/doctor-service.md +++ b/docs/api/doctor-service.md @@ -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 | diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 5c03545c..a9292336 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -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 | diff --git a/docs/security/AUDIT-2026-08-07.md b/docs/security/AUDIT-2026-08-07.md index e7716000..064dc7a5 100644 --- a/docs/security/AUDIT-2026-08-07.md +++ b/docs/security/AUDIT-2026-08-07.md @@ -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 مجوز هر روت +``` --- diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 12d7c8b4..24c91fce 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -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'); diff --git a/src/Appointment/Plan/Controller/AppointmentPlanController.php b/src/Appointment/Plan/Controller/AppointmentPlanController.php index 77020fe2..e04951b4 100644 --- a/src/Appointment/Plan/Controller/AppointmentPlanController.php +++ b/src/Appointment/Plan/Controller/AppointmentPlanController.php @@ -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)) { diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index 912f25e3..740c61e5 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -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); diff --git a/src/Resource/Controller/ResourcePermissionTrait.php b/src/Resource/Controller/ResourcePermissionTrait.php index e981380b..b31964df 100644 --- a/src/Resource/Controller/ResourcePermissionTrait.php +++ b/src/Resource/Controller/ResourcePermissionTrait.php @@ -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); - } - /** * خواندنِ منبع **برای نوبت‌دهی** — نه برای پیکربندی‌اش. * diff --git a/src/Shared/Controller/PermissionGateTrait.php b/src/Shared/Controller/PermissionGateTrait.php new file mode 100644 index 00000000..c1b26ba3 --- /dev/null +++ b/src/Shared/Controller/PermissionGateTrait.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/tests/Secretary/SecretaryAppointmentScopeTest.php b/tests/Secretary/SecretaryAppointmentScopeTest.php index 4401fea0..35bfd939 100644 --- a/tests/Secretary/SecretaryAppointmentScopeTest.php +++ b/tests/Secretary/SecretaryAppointmentScopeTest.php @@ -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']); } } diff --git a/tests/Shared/ApiLeastPrivilegeTest.php b/tests/Shared/ApiLeastPrivilegeTest.php index 3c85fb18..201a2bba 100644 --- a/tests/Shared/ApiLeastPrivilegeTest.php +++ b/tests/Shared/ApiLeastPrivilegeTest.php @@ -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 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 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 اضافه شد، این * تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه diff --git a/tests/Staff/StaffCrossTenantTest.php b/tests/Staff/StaffCrossTenantTest.php new file mode 100644 index 00000000..298bff1f --- /dev/null +++ b/tests/Staff/StaffCrossTenantTest.php @@ -0,0 +1,224 @@ + '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']); + } +}