From f33c7a3eab7470fe72dc6bcb066fc6f12720a2e3 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 19:09:16 +0330 Subject: [PATCH] feat(clinic-doctor): full permission coverage + enforcement, parity with secretary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...rmissions-full-coverage-and-enforcement.md | 203 ++++++++++++++++++ assets/admin/App.tsx | 2 +- .../layout/PurchaseSubscriptionSidebar.tsx | 10 +- .../components/layout/SettingsLayout.tsx | 6 +- .../admin/components/layout/Sidebar.test.tsx | 28 +++ assets/admin/components/layout/Sidebar.tsx | 34 ++- .../components/ui/DoctorPermissionsModal.tsx | 28 +++ docs/api/clinic.md | 23 +- src/Clinic/Entity/ClinicDoctorPermission.php | 7 + .../Security/ClinicDoctorAccessChecker.php | 91 ++++++++ .../Controller/ClinicServiceController.php | 35 +-- .../Controller/DiscountController.php | 9 + .../Controller/InsuranceController.php | 13 ++ .../Controller/InventoryController.php | 14 ++ src/Patient/Controller/PatientController.php | 22 ++ src/Payment/Controller/PaymentController.php | 2 + .../Controller/PaymentMethodController.php | 2 + src/Sms/Controller/SmsWalletController.php | 10 + src/Staff/Controller/StaffController.php | 9 + src/Tag/Controller/TenantTagController.php | 8 + .../ClinicDoctorPermissionEnforcementTest.php | 92 ++++++++ 21 files changed, 617 insertions(+), 31 deletions(-) create mode 100644 .claude/prompt/clinic-doctor-permissions-full-coverage-and-enforcement.md create mode 100644 src/Clinic/Security/ClinicDoctorAccessChecker.php create mode 100644 tests/Clinic/ClinicDoctorPermissionEnforcementTest.php diff --git a/.claude/prompt/clinic-doctor-permissions-full-coverage-and-enforcement.md b/.claude/prompt/clinic-doctor-permissions-full-coverage-and-enforcement.md new file mode 100644 index 00000000..e53e2aef --- /dev/null +++ b/.claude/prompt/clinic-doctor-permissions-full-coverage-and-enforcement.md @@ -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 متأثر) به‌روز شود. diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 2c4ce24e..c77870e2 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -229,7 +229,7 @@ export default function App() { {/* دکتر / منشی / کلینیک */} } /> - } /> + } /> } /> } /> diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx index fa33dc49..a489eb7a 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -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; diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 898c3527..1e19e25d 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -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; } diff --git a/assets/admin/components/layout/Sidebar.test.tsx b/assets/admin/components/layout/Sidebar.test.tsx index ea5fb417..682df639 100644 --- a/assets/admin/components/layout/Sidebar.test.tsx +++ b/assets/admin/components/layout/Sidebar.test.tsx @@ -142,3 +142,31 @@ describe("Sidebar — گِیت منوی منشی بر اساس مجوز", () => expect(screen.queryByText("پزشکان کلینیک")).not.toBeInTheDocument(); }); }); + +describe("Sidebar — گِیت منوی پزشکِ عضوِ کلینیک بر اساس مجوز", () => { + const setMemberDoctor = (resources: Record>) => + useAuthStore.setState({ + primaryRole: "doctor", + dbUuid: "c1", + userName: "دکتر عضو", + availableContexts: [], + context: { scope: "clinic", permissions: { resources } }, + } as any); + + it("پزشکِ عضو فقط منابعِ مجاز را در سایدبار می‌بیند + «تنظیمات» همیشه", () => { + setMemberDoctor({ appointments: { view: true }, services: { view: true } }); + renderWithProviders(, { 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(, { route: "/admin/dashboard" }); + expect(screen.queryByText("نوبت‌های من")).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 916d1916..fe98409e 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -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") { diff --git a/assets/admin/components/ui/DoctorPermissionsModal.tsx b/assets/admin/components/ui/DoctorPermissionsModal.tsx index 3dcfb8d7..dcfdd420 100644 --- a/assets/admin/components/ui/DoctorPermissionsModal.tsx +++ b/assets/admin/components/ui/DoctorPermissionsModal.tsx @@ -45,6 +45,34 @@ const RESOURCE_LABELS: Record ['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], ], ]; diff --git a/src/Clinic/Security/ClinicDoctorAccessChecker.php b/src/Clinic/Security/ClinicDoctorAccessChecker.php new file mode 100644 index 00000000..def89cf1 --- /dev/null +++ b/src/Clinic/Security/ClinicDoctorAccessChecker.php @@ -0,0 +1,91 @@ +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); + } + } +} diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 83ac747f..5e94898f 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -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); diff --git a/src/Discount/Controller/DiscountController.php b/src/Discount/Controller/DiscountController.php index 0c80be8b..720944e6 100644 --- a/src/Discount/Controller/DiscountController.php +++ b/src/Discount/Controller/DiscountController.php @@ -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); diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index 96ffba81..f0a8c647 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -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); diff --git a/src/Inventory/Controller/InventoryController.php b/src/Inventory/Controller/InventoryController.php index fb392abd..1795b522 100644 --- a/src/Inventory/Controller/InventoryController.php +++ b/src/Inventory/Controller/InventoryController.php @@ -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()]; } diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index d31acf0e..c8ec87a6 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -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); diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index c8a9b694..65cc71ab 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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'); diff --git a/src/PaymentMethod/Controller/PaymentMethodController.php b/src/PaymentMethod/Controller/PaymentMethodController.php index 76204410..4c3d68dc 100644 --- a/src/PaymentMethod/Controller/PaymentMethodController.php +++ b/src/PaymentMethod/Controller/PaymentMethodController.php @@ -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 ----------------------------------------------------- diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php index 614d5a4e..3190f2d7 100644 --- a/src/Sms/Controller/SmsWalletController.php +++ b/src/Sms/Controller/SmsWalletController.php @@ -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]; } diff --git a/src/Staff/Controller/StaffController.php b/src/Staff/Controller/StaffController.php index f6649e06..dc1c6545 100644 --- a/src/Staff/Controller/StaffController.php +++ b/src/Staff/Controller/StaffController.php @@ -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]; } diff --git a/src/Tag/Controller/TenantTagController.php b/src/Tag/Controller/TenantTagController.php index 784055dd..a0f082cf 100644 --- a/src/Tag/Controller/TenantTagController.php +++ b/src/Tag/Controller/TenantTagController.php @@ -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()]; } diff --git a/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php new file mode 100644 index 00000000..0baf6ea7 --- /dev/null +++ b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php @@ -0,0 +1,92 @@ +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()); + } +}