feat(clinic-doctor): full permission coverage + enforcement, parity with secretary
The clinic-member-doctor permission system (ClinicDoctorPermission) lagged the secretary system: only 6 resources, enforced in ~6 places, dead toggles (services.update never checked), and a sidebar showing just appointments+patients. Bring it to parity so a clinic owner can control exactly what each member doctor does — while an independent doctor stays completely unrestricted. Coverage: add insurances, addresses, inventory, tags, staff, discounts, sms to ClinicDoctorPermission::DEFAULT_PERMISSIONS + DoctorPermissionsModal (subscription/clinic_doctors stay owner-only by design). New App\Clinic\Security\ClinicDoctorAccessChecker (parallel to SecretaryAccessChecker): - denyUnlessGranted(user, resource, action): 403 only for a clinic-member doctor in the clinic context; owner/admin/secretary/independent-doctor pass through. - memberClinicId(user): resolves the member doctor to the CLINIC's tenant so the role-based controllers (Inventory/Tag/Staff/Discount/Sms) stop showing them their personal tenant in clinic context. Enforcement wired into 10 controllers alongside the existing secretary gates: ClinicService (services), Insurance (insurances), Patient (patients+payments), Staff, Discount, Inventory, Tag, SmsWallet, Payment, PaymentMethod. Frontend: the guest-doctor sidebar branch now exposes every permitted resource (gated by can()) plus a «تنظیمات» entry; both settings navs (PurchaseSubscription Sidebar + SETTINGS_MENU) are now permission-filtered for a scope=clinic doctor, not just secretaries; my-payments route gets the missing payments permission. CRUD-button gating already applies (usePermissions is role-agnostic). Tests: ClinicDoctorPermissionEnforcementTest (member denied/allowed + independent-doctor-unrestricted); guest-doctor sidebar gating. Backend 375 pass, frontend 503 pass. docs/api/clinic.md updated with the full resource set + enforcement notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# پوشش و اعمالِ کاملِ مجوز پزشکِ عضو کلینیک (ClinicDoctorPermission) — همتای کار منشی
|
||||
|
||||
## زمینه
|
||||
|
||||
برای منشی (`DoctorSecretary.permission`) یک سیستم مجوزِ کامل ساختیم: ۱۵ منبع، enforcement در بکاند
|
||||
(`SecretaryAccessChecker::denyUnlessGranted`)، گِیت سایدبار/Route با `usePermissions().can`، و گِیتِ
|
||||
دکمههای CRUD در همهٔ صفحات. سیستمِ **موازی** برای «پزشکی که به کلینیک اضافه میشود» (پزشکِ مهمانِ
|
||||
عضو کلینیک) `ClinicDoctorPermission` است — اما ناقص مانده و با کار منشی همتراز نیست:
|
||||
|
||||
- فقط **۶ منبع** دارد: `appointments`, `appointment_settings`, `patients`, `payments`, `services`, `clinic_info`
|
||||
(`src/Clinic/Entity/ClinicDoctorPermission.php:21`) در برابر ۱۵ منبعِ منشی.
|
||||
- enforcement فقط در **۶ نقطه** صدا زده میشود: `AppointmentAccessChecker`, `AppointmentSettingsController`,
|
||||
`DashboardController`, `PatientRecordScopeResolver`, `ClinicController`, `InsuranceController`.
|
||||
- سایدبارِ پزشکِ مهمان فقط `appointments` و `patients` را نشان میدهد؛ بقیهٔ منابع (حتی `services`/
|
||||
`appointment_settings` که toggle دارند) در منو نیستند.
|
||||
- چند toggle **مردهاند**: `services.update` هیچجا enforce نمیشود، و بیمه با checkerِ منشی گِیت شده نه
|
||||
ClinicDoctorPermission.
|
||||
|
||||
هدف: `ClinicDoctorPermission` را به همان کاملیِ سیستم منشی برسانیم — برای **دو حالت** «پزشک مستقل»
|
||||
(بدون context کلینیک، آزاد) و «پزشک عضو کلینیک» (`scope=clinic`، محدود به مجوزهای کلینیک).
|
||||
|
||||
> پیش از هر گرِپ/خواندن: `graphify query "..."`. بعد از هر تغییر کد (پس از commit): `graphify update .`.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
سه شکاف، مطابق همان الگوی منشی:
|
||||
|
||||
1. **پوشش (Coverage):** هر صفحه/عملیاتی که پزشکِ عضو کلینیک به آن میرسد باید toggle مجوز داشته باشد و
|
||||
در فرم «مدیریت دسترسیهای پزشک» نمایش داده شود. حداقل بررسی: `insurances`, `inventory`, `tags`,
|
||||
`staff`, `discounts`, `sms` — هرکدام که پزشک عضو باید کنترل شود.
|
||||
2. **اعمال (Enforcement):** هر منبع باید در نقطهٔ درستِ بکاند با `ClinicDoctorPermissionChecker::can(
|
||||
$user, $clinic, $resource, $action)` گِیت شود (۴۰۳ اگر مجوز نبود)، نه فقط توگل ذخیرهشود. توگلهای
|
||||
مردهٔ فعلی (`services.update`، `payments.*`، …) واقعاً enforce شوند.
|
||||
3. **پنل (Frontend):** سایدبارِ پزشکِ مهمان همهٔ منابعِ مجاز را با `can()` نشان دهد؛ Routeها با
|
||||
`blockClinicScope` + `permission` گِیت شوند؛ دکمههای CRUD طبق مجوز پنهان شوند (بخش زیادی از این با
|
||||
sweepِ منشی که role-agnostic بود از قبل کار میکند — فقط تأیید و تکمیل).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش | کار |
|
||||
|------|-----|-----|
|
||||
| `src/Clinic/Entity/ClinicDoctorPermission.php` | `DEFAULT_PERMISSIONS` + `mergePermissions` (valid-resource whitelist) | افزودن منابع جدید |
|
||||
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | checker (`can($user,$clinic,$resource,$action)`) | نقطهٔ واحد enforcement پزشک عضو |
|
||||
| `src/ClinicService/Controller/ClinicServiceController.php` | خدمات — **هیچ چک ClinicDoctorPermission ندارد** | گارد `services` برای پزشک عضو |
|
||||
| `src/Insurance/Controller/InsuranceController.php` | نوشتنها با `secretaryAccess` گِیت شده، نه پزشک عضو | گارد `insurances` برای پزشک عضو |
|
||||
| `src/Inventory/Controller/InventoryController.php` · `src/Tag/Controller/TenantTagController.php` · `src/Staff/Controller/StaffController.php` · `src/Discount/Controller/DiscountController.php` · `src/Sms/Controller/SmsWalletController.php` | فقط `secretaryAccess` دارند | گارد پزشک عضو اگر باید کنترل شود |
|
||||
| `src/Patient/Controller/PatientController.php` · `PatientRecordScopeResolver.php` | scope + نوشتنها | تأیید گارد `patients` پزشک عضو |
|
||||
| `assets/admin/components/ui/DoctorPermissionsModal.tsx` | `RESOURCE_LABELS` (۶ منبع) | افزودن منابع جدید |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` (بلوک `primaryRole === "doctor" && scope === "clinic"`, L71) | منوی پزشک مهمان — فقط appointments/patients | افزودن بقیهٔ منابع با `can()` |
|
||||
| `assets/admin/App.tsx` | Routeها با `blockClinicScope` + `permission` | تأیید/تکمیل گِیت پزشک مهمان |
|
||||
| `src/Auth/Controller/AuthController.php` (`contextPermissions`, L~778؛ `buildAvailableContexts`) | تزریق permissions پزشک مهمان به context | بدون تغییر ساختار |
|
||||
| `docs/api/clinic.md` | مستندات مجوز پزشک کلینیک | بهروزرسانی |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
منبع حقیقتِ مجوز پزشک عضو:
|
||||
|
||||
```php
|
||||
// src/Clinic/Entity/ClinicDoctorPermission.php:21
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'appointments' => ['view' => true, 'create' => true, 'cancel' => true, 'update_status' => true],
|
||||
'appointment_settings' => ['view' => true, 'update' => true],
|
||||
'patients' => ['view' => true, 'create' => true, 'update' => true, 'delete' => false],
|
||||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
],
|
||||
];
|
||||
|
||||
// mergePermissions فقط منابعِ داخل DEFAULT_PERMISSIONS را میپذیرد (منبع ناشناس رد میشود):
|
||||
// L98: if (!is_array($actions) || !isset(self::DEFAULT_PERMISSIONS['resources'][$resource])) { continue; }
|
||||
// can(): L88: return (bool) ($this->permissions['resources'][$resource][$action] ?? false);
|
||||
```
|
||||
|
||||
سایدبارِ پزشک مهمان فقط ۲ منبع را گِیت میکند:
|
||||
|
||||
```tsx
|
||||
// assets/admin/components/layout/Sidebar.tsx — بلوک doctor + scope=clinic (L71)
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
const items = [{ to: "/admin/dashboard", ... }];
|
||||
if (can("appointments", "view")) { items.push({ to: "/admin/appointments", ... }); }
|
||||
if (can("patients", "view")) { items.push({ to: "/admin/patients", ... }); }
|
||||
return [{ label: "عمومی", items }]; // ← services / appointment_settings / payments / … نیستند
|
||||
}
|
||||
```
|
||||
|
||||
توگل مردهٔ services (پزشک عضو با `services.update=false` هم میتواند ویرایش کند):
|
||||
|
||||
```php
|
||||
// src/ClinicService/Controller/ClinicServiceController.php
|
||||
// هیچ ClinicDoctorPermissionChecker صدا زده نمیشود؛ فقط SecretaryAccessChecker (مخصوص منشی).
|
||||
// resolveEntity برای پزشکِ عضو، کلینیک را برمیگرداند و بدون هیچ گِیتی اجازهٔ نوشتن میدهد.
|
||||
```
|
||||
|
||||
بیمه با checkerِ اشتباه:
|
||||
|
||||
```php
|
||||
// src/Insurance/Controller/InsuranceController.php:307
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update'); // ← فقط منشی؛
|
||||
// برای پزشک عضو canOrNonSecretary → true → گِیت نمیشود. باید permChecker->can($user,$clinic,'insurances',$action) هم باشد.
|
||||
// (خط ۷۶ همین کنترلر از permChecker با منبع 'services' استفاده میکند — الگوی درست همانجاست.)
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
> هر منبع همزمان در سهگانه اضافه شود وگرنه merge/نمایش میشکند:
|
||||
> (۱) `ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources']`،
|
||||
> (۲) `DoctorPermissionsModal.tsx::RESOURCE_LABELS`،
|
||||
> (۳) هر جای دیگری که منابع را فهرست میکند (contextPermissions خودکار از entity میخواند، دستی نیست).
|
||||
> بعد از تغییر endpoint: `docs/api/*` همان session. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
|
||||
|
||||
### ۰. ممیزی و تصمیم (اول این)
|
||||
|
||||
1. برای هر منبعی که سیستم منشی دارد ولی ClinicDoctorPermission ندارد
|
||||
(`insurances, inventory, tags, staff, discounts, sms`)، مشخص کن آیا پزشکِ عضو کلینیک به آن صفحه
|
||||
دسترسی دارد (از سایدبار/Route/endpoint). جدول بساز:
|
||||
**منبع | پزشک عضو میرسد؟ | toggle در ClinicDoctorPermission؟ | enforce با permChecker؟ | sidebar؟ | Route؟**.
|
||||
2. تصمیم بگیر کدامها باید toggle بگیرند (مثلاً بیمه/انبار/تگ/خدمات منطقیاند؛ خرید اشتراک و مدیریت
|
||||
پزشکان کلینیک ذاتاً مالکاند و برای پزشکِ عضو نباید باشند). دلیل هر تصمیم را در پرامپت بنویس.
|
||||
|
||||
### ۱. افزودن منابع مجوز (Coverage)
|
||||
|
||||
برای هر منبعِ تصمیمگرفتهشده، در `DEFAULT_PERMISSIONS['resources']` و `RESOURCE_LABELS` اضافه کن.
|
||||
پیشفرضِ منطقی برای پزشکِ عضو: `view=true` و نوشتنها `false` (مثل الگوی فعلی `services`/`clinic_info`).
|
||||
|
||||
```php
|
||||
// نمونه افزودن به ClinicDoctorPermission::DEFAULT_PERMISSIONS
|
||||
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'inventory' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
// ... طبق تصمیم وظیفهٔ ۰
|
||||
```
|
||||
|
||||
> چون `mergePermissions` whitelist دارد، منابعِ جدید حتماً باید در `DEFAULT_PERMISSIONS` باشند تا از فرم
|
||||
> ذخیره شوند. `contextPermissions` خودکار از entity میخواند؛ نیازی به تغییر دستی نیست.
|
||||
|
||||
### ۲. اعمال در بکاند (Enforcement)
|
||||
|
||||
الگو را از `InsuranceController.php:76` بگیر (`$this->permChecker->can($user, $clinic, $resource, $action)`).
|
||||
برای مسیرهای چند-نقشه، فقط پزشکِ عضو کلینیک را محدود کن (سایر نقشها بدون تغییر). نکات:
|
||||
|
||||
- **services:** در `ClinicServiceController` برای هر اکشن، اگر کاربر پزشکِ عضو کلینیک است، با
|
||||
`permChecker->can($user, $clinic, 'services', $action)` گِیت کن (view/update). کلینیک را از محیطِ فعال
|
||||
حل کن (همان `EntityContextResolver` که الان استفاده میشود کلینیک را میدهد؛ فقط چکِ مجوز اضافه کن).
|
||||
- **insurances:** در `InsuranceController` نوشتنها، علاوهبر `secretaryAccess`، `permChecker->can(...,'insurances',$action)`
|
||||
را هم برای پزشکِ عضو اعمال کن (اگر `insurances` toggle گرفت).
|
||||
- **payments / patients:** تأیید کن نوشتنهای پزشکِ عضو با `permChecker` گِیت میشوند (patients scope از
|
||||
`PatientRecordScopeResolver` میآید؛ نوشتنها گارد جدا لازم دارند).
|
||||
- **inventory/tags/staff/discounts/sms:** اگر toggle گرفتند، در همان کنترلرها برای پزشکِ عضو گارد بگذار
|
||||
(این کنترلرها الان فقط `secretaryAccess` دارند).
|
||||
- نبودِ مجوز → `403 ERR_FORBIDDEN_001` استاندارد (مثل `SecretaryAccessChecker`).
|
||||
|
||||
> **SOLID:** اگر منطقِ «آیا این کاربر پزشکِ عضوِ همین کلینیک است و مجوز دارد؟» در چند کنترلر تکرار شد،
|
||||
> یک متدِ کمکی روی `ClinicDoctorPermissionChecker` بساز (قرینهٔ `SecretaryAccessChecker::denyUnlessGranted`)
|
||||
> تا تکرار نشود. الگوی موجود منشی را دنبال کن.
|
||||
|
||||
### ۳. اعمال در پنل (Frontend)
|
||||
|
||||
1. **Sidebar** (`Sidebar.tsx`، بلوک `doctor && scope=clinic`, L71): برای هر منبعِ مجاز آیتم منو اضافه کن،
|
||||
هرکدام با `can(resource,'view')` — services، تنظیمات نوبتدهی، پرداختها، بیمه، انبار، تگ، … دقیقاً
|
||||
مثل بلوکِ `secretary`. منوی مطبِ شخصیِ پزشکِ مستقل (`primaryRole==='doctor'` بدون scope کلینیک) نباید
|
||||
تغییر کند — آنجا مالک است و آزاد.
|
||||
2. **Route** (`App.tsx`): مطمئن شو هر صفحهٔ پزشکِ عضو با `blockClinicScope permission={[resource,'view']}`
|
||||
گِیت شده (این الگو از قبل برای پزشکِ مهمان طراحی شده؛ فقط منابعِ جدید را پوشش بده).
|
||||
3. **CRUD buttons:** از قبل با `usePermissions().can` گِیت شدهاند (sweepِ منشی role-agnostic بود، پس
|
||||
برای پزشکِ عضو هم کار میکند). فقط تأیید کن صفحاتِ منابعِ جدید هم پوشش دارند.
|
||||
|
||||
### ۴. تستها و ماتریس نهایی
|
||||
|
||||
- بکاند (`ddev exec php bin/phpunit`): برای هر منبع، پزشکِ عضوِ مجاز (۲۰۰) و غیرمجاز (۴۰۳)؛ و تأیید
|
||||
اینکه **پزشک مستقل** (بدون context کلینیک) هیچ محدودیتی نمیگیرد. یک `ClinicDoctorPermissionEnforcementTest`
|
||||
قرینهٔ `SecretaryResourceEnforcementTest` بساز.
|
||||
- فرانتاند (`yarn test` روی host با `npx vitest`): سایدبارِ پزشکِ مهمان با مجوزهای مختلف فقط آیتمهای
|
||||
مجاز را رندر کند.
|
||||
- جدول نهایی مثل کار منشی:
|
||||
|
||||
```
|
||||
Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested
|
||||
appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
|
||||
appointment_settings | ✓ | + | ✓ | ✓ | ✓ | ?
|
||||
patients | ✓ | ✓ | ✓ | ? | ✓ | ?
|
||||
payments | ✓ | + | + | + | ✓ | ?
|
||||
services | ✓ | + | + | + | ✓ | ?
|
||||
clinic_info | ✓ | + | + | + | ✓ | ?
|
||||
insurances | + | + | + | + | ✓ | ?
|
||||
inventory/tags/… | + | + | + | + | ✓ | ?
|
||||
```
|
||||
(`+` = این پرامپت باید بسازد/تکمیل کند.)
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **منبع حقیقتِ پزشکِ عضو = `ClinicDoctorPermission.permissions`** (نه `DoctorSecretary` که مالِ منشی است).
|
||||
هر دو سیستم جدا هستند و نباید قاطی شوند.
|
||||
- **پزشکِ مستقل مطلقاً محدود نشود:** `usePermissions().can` بدون context آزاد است؛ در بکاند هم گِیت فقط
|
||||
وقتی اعمال شود که کاربر پزشکِ عضوِ همان کلینیک باشد (`context.scope==='clinic'` / کلینیک از محیطِ فعال).
|
||||
- `mergePermissions` whitelist دارد → منبع جدید حتماً در `DEFAULT_PERMISSIONS`.
|
||||
- الگوی enforcement را از `InsuranceController::76` و کارِ منشی (`SecretaryAccessChecker`) بگیر؛ SOLID،
|
||||
بدون تکرار.
|
||||
- تاریخها Unix timestamp؛ پاسخها `$this->success()/$this->error()`؛ لیستها array-hydration.
|
||||
- رشتههای UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفتوگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیادهسازی.
|
||||
- بعد از تغییر endpointها: `docs/api/clinic.md` (+ هر domain متأثر) بهروز شود.
|
||||
@@ -229,7 +229,7 @@ export default function App() {
|
||||
|
||||
{/* دکتر / منشی / کلینیک */}
|
||||
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></RoleRoute>} />
|
||||
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentsPage /></RoleRoute>} />
|
||||
<Route path="my-payments" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['payments', 'view']}><MyPaymentsPage /></RoleRoute>} />
|
||||
<Route path="my-payments/:patientUuid" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPaymentDetailPage /></RoleRoute>} />
|
||||
|
||||
<Route path="patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope permission={['patients', 'view']}><PatientsListPage /></RoleRoute>} />
|
||||
|
||||
@@ -51,13 +51,15 @@ export default function PurchaseSubscriptionSidebar({ active }: { active: string
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
const scope = useAuthStore((s) => s.context?.scope);
|
||||
const { can } = usePermissions();
|
||||
// منشی، و پزشکِ عضوِ کلینیک (scope=clinic) هر دو محدود-به-مجوزند؛ پزشکِ مستقل و
|
||||
// مالک آزادند و با فیلترِ نقشیِ معمول کار میکنند.
|
||||
const permissionRestricted = primaryRole === 'secretary' || (primaryRole === 'doctor' && scope === 'clinic');
|
||||
const items = useMemo(
|
||||
() => NAV_ITEMS
|
||||
// منشی: بر اساس مجوز، نه نقش. آیتمِ بدونِ perm/alwaysOpen پنهان است. برای
|
||||
// آیتمهایی که واریانتِ نقشی دارند (مثلِ «نوبتدهی» با doctor vs clinic)، با
|
||||
// scope منشی تطبیق داده میشود تا واریانتِ درست انتخاب شود.
|
||||
// آیتمِ بدونِ perm/alwaysOpen برای کاربرِ محدود پنهان است. برای آیتمهایی که
|
||||
// واریانتِ نقشی دارند (مثلِ «نوبتدهی» با doctor vs clinic)، با scope تطبیق داده میشود.
|
||||
.filter((i) => {
|
||||
if (primaryRole !== 'secretary') {
|
||||
if (!permissionRestricted) {
|
||||
return !i.roles || (primaryRole != null && i.roles.includes(primaryRole));
|
||||
}
|
||||
if (i.alwaysOpen) return true;
|
||||
|
||||
@@ -48,11 +48,13 @@ export function menuForRole(
|
||||
can?: (resource: string, action: string) => boolean,
|
||||
scope?: string | null,
|
||||
): SettingsMenuItem[] {
|
||||
// منشی و پزشکِ عضوِ کلینیک (scope=clinic) محدود-به-مجوزند؛ بقیه با فیلترِ نقشی.
|
||||
const permissionRestricted = role === 'secretary' || (role === 'doctor' && scope === 'clinic');
|
||||
return SETTINGS_MENU.filter((i) => {
|
||||
if (role === 'secretary') {
|
||||
if (permissionRestricted) {
|
||||
if (i.alwaysOpen) return true;
|
||||
if (!i.perm || !can || !can(i.perm[0], i.perm[1])) return false;
|
||||
// واریانتِ نقشی (نوبتدهی/پزشکان کلینیک) را با scope منشی تطبیق بده.
|
||||
// واریانتِ نقشی (نوبتدهی/پزشکان کلینیک) را با scope تطبیق بده.
|
||||
if (i.roles) return i.roles.includes(scope === 'clinic' ? 'clinic' : 'doctor');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -142,3 +142,31 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () =>
|
||||
expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => {
|
||||
const setMemberDoctor = (resources: Record<string, Record<string, boolean>>) =>
|
||||
useAuthStore.setState({
|
||||
primaryRole: "doctor",
|
||||
dbUuid: "c1",
|
||||
userName: "دکتر عضو",
|
||||
availableContexts: [],
|
||||
context: { scope: "clinic", permissions: { resources } },
|
||||
} as any);
|
||||
|
||||
it("پزشکِ عضو فقط منابعِ مجاز را در سایدبار میبیند + «تنظیمات» همیشه", () => {
|
||||
setMemberDoctor({ appointments: { view: true }, services: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.getByText("نوبتهای من")).toBeInTheDocument();
|
||||
expect(screen.getByText("سرویس ها").closest("a")).toHaveAttribute("href", "/admin/clinic-services");
|
||||
expect(screen.getByRole("link", { name: "تنظیمات" })).toHaveAttribute("href", "/admin/account-settings");
|
||||
// بدون مجوز → پنهان
|
||||
expect(screen.queryByText("انبارداری")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("پرداختها")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("پزشکِ عضو بدونِ مجوزِ نوبت، آیتم نوبت را نمیبیند", () => {
|
||||
setMemberDoctor({ services: { view: true } });
|
||||
renderWithProviders(<Sidebar />, { route: "/admin/dashboard" });
|
||||
expect(screen.queryByText("نوبتهای من")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,9 +69,7 @@ function buildSections(
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): منو از روی مجوزهایی که کلینیک
|
||||
// برایش تعیین کرده ساخته میشود، نه بهصورت hardcode.
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
const items: SectionItem[] = [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
];
|
||||
const items: SectionItem[] = [];
|
||||
if (can("appointments", "view")) {
|
||||
items.push({
|
||||
to: "/admin/appointments",
|
||||
@@ -88,8 +86,36 @@ function buildSections(
|
||||
feature: "patient_records",
|
||||
});
|
||||
}
|
||||
if (can("payments", "view")) {
|
||||
items.push({ to: "/admin/my-payments", icon: CreditCardIcon, label: "پرداختها" });
|
||||
}
|
||||
if (can("insurances", "view")) {
|
||||
items.push(
|
||||
{ to: "/admin/insurance-pricing", icon: ShieldCheckIcon, label: "قیمتگذاری بیمه", feature: "insurance" },
|
||||
{ to: "/admin/claims", icon: DocumentTextIcon, label: "مطالبات بیمه", feature: "insurance" },
|
||||
);
|
||||
}
|
||||
if (can("services", "view")) {
|
||||
items.push({ to: "/admin/clinic-services", icon: WrenchScrewdriverIcon, label: "سرویس ها" });
|
||||
}
|
||||
if (can("inventory", "view")) {
|
||||
items.push({ to: "/admin/inventory", icon: ArchiveBoxIcon, label: "انبارداری" });
|
||||
}
|
||||
|
||||
return [{ label: "عمومی", items }];
|
||||
// زیرمنوهای «تنظیمات» (services/tags/staff/discounts/sms/appointment_settings)
|
||||
// مثل پزشکِ مستقل داخل صفحهٔ تنظیماتاند، نه سایدبار اصلی؛ منویِ کناریِ آن
|
||||
// صفحه بر اساس مجوز فیلتر میشود.
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
|
||||
},
|
||||
{ label: "مدیریت", items },
|
||||
{
|
||||
label: "تنظیمات",
|
||||
items: [{ to: "/admin/account-settings", icon: Cog6ToothIcon, label: "تنظیمات" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "admin") {
|
||||
|
||||
@@ -45,6 +45,34 @@ const RESOURCE_LABELS: Record<string, { label: string; actions: Record<string, s
|
||||
label: 'اطلاعات کلینیک',
|
||||
actions: { view: 'مشاهده', update: 'ویرایش' },
|
||||
},
|
||||
insurances: {
|
||||
label: 'بیمهها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
addresses: {
|
||||
label: 'آدرسها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
inventory: {
|
||||
label: 'انبارداری',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
tags: {
|
||||
label: 'تگها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
staff: {
|
||||
label: 'پرسنل',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
discounts: {
|
||||
label: 'تخفیفها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
sms: {
|
||||
label: 'پیامکها',
|
||||
actions: { view: 'مشاهده', create: 'ایجاد', update: 'ویرایش', delete: 'حذف' },
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_COLUMNS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
||||
|
||||
+16
-7
@@ -382,19 +382,28 @@ The envelope is always returned in full (`{version, resources}`); it is never fl
|
||||
{
|
||||
"version": 1,
|
||||
"resources": {
|
||||
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
|
||||
"appointment_settings": { "view": true, "update": true },
|
||||
"patients": { "view": true, "create": true, "update": true, "delete": false },
|
||||
"payments": { "view": true, "create": false, "update": false, "delete": false },
|
||||
"services": { "view": true, "update": false },
|
||||
"clinic_info": { "view": true, "update": false }
|
||||
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
|
||||
"appointment_settings": { "view": true, "update": true },
|
||||
"patients": { "view": true, "create": true, "update": true, "delete": false },
|
||||
"payments": { "view": true, "create": false, "update": false, "delete": false },
|
||||
"services": { "view": true, "update": false },
|
||||
"clinic_info": { "view": true, "update": false },
|
||||
"insurances": { "view": true, "create": false, "update": false, "delete": false },
|
||||
"addresses": { "view": true, "create": false, "update": false, "delete": false },
|
||||
"inventory": { "view": false, "create": false, "update": false, "delete": false },
|
||||
"tags": { "view": false, "create": false, "update": false, "delete": false },
|
||||
"staff": { "view": false, "create": false, "update": false, "delete": false },
|
||||
"discounts": { "view": false, "create": false, "update": false, "delete": false },
|
||||
"sms": { "view": false, "create": false, "update": false, "delete": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`active: false` revokes everything at once regardless of the individual flags. The clinic owner and `ROLE_ADMIN` bypass all checks and can never be locked out.
|
||||
|
||||
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys.
|
||||
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys. `subscription` و `clinic_doctors` عمداً منبع نیستند — عملیاتِ مالکِ کلینیکاند، نه پزشکِ عضو.
|
||||
|
||||
**اعمال (enforcement):** همهٔ منابع در بکاند enforce میشوند. نقطهٔ واحد `App\Clinic\Security\ClinicDoctorAccessChecker` (`denyUnlessGranted` / `memberClinicId`) که **فقط پزشکِ عضوِ کلینیک در محیطِ فعالِ کلینیک** را محدود میکند؛ مالک/ادمین/منشی/پزشکِ مطبِ شخصی دستنخورده عبور میکنند. کنترلرهایی که tenant را نقشمحور حل میکنند (Inventory/Tag/Staff/Discount/Sms) با `memberClinicId` پزشکِ عضو را به دادهٔ کلینیک میبرند (نه مطبِ شخصی). نبودِ مجوز → `403`. در پنل، سایدبار/Route/دکمههای CRUD با `usePermissions().can` برای محیطِ `scope=clinic` گِیت میشوند.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ class ClinicDoctorPermission
|
||||
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'services' => ['view' => true, 'update' => false],
|
||||
'clinic_info' => ['view' => true, 'update' => false],
|
||||
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'discounts' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
'sms' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Clinic\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* نقطهٔ واحدِ اعمالِ مجوزِ «پزشکِ عضوِ کلینیک» روی endpointهای چند-نقشه — قرینهٔ
|
||||
* SecretaryAccessChecker اما برای ClinicDoctorPermission.
|
||||
*
|
||||
* فقط پزشکی که در محیطِ فعالش (UserActiveContext.db_uuid = کلینیک) عضوِ همان کلینیک
|
||||
* است محدود میشود. بقیه — مالکِ کلینیک، ادمین، منشی، پزشکِ مطب شخصی، یا هر کاربری
|
||||
* که محیطش کلینیک نیست — دستنخورده عبور میکنند (این checker آنها را محدود نمیکند).
|
||||
*/
|
||||
class ClinicDoctorAccessChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicDoctorPermissionChecker $permissions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* idِ کلینیکی که این پزشک در محیطِ فعالش عضوِ آن است — برای کنترلرهایی که
|
||||
* tenant را نقشمحور حل میکنند و بدون این، پزشکِ عضو را به مطبِ شخصیاش میبردند
|
||||
* (نه دادهٔ کلینیک). null اگر محیط کلینیک نیست یا کاربر عضو نیست.
|
||||
*/
|
||||
public function memberClinicId(User $user): ?int
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $clinic->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط پزشکِ عضوِ کلینیک را با ClinicDoctorPermission محدود کن؛ سایر کاربران true.
|
||||
*/
|
||||
public function canOrNonMember(User $user, string $resource, string $action): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
// محیطِ مطب شخصی (db_uuid پزشک است، نه کلینیک) → محدود نمیکنیم.
|
||||
return true;
|
||||
}
|
||||
|
||||
// مالکِ کلینیک هرگز با مجوزهای عضویت قفل نمیشود.
|
||||
if ($clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
// غیرعضو (مثلاً منشی) — این checker مالِ او نیست.
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->permissions->can($user, $clinic, $resource, $action);
|
||||
}
|
||||
|
||||
/** 403 اگر پزشکِ عضو مجاز نباشد؛ سایر کاربران بدون تغییر عبور میکنند. */
|
||||
public function denyUnlessGranted(User $user, string $resource, string $action): void
|
||||
{
|
||||
if (!$this->canOrNonMember($user, $resource, $action)) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\ClinicService\Entity\ServiceItemAuditLog;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\ClinicService\Entity\Tariff;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||
@@ -51,8 +52,16 @@ class ClinicServiceController extends BaseController
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** گِیتِ ترکیبی: منشی + پزشکِ عضوِ کلینیک (هرکدام فقط نقشِ خودش را محدود میکند). */
|
||||
private function denyServices(User $user, string $action): void
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* uuid و عنوان پکیج کالای هر سرویس را به آرایهی خروجی اضافه میکند. پکیجها با یک
|
||||
* کوئری واکشی میشوند تا فهرست سرویسها به N+1 نیفتد.
|
||||
@@ -140,7 +149,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
public function listSections(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -158,7 +167,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section', methods: ['POST'])]
|
||||
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
$this->denyServices($user, 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -178,7 +187,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -203,7 +212,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -223,7 +232,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items', methods: ['GET'])]
|
||||
public function listAllItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
// A user with neither a doctor profile nor a clinic (admin, secretary,
|
||||
@@ -242,7 +251,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
@@ -256,7 +265,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['GET'])]
|
||||
public function getItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -271,7 +280,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])]
|
||||
public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -288,7 +297,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create');
|
||||
$this->denyServices($user, 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
@@ -343,7 +352,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -398,7 +407,7 @@ class ClinicServiceController extends BaseController
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete');
|
||||
$this->denyServices($user, 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -429,7 +438,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listTariffs(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view');
|
||||
$this->denyServices($user, 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
@@ -450,7 +459,7 @@ class ClinicServiceController extends BaseController
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function setTariff(string $uuid, int $year, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update');
|
||||
$this->denyServices($user, 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
|
||||
@@ -27,12 +27,17 @@ class DiscountController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** @return array{0: string, 1: ?int} */
|
||||
private function resolveOwner(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
|
||||
if ($memberClinicId !== null) {
|
||||
return ['clinic', $memberClinicId];
|
||||
}
|
||||
return ['doctor', $this->doctorRepo->findByUser($user)?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
@@ -52,6 +57,7 @@ class DiscountController extends BaseController
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'discounts', 'view');
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
@@ -65,6 +71,7 @@ class DiscountController extends BaseController
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'discounts', 'create');
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
@@ -93,6 +100,7 @@ class DiscountController extends BaseController
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'discounts', 'update');
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
@@ -127,6 +135,7 @@ class DiscountController extends BaseController
|
||||
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'discounts', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'discounts', 'delete');
|
||||
[$ownerType, $ownerId] = $this->resolveOwner($user);
|
||||
if ($ownerId === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
|
||||
@@ -46,6 +46,7 @@ class InsuranceController extends BaseController
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly string $projectDir,
|
||||
) {}
|
||||
|
||||
@@ -255,6 +256,7 @@ class InsuranceController extends BaseController
|
||||
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -305,6 +307,7 @@ class InsuranceController extends BaseController
|
||||
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -375,6 +378,7 @@ class InsuranceController extends BaseController
|
||||
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -406,6 +410,7 @@ class InsuranceController extends BaseController
|
||||
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -442,6 +447,7 @@ class InsuranceController extends BaseController
|
||||
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -488,6 +494,7 @@ class InsuranceController extends BaseController
|
||||
public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -508,6 +515,7 @@ class InsuranceController extends BaseController
|
||||
public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
|
||||
if ($err !== null) {
|
||||
return $err;
|
||||
@@ -539,6 +547,7 @@ class InsuranceController extends BaseController
|
||||
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
|
||||
@@ -585,6 +594,7 @@ class InsuranceController extends BaseController
|
||||
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'create');
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorId = $data['doctor_id'] ?? null;
|
||||
$insuranceId = $data['insurance_id'] ?? null;
|
||||
@@ -626,6 +636,7 @@ class InsuranceController extends BaseController
|
||||
public function showDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'view');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
@@ -643,6 +654,7 @@ class InsuranceController extends BaseController
|
||||
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'update');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
@@ -666,6 +678,7 @@ class InsuranceController extends BaseController
|
||||
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'insurances', 'delete');
|
||||
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
|
||||
if ($doctorInsurance === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
|
||||
|
||||
@@ -39,6 +39,7 @@ class InventoryController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────
|
||||
@@ -47,6 +48,7 @@ class InventoryController extends BaseController
|
||||
public function listItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -64,6 +66,7 @@ class InventoryController extends BaseController
|
||||
public function listCategories(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -85,6 +88,7 @@ class InventoryController extends BaseController
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'create');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -107,6 +111,7 @@ class InventoryController extends BaseController
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'update');
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
@@ -130,6 +135,7 @@ class InventoryController extends BaseController
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'delete');
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
@@ -146,6 +152,7 @@ class InventoryController extends BaseController
|
||||
public function listPackages(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'view');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -161,6 +168,7 @@ class InventoryController extends BaseController
|
||||
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'create');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -183,6 +191,7 @@ class InventoryController extends BaseController
|
||||
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'update');
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
@@ -210,6 +219,7 @@ class InventoryController extends BaseController
|
||||
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'inventory', 'delete');
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
@@ -280,6 +290,10 @@ class InventoryController extends BaseController
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
|
||||
if ($memberClinicId !== null) {
|
||||
return ['clinic', $memberClinicId];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return ['doctor', $doctor?->getId()];
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class PatientController extends BaseController
|
||||
private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo,
|
||||
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -144,6 +145,7 @@ class PatientController extends BaseController
|
||||
public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -180,6 +182,7 @@ class PatientController extends BaseController
|
||||
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -240,6 +243,7 @@ class PatientController extends BaseController
|
||||
public function createCall(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -277,6 +281,7 @@ class PatientController extends BaseController
|
||||
public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$call = $this->callRepo->findByUuid($uuid);
|
||||
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -309,6 +314,7 @@ class PatientController extends BaseController
|
||||
public function createMessage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -335,6 +341,7 @@ class PatientController extends BaseController
|
||||
public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$message = $this->messageRepo->findByUuid($uuid);
|
||||
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -370,6 +377,7 @@ class PatientController extends BaseController
|
||||
public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -393,6 +401,7 @@ class PatientController extends BaseController
|
||||
public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$note = $this->noteRepo->findByUuid($uuid);
|
||||
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -419,6 +428,7 @@ class PatientController extends BaseController
|
||||
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$note = $this->noteRepo->findByUuid($uuid);
|
||||
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -451,6 +461,7 @@ class PatientController extends BaseController
|
||||
public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -476,6 +487,7 @@ class PatientController extends BaseController
|
||||
public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$medical = $this->medicalRepo->findByUuid($uuid);
|
||||
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -507,6 +519,7 @@ class PatientController extends BaseController
|
||||
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$medical = $this->medicalRepo->findByUuid($uuid);
|
||||
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -539,6 +552,7 @@ class PatientController extends BaseController
|
||||
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
|
||||
@@ -562,6 +576,7 @@ class PatientController extends BaseController
|
||||
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$attachment = $this->attachmentRepo->findByUuid($uuid);
|
||||
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
|
||||
@@ -700,6 +715,7 @@ class PatientController extends BaseController
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -794,6 +810,7 @@ class PatientController extends BaseController
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -1004,6 +1021,7 @@ class PatientController extends BaseController
|
||||
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -1048,6 +1066,7 @@ class PatientController extends BaseController
|
||||
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -1124,6 +1143,7 @@ class PatientController extends BaseController
|
||||
public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -1150,6 +1170,7 @@ class PatientController extends BaseController
|
||||
public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
@@ -1172,6 +1193,7 @@ class PatientController extends BaseController
|
||||
public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'delete');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class PaymentController extends BaseController
|
||||
private readonly PaymentManager $paymentManager,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
@@ -509,6 +510,7 @@ class PaymentController extends BaseController
|
||||
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', 'view');
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
|
||||
$status = $request->query->get('status');
|
||||
|
||||
@@ -28,6 +28,7 @@ class PaymentMethodController extends BaseController
|
||||
public function __construct(
|
||||
private readonly PaymentMethodService $service,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** نقش مجاز + مجوز منشی روی منبع payments (روشهای پرداخت زیرمجموعهٔ مالی است). */
|
||||
@@ -35,6 +36,7 @@ class PaymentMethodController extends BaseController
|
||||
{
|
||||
$this->assertRole($user);
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'payments', $action);
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'payments', $action);
|
||||
}
|
||||
|
||||
// ---- Bank accounts -----------------------------------------------------
|
||||
|
||||
@@ -39,6 +39,7 @@ class SmsWalletController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly \App\Config\Repository\SiteConfigRepository $configRepo,
|
||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
@@ -52,6 +53,7 @@ class SmsWalletController extends BaseController
|
||||
public function balance(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -72,6 +74,7 @@ class SmsWalletController extends BaseController
|
||||
public function charge(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'sms', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'sms', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -106,6 +109,7 @@ class SmsWalletController extends BaseController
|
||||
public function logs(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -130,6 +134,7 @@ class SmsWalletController extends BaseController
|
||||
public function getSettings(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'sms', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -155,6 +160,7 @@ class SmsWalletController extends BaseController
|
||||
public function updateSettings(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'sms', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'sms', 'update');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -278,6 +284,10 @@ class SmsWalletController extends BaseController
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
|
||||
if ($memberClinicId !== null) {
|
||||
return ['clinic', $memberClinicId];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ class StaffController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/staff', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'staff', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'staff', 'view');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -49,6 +51,7 @@ class StaffController extends BaseController
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'staff', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'staff', 'create');
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -76,6 +79,7 @@ class StaffController extends BaseController
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'staff', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'staff', 'update');
|
||||
$staff = $this->staffRepo->findByUuid($uuid);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
|
||||
@@ -104,6 +108,7 @@ class StaffController extends BaseController
|
||||
public function toggle(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'staff', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'staff', 'update');
|
||||
$staff = $this->staffRepo->findByUuid($uuid);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
|
||||
@@ -122,6 +127,10 @@ class StaffController extends BaseController
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
|
||||
if ($memberClinicId !== null) {
|
||||
return ['clinic', $memberClinicId];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class TenantTagController extends BaseController
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
/** تگها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. */
|
||||
@@ -65,6 +66,7 @@ class TenantTagController extends BaseController
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'create');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'tags', 'create');
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
@@ -94,6 +96,7 @@ class TenantTagController extends BaseController
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'update');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'tags', 'update');
|
||||
$tag = $this->ownedTag($uuid, $user);
|
||||
if ($tag === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);
|
||||
@@ -127,6 +130,7 @@ class TenantTagController extends BaseController
|
||||
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'delete');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'tags', 'delete');
|
||||
$tag = $this->ownedTag($uuid, $user);
|
||||
if ($tag === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);
|
||||
@@ -154,6 +158,10 @@ class TenantTagController extends BaseController
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$memberClinicId = $this->clinicDoctorAccess->memberClinicId($user);
|
||||
if ($memberClinicId !== null) {
|
||||
return ['clinic', $memberClinicId];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return ['doctor', $doctor?->getId()];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* مجوزهای پزشکِ عضوِ کلینیک (ClinicDoctorPermission) باید مثل منشی در API enforce
|
||||
* شوند: منبعی که کلینیک به پزشکِ عضو نداده ۴۰۳؛ منبعِ دادهشده ۲۰۰. پزشکِ مستقل
|
||||
* (بدون محیطِ کلینیک) هرگز محدود نمیشود.
|
||||
*/
|
||||
class ClinicDoctorPermissionEnforcementTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: ClinicDoctorPermission} */
|
||||
private function makeMemberDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر عضو');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$perm = new ClinicDoctorPermission($clinic, $doctor);
|
||||
$this->em->persist($perm);
|
||||
// محیطِ فعالِ پزشک = کلینیک، تا memberClinicId او را به کلینیک ببرد.
|
||||
$this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid()));
|
||||
|
||||
return [$doctorUser, $perm];
|
||||
}
|
||||
|
||||
public function testInventoryDeniedByDefault(): void
|
||||
{
|
||||
// DEFAULT_PERMISSIONS: inventory.* = false
|
||||
[$doctorUser] = $this->makeMemberDoctor();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInventoryAllowedWhenGranted(): void
|
||||
{
|
||||
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
||||
$perm->mergePermissions(['resources' => ['inventory' => ['view' => true]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStaffDeniedByDefault(): void
|
||||
{
|
||||
[$doctorUser] = $this->makeMemberDoctor();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStaffCreateDeniedButViewGranted(): void
|
||||
{
|
||||
[$doctorUser, $perm] = $this->makeMemberDoctor();
|
||||
$perm->mergePermissions(['resources' => ['staff' => ['view' => true, 'create' => false]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/staff', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/staff', $doctorUser, ['full_name' => 'خانم تست']);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testIndependentDoctorIsNotRestricted(): void
|
||||
{
|
||||
// پزشکِ مستقل: نه عضوِ کلینیک، نه محیطِ کلینیک → روی دادهٔ شخصیِ خودش آزاد.
|
||||
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$this->em->persist(new Doctor($doctorUser, 'دکتر مستقل'));
|
||||
$this->em->flush();
|
||||
|
||||
// inventory برای منابعِ ClinicDoctorPermission پیشفرض false است، اما این پزشک
|
||||
// اصلاً عضوِ کلینیک نیست، پس ClinicDoctorAccessChecker او را محدود نمیکند.
|
||||
$this->authJson('GET', '/api/v1/inventory-items', $doctorUser);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user