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 |