feat: Implement permission gate for appointment and billing controllers

- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
This commit is contained in:
hamed
2026-08-08 10:27:13 +03:30
parent c452150a83
commit 934405c42d
14 changed files with 830 additions and 67 deletions
+22
View File
@@ -24,6 +24,28 @@
---
## مجوزها
از ۲۰۲۶-۰۸-۰۸:
- `GET /service-item/{uuid}/segments``services.view`
- `PUT /service-item/{uuid}/segments``services.update`
- `POST /appointment-plan/preview``services.view` **یا** `appointments.view`
منبعش `services` است نه `appointments`: بخش‌بندی یک خاصیتِ `ServiceItem` است و صفحه‌اش
داخل کاتالوگ خدمات می‌نشیند. همان استدلالِ پروتکل درمان در آدیت ۲۰۲۶-۰۸-۰۷.
`preview` استثناست و «یا» می‌گیرد، چون ورودیِ فرمِ ثبت نوبت است نه پیکربندیِ سرویس:
منشی‌ای که اجازهٔ ثبت نوبت دارد ولی کاتالوگ خدمات برایش بسته است، وگرنه نمی‌توانست
همان نوبتی را که مجاز است ثبت کند. قرینهٔ `ResourcePermissionTrait::denyUnlessGrantedForBooking`.
> **چرا اضافه شد:** این کنترلر دقیقاً همان شکلِ `TreatmentProtocolController` پیش از
> رفعِ یافتهٔ ۱ آدیت را داشت — `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح‌کلاس و یک
> `requireItem()` که فقط مالکیتِ محیط را می‌سنجد. مالکیت مجوز نیست: عبور از آن فقط
> ثابت می‌کند سرویس مالِ همین محیط است، نه اینکه این کاربر حق دست‌زدن به آن را دارد.
---
## `GET/PUT /api/v1/service-item/{uuid}/segments`
`PUT` جایگزینی کامل است. هر بخش:
+26 -8
View File
@@ -524,7 +524,13 @@ The doctor dashboard filter bar uses the paginated form, defaulting `statuses` t
Get all appointments for the authenticated user.
**Permission:** `AUTH`
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
> این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان
> بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
> داده شد: گِیتِ `appointments.view` یعنی منشی‌ای که جایی بیمار است نوبت‌های شخصی‌اش
> را نبیند. در `ApiLeastPrivilegeTest::ALLOWED_200` با همین دلیل ثبت است.
### Query Parameters
| Param | Type | Required | Description |
@@ -780,6 +786,9 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
## POST `/api/v1/my/appointment`
**Permission:** `appointments.create` — علاوه بر بررسی نقش (`ROLE_DOCTOR`/`ROLE_CLINIC`/`ROLE_SECRETARY`/`ROLE_ADMIN`).
تا پیش از ۲۰۲۶-۰۸-۰۸ فقط نقش بررسی می‌شد، پس منشیِ `appointments.create:false` هم نوبت ثبت می‌کرد.
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
@@ -854,9 +863,12 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
جستجوی بیمار با شماره موبایل **یا** کد ملی، پیش از ثبت نوبت. فرم ثبت نوبت با یکی از این دو معیار جستجو می‌کند؛ اگر بیمار یافت شد و کد ملی دارد، مستقیم استفاده می‌شود، وگرنه بقیهٔ مشخصات (نام و موبایل یا کد ملی) از کاربر گرفته می‌شود.
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`**Permission:** `appointments.create`
> برخلاف `GET /api/v1/patient/search-user`، این endpoint به فیچر `patient_records` اشتراک وابسته نیست و `ROLE_ADMIN` را هم می‌پذیرد، چون ثبت نوبت باید مستقل از اشتراک کار کند.
>
> مجوزش عمداً `appointments.create` است نه `patients.view`: بخشی از فرمِ ثبت نوبت است،
> و با گیتِ پروندهٔ بیمار، منشی‌ای که فقط اجازهٔ نوبت‌دهی دارد فرمش را از دست می‌داد.
### Query Parameters
یکی از `mobile` یا `national_code` الزامی است. اگر هر دو ارسال شوند، `national_code` اولویت دارد.
@@ -897,7 +909,10 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
**Auth:** `IS_AUTHENTICATED_FULLY` (any role)
**Auth:** `IS_AUTHENTICATED_FULLY` **Permission:** `appointments.view`
> از ۲۰۲۶-۰۸-۰۸ گِیت دارد. پیش از آن منشیِ `appointments:false` با درخواست مستقیم به
> API همان فهرستی را می‌گرفت که توگل، دکمه‌اش را در پنل پنهان کرده بود.
**Role behavior:**
| Role | Scope |
@@ -913,10 +928,13 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).
**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
**Auth:** `IS_AUTHENTICATED_FULLY`**Permission:** `appointments.view`. A caller with no
resolvable scope (clinic/doctor row missing, or no assigned doctors) gets all-zero counts
rather than an unscoped, system-wide count. A plain patient gets counts over their own
appointments only.
> پیش از ۲۰۲۶-۰۸-۰۸ منشیِ بدون `appointments.view` به‌جای ۴۰۳ صفر می‌گرفت. صفرِ خاموش
> با «اجازه نداری» یکی نیست؛ حالا ۴۰۳ می‌گیرد.
### Query Parameters
| Param | Type | Default | Description |
@@ -1399,7 +1417,7 @@ active — the request simply carried no `clinic_uuid`.
## GET /api/v1/my/clinic-doctors
**Permission:** `IS_AUTHENTICATED_FULLY`
**Permission:** `IS_AUTHENTICATED_FULLY` + `appointments.view`
پزشکانِ در دسترسِ کاربرِ پنل، برای ساختِ تب‌ها/تایم‌لاینِ صفحهٔ نوبت‌ها. برخلاف
`GET /api/v1/clinic/doctor-list/{clinicUuid}` که روی firewallِ عمومی است و **همهٔ** پزشکانِ
+27 -1
View File
@@ -52,11 +52,37 @@ ddev exec php bin/console app:billing:backfill-claims # ساخت م
---
## مجوزها
از ۲۰۲۶-۰۸-۰۸ هر روتِ این کنترلر پیش از هر واکشی، مجوزِ `payments` را با
`App\Shared\Controller\PermissionGateTrait` می‌سنجد. پیش از آن هیچ روتی گِیت مجوزی
نداشت: منشیِ `payments:false` هم پرداخت‌ها را می‌دید، هم صورتحساب می‌ساخت، هم وضعیت
مطالبه را عوض می‌کرد. جزئیات در `docs/security/AUDIT-2026-08-07.md` یافتهٔ ۸.
منبعِ مجوز برای صورتحساب و مطالبه یکی است — `payments` — چون هر دو زیر همان توگلِ
«مدیریت پرداخت‌ها»ی پنل نشسته‌اند و توگل جداگانه‌ای ندارند.
- `payments.view` — خواندن: صورتحساب، فهرست پرداخت‌ها و خلاصه‌شان، فهرست مطالبات،
مطالبات به تفکیک بیمار، گزارش بدهی بیمه، صورتحساب‌های یک بیمار.
- `payments.create` — ساخت: صورتحساب تازه، مطالبهٔ تازه.
- `payments.update` — تغییر وضعیت: نهایی‌کردن صورتحساب، و `submit`/`approve`/`reject`/`pay`
روی مطالبه.
گِیت پیش از `findByUuid()` می‌نشیند. ترتیب عمدی است: اگر بعدش بود، uuidِ ناموجود ۴۰۴
می‌داد و همان تفاوت ۴۰۳/۴۰۴ به کاربرِ بی‌مجوز می‌گفت کدام uuid در این محیط وجود دارد.
نقش‌های دیگر اثری نمی‌گیرند: هر دو checker برای ادمین، مالک کلینیک و پزشک مطب شخصی
pass-through هستند و فقط منشی و پزشکِ عضوِ کلینیک را محدود می‌کنند.
خطای رد: `403` با `ERR_FORBIDDEN_001`.
---
## POST /api/v1/billing/invoices
ساخت صورتحساب از یک مراجعه. اگر صورتحساب برای آن مراجعه قبلاً ساخته شده، همان برگردانده می‌شود (idempotent).
**Permission:** `AUTH` (مالک مراجعه)
**Permission:** `payments.create` + مالکیت محیطِ مراجعه
**Body:**
```json
+9 -1
View File
@@ -8,7 +8,15 @@
List all active doctor services.
**Permission:** `PUBLIC`
**Permission:** `AUTH` — بدون مجوزِ رجیستری، و این عمدی است.
> سند تا ۲۰۲۶-۰۸-۰۸ اینجا `PUBLIC` نوشته بود که با رفتار نمی‌خواند: مسیر پشت firewall
> است و درخواستِ بدون توکن `401` می‌گیرد.
>
> کاتالوگ سراسری است — `findActive()` بدون هیچ فیلترِ محیط. هم‌ردهٔ `specialties` و
> `tags`. آدیت ۲۰۲۶-۰۸-۰۷ آن را گَپِ `services.view` دانسته بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت
> کاذب تشخیص داده شد: این فهرست dropdown فرم‌ها را پر می‌کند، پس گِیت‌زدنش یک مجوز را
> با نبودِ مجوزِ دیگری می‌شکند. در `ApiLeastPrivilegeTest::ALLOWED_200` ثبت است.
### Query Parameters
| Param | Type | Required | Description |
+9 -1
View File
@@ -41,7 +41,15 @@ hardcode نمی‌شود. ویزیت همیشه `outpatient` است.
List all active insurances.
**Permission:** `PUBLIC`
**Permission:** `AUTH` — بدون مجوزِ رجیستری، و این عمدی است.
> سند تا ۲۰۲۶-۰۸-۰۸ اینجا `PUBLIC` نوشته بود که با رفتار نمی‌خواند: مسیر پشت firewall
> است و درخواستِ بدون توکن `401` می‌گیرد.
>
> کاتالوگ سراسری بیمه‌هاست — `findActive()` بدون فیلترِ محیط، جدا از قرارداد بیمهٔ
> tenant (`TenantInsurance`) که مجوز خودش را دارد. گِیت‌زدنش با `insurances.view` فرمِ
> ثبت بیمار را برای منشیِ دارای `patients.create` با کمبوی خالی می‌شکست. در
> `ApiLeastPrivilegeTest::ALLOWED_200` با همین دلیل ثبت است.
### Query Parameters
| Param | Type | Required | Description |
+154 -6
View File
@@ -25,12 +25,17 @@ authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ⚠️ باز — با تست baseline مهار شد |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
حین همین کار کشف شد.
یافته‌های ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
```bash
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
@@ -539,13 +544,156 @@ docs/api/treatment.md مجوز هر سه ا
## پیشنهادها
1. **بستنِ ده گَپِ یافتهٔ ۸** — با دیدنِ مصرفِ واقعیِ پنل تصمیم بگیر هر کدام کدام مجوز را بخواهد،
بعد ردیفش را از `KNOWN_GAPS` بردار. تست دوم خودش یادآوری می‌کند.
1. ~~**بستنِ ده گَپِ یافتهٔ ۸**~~ — انجام شد ۲۰۲۶-۰۸-۰۸. جزئیات پایین.
2. **مهاجرت CKEditor** به پکیج umbrella `ckeditor5` v45+ — تنها راهِ بستنِ یافتهٔ ۴.
3. **یک `ClinicStaff` در tenant دوم** — تا آدیت بعدی بتواند IDOR بین‌نقشیِ پرسنل را واقعاً بزند.
4. **گسترشِ `ApiLeastPrivilegeTest` به روت‌های نوشتنی** — الان فقط `GET` بدون path parameter را
پوشش می‌دهد. `POST`/`PATCH`/`DELETE` بدنهٔ معتبر می‌خواهند، ولی همان‌ها خطرناک‌ترند.
3. ~~**یک `ClinicStaff` در tenant دوم**~~ — انجام شد، ولی در fixture نه در DB زنده.
4. ~~**گسترشِ `ApiLeastPrivilegeTest` به روت‌های نوشتنی**~~ — انجام شد ۲۰۲۶-۰۸-۰۸.
5. **پاک‌سازیِ ۱۷ خطای `phpstan`** — بدهیِ قدیمی، بی‌ربط به امنیت، ولی مانعِ «سبز یعنی سبز» است.
6. **بستنِ یافتهٔ ۱۰** — گِیت را در ۳۴ روت به بالای واکشی ببر. ~۱۵ تای‌شان object-scoped
هستند و پیش‌چکِ منبع‌سطح می‌خواهند، پس تسک جدا لازم است.
---
# پیگیری ۲۰۲۶-۰۸-۰۸
جلسهٔ بعدی، با هدفِ بستنِ یافتهٔ ۸ و پیشنهادهای ۳ و ۴. دو یافتهٔ تازه حین کار پیدا شد.
## یافتهٔ ۸ — بسته شد، ولی سه ردیفش مثبت کاذب بود
فهرست ده‌تایی از نو با کد سنجیده شد. سه ردیف اصلاً گَپ نبودند:
| روت | چرا مثبت کاذب بود |
|---|---|
| `/api/v1/appointments/user` | `findByUser($user)` یعنی `a.user = خودِ کاربر`. نوبت‌های خودِ فرد به‌عنوان بیمار است، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است؛ گِیت‌زدنش رگرسیون بود. |
| `/api/v1/doctor-services` | `findActive()` بدون هیچ فیلترِ محیط — کاتالوگ سراسری، هم‌ردهٔ `specialties` و `tags`. |
| `/api/v1/insurances` | همان. گِیت‌زدنش یک مجوز را با نبودِ مجوزِ دیگری می‌شکست: منشیِ دارای `patients.create` فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گرفت. |
درسِ روشی: خروجیِ پویشِ رفتاری lead است نه یافته — حتی وقتی پویش را خودِ آدیت نوشته باشد. سه
ردیف بالا با «۲۰۰ داد پس نشت است» ثبت شده بودند، بی‌آنکه کوئریِ پشتشان خوانده شود.
هفت ردیف باقی‌مانده گَپ واقعی بودند و گِیت گرفتند. ولی چون هر سه کنترلر بی‌گِیت بودند و پویشِ
قبلی فقط `GET` بدون path parameter را می‌دید، **کلِ روت‌های نوشتنی‌شان هم باز بود**:
```
sec (بدون هیچ مجوزی) | POST /api/v1/billing/invoices | صورتحساب می‌ساخت
sec (بدون هیچ مجوزی) | POST /api/v1/billing/claims/{uuid}/pay | مطالبه را «پرداخت‌شده» می‌کرد
sec (بدون هیچ مجوزی) | POST /api/v1/my/appointment | نوبت ثبت می‌کرد
sec (بدون هیچ مجوزی) | GET /api/v1/my/appointment/patient-lookup | نام و کد ملی هر بیمار را می‌گرفت
```
پس محدوده به همهٔ روت‌های آن سه کنترلر باز شد: ۱۲ روت `BillingController` و ۵ روت
`MyAppointmentsController`.
**نگاشت مجوزها:**
- Billing خواندنی → `payments.view` · ساخت → `payments.create` · `finalize` و `transitionClaim``payments.update`
- `my/appointments`, `today-stats`, `my/clinic-doctors``appointments.view`
- `POST /my/appointment` و `patient-lookup``appointments.create`
`patient-lookup` عمداً `appointments.create` گرفت نه `patients.view`: کامنت خودِ متد می‌گوید
عمداً از گیتِ پروندهٔ بیمار جدا شده تا ثبت نوبت مستقل از اشتراک کار کند. با `patients.view`
همان قابلیت می‌شکست.
**مکانیزم:** `App\Shared\Controller\PermissionGateTrait` از دلِ `ResourcePermissionTrait` بیرون
کشیده شد؛ trait قبلی حالا رویش سوار است و فقط منبعِ پیش‌فرض و استثنای نوبت‌دهی‌اش را نگه داشته.
هیچ voter یا checker تازه‌ای ساخته نشد.
**یک تغییر رفتار که باید بدانی:** منشیِ **بدون رابطهٔ فعال** روی `/my/appointments` دیگر لیست
خالی نمی‌گیرد، `403` می‌گیرد. `SecretaryAccessChecker::can()` برای او `false` می‌دهد — همان
fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش» با «اجازه نداری» یکی نیست.
`SecretaryAppointmentScopeTest::testSecretaryWithNoAssignmentIsDenied` همین را تثبیت می‌کند.
## یافتهٔ ۹ 🟧 HIGH — `AppointmentPlanController` بدون گِیت
**۱. فایل:** `src/Appointment/Plan/Controller/AppointmentPlanController.php`
**۲. شرح:** دوقلوی یافتهٔ ۱. همان `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح‌کلاس، و همان
`requireItem()` — کپیِ کلمه‌به‌کلمهٔ تابعی که آدیت دیروز در `TreatmentProtocolController`
ناکافی خواند. آدیت قلِ اول را بست و دوقلویش را ندید، چون `GET .../segments` پارامتر دارد و
پویشِ `GET` روت‌های پارامتردار را رد می‌کرد.
**۳. ریسک:** منشیِ `services:false` بخش‌بندیِ هر سرویسِ محیط را می‌خواند و با `PUT` کاملاً
بازنویسی می‌کرد. بخش‌بندی طولِ نوبت و منابعِ لازم را تعیین می‌کند، پس بازنویسی‌اش یعنی
به‌هم‌ریختنِ برنامهٔ همهٔ نوبت‌های آن سرویس.
**۴. رفع:** `show``services.view`، `replace``services.update`، `preview`
`services.view` **یا** `appointments.view`. «یا» عمدی است: `preview` ورودیِ فرمِ ثبت نوبت است،
پس قرینهٔ `denyUnlessGrantedForBooking`.
**۵. مستندات:** `docs/api/appointment-plan.md` بخش «مجوزها».
## یافتهٔ ۱۰ 🟦 LOW — گِیت بعد از واکشی در ۳۴ روت نوشتنی
**۱. کشف چطور شد:** حین نوشتنِ پویشِ روت‌های نوشتنی. قاعدهٔ اولیه «منشیِ بی‌مجوز فقط `403`»
بود؛ ۷۵ روت قرمز شد. پس از خواندنِ کد معلوم شد بیشترشان گِیت **دارند**، ولی بعد از
`findByUuid()` می‌نشیند، پس uuidِ ناموجود اول `404` می‌گیرد.
**۲. ریسک:** enumeration oracle. کاربرِ بی‌مجوز از تفاوت `403`/`404` می‌فهمد کدام uuid در این
محیط وجود دارد. همان چیزی که یافتهٔ ۱ عمداً با «گیت پیش از `requireItem`» بست، ولی هیچ چیزی
در بقیهٔ کد اجبارش نمی‌کرد.
**۳. چرا رفع نشد:** حدود ۱۵ تای این چک‌ها **object-scoped** هستند، نه resource-scoped —
مثلاً `ClinicController::update` چکش `permChecker->can($user, $clinic, …)` است و بدون `$clinic`
اصلاً قابل صدا زدن نیست. بالا بردنشان یعنی افزودنِ یک پیش‌چکِ منبع‌سطحِ تازه به هر کدام، نه
جابه‌جا کردن دو خط. این طراحی است، نه reorder، و در محدودهٔ این جلسه نبود.
**۴. فهرست کامل** — گروه‌بندی بر اساس کنترلر:
- `AppointmentController``updateStatus`, `confirm`, `update`, `serviceReschedule`
- `AppointmentSettingsController``createSchedule`, `updateSchedule`, `deleteSchedule`,
`createOverride`, `updateOverride`, `deleteOverride`, `createHoliday`, `updateHoliday`,
`deleteHoliday`
- `ClinicController``update`, `detachDoctor`, `createAddress`, `updateAddress`, `deleteAddress`
- `ClinicDoctorPermissionController``updatePermissions`
- `ClinicInvitationController``inviteDoctor`, `resendInvitation`, `changeInvitationStatus`,
`deleteInvitation`
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`
- `ResourceBlockController``create`, `delete`
- `RepresentationController``update`
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`
سه روتِ `AppointmentSettingsController` و `SecretaryController::create` پارامتر مسیری ندارند و
هدفشان از بدنه می‌آید؛ رفتارشان همان است.
**۵. مهار:** `ApiLeastPrivilegeTest::testNoApiWriteRouteSkipsItsPermissionGate` قاعده‌اش دو
سطحی است — روتِ **بدون** path parameter باید `403` بدهد، روتِ پارامتردار `403` یا `404`. پس
روت‌های این فهرست عبور می‌کنند ولی هر روتِ نوشتنیِ تازه‌ای که `2xx` یا `422` بدهد قرمز می‌شود.
## پویش روت‌های نوشتنی — نتیجه
۷۵ روتِ `POST`/`PUT`/`PATCH`/`DELETE` با منشیِ بدونِ هیچ مجوزی زده شد. پس از triage:
- **۳۴** عمداً باز — لاگین و ثبت‌نام و OTP، کال‌بک درگاه، امتیاز و کامنت بیمار، پروفایل و
کیف‌پول خودِ کاربر، و کلِ جریان رزرو عمومی. همه با دلیل در `ALLOWED_WRITE`.
- **۲** عمداً `409` — حذف سرویس و بخش سرویس اصلاً ممکن نیست، چون نوبت و فاکتور به سرویس ارجاع
دارند.
- **۳۴** یافتهٔ ۱۰ بالا.
- **۵** گَپِ واقعی که همین جلسه بسته شد — روت‌های نوشتنیِ Billing و MyAppointments و
`service_segments_replace`.
## پرسنل tenant دوم — پوشش بسته شد
`tests/Staff/StaffCrossTenantTest.php` هر دو محیط را در fixture می‌سازد. چهار تست: شاهد مثبت
(پرسنل روی جلسهٔ محیط خودش `200`)، جلسهٔ محیط دیگر روی `GET`/`start`/`finish`، ناحیهٔ جلسهٔ
محیط دیگر روی `start`/`complete`/`skip`/`reopen`، و تستِ مرزی که uuidِ ناموجود و uuidِ محیطِ
دیگر باید **دقیقاً یک پاسخ** بدهند. همه `404 / ERR_NOT_FOUND_001` — بدون نشت و بدون oracle.
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed می‌رود و
هیچ‌وقت خودکار اجرا نمی‌شود.
## تغییرات این جلسه
```
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک (جدید)
src/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php ۱۲ روت → payments.view/create/update
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.view/create
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE؛ KNOWN_GAPS خالی شد
tests/Staff/StaffCrossTenantTest.php IDOR بین‌محیطیِ پرسنل (جدید)
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بی‌رابطه: خالی → ۴۰۳
docs/api/billing.md · appointment.md · appointment-plan.md مجوز هر روت
```
---