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>
16 KiB
پوشش و اعمالِ کاملِ مجوز پزشکِ عضو کلینیک (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 ..
مشکل / هدف
سه شکاف، مطابق همان الگوی منشی:
- پوشش (Coverage): هر صفحه/عملیاتی که پزشکِ عضو کلینیک به آن میرسد باید toggle مجوز داشته باشد و
در فرم «مدیریت دسترسیهای پزشک» نمایش داده شود. حداقل بررسی:
insurances,inventory,tags,staff,discounts,sms— هرکدام که پزشک عضو باید کنترل شود. - اعمال (Enforcement): هر منبع باید در نقطهٔ درستِ بکاند با
ClinicDoctorPermissionChecker::can( $user, $clinic, $resource, $action)گِیت شود (۴۰۳ اگر مجوز نبود)، نه فقط توگل ذخیرهشود. توگلهای مردهٔ فعلی (services.update،payments.*، …) واقعاً enforce شوند. - پنل (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 |
مستندات مجوز پزشک کلینیک | بهروزرسانی |
وضعیت فعلی (کد واقعی)
منبع حقیقتِ مجوز پزشک عضو:
// 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);
سایدبارِ پزشک مهمان فقط ۲ منبع را گِیت میکند:
// 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 هم میتواند ویرایش کند):
// src/ClinicService/Controller/ClinicServiceController.php
// هیچ ClinicDoctorPermissionChecker صدا زده نمیشود؛ فقط SecretaryAccessChecker (مخصوص منشی).
// resolveEntity برای پزشکِ عضو، کلینیک را برمیگرداند و بدون هیچ گِیتی اجازهٔ نوشتن میدهد.
بیمه با checkerِ اشتباه:
// 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. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
۰. ممیزی و تصمیم (اول این)
- برای هر منبعی که سیستم منشی دارد ولی ClinicDoctorPermission ندارد
(
insurances, inventory, tags, staff, discounts, sms)، مشخص کن آیا پزشکِ عضو کلینیک به آن صفحه دسترسی دارد (از سایدبار/Route/endpoint). جدول بساز: منبع | پزشک عضو میرسد؟ | toggle در ClinicDoctorPermission؟ | enforce با permChecker؟ | sidebar؟ | Route؟. - تصمیم بگیر کدامها باید toggle بگیرند (مثلاً بیمه/انبار/تگ/خدمات منطقیاند؛ خرید اشتراک و مدیریت پزشکان کلینیک ذاتاً مالکاند و برای پزشکِ عضو نباید باشند). دلیل هر تصمیم را در پرامپت بنویس.
۱. افزودن منابع مجوز (Coverage)
برای هر منبعِ تصمیمگرفتهشده، در DEFAULT_PERMISSIONS['resources'] و RESOURCE_LABELS اضافه کن.
پیشفرضِ منطقی برای پزشکِ عضو: view=true و نوشتنها false (مثل الگوی فعلی services/clinic_info).
// نمونه افزودن به 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],
// ... طبق تصمیم وظیفهٔ ۰
چون
mergePermissionswhitelist دارد، منابعِ جدید حتماً باید در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)را هم برای پزشکِ عضو اعمال کن (اگرinsurancestoggle گرفت). - payments / patients: تأیید کن نوشتنهای پزشکِ عضو با
permCheckerگِیت میشوند (patients scope ازPatientRecordScopeResolverمیآید؛ نوشتنها گارد جدا لازم دارند). - inventory/tags/staff/discounts/sms: اگر toggle گرفتند، در همان کنترلرها برای پزشکِ عضو گارد بگذار
(این کنترلرها الان فقط
secretaryAccessدارند). - نبودِ مجوز →
403 ERR_FORBIDDEN_001استاندارد (مثلSecretaryAccessChecker).
SOLID: اگر منطقِ «آیا این کاربر پزشکِ عضوِ همین کلینیک است و مجوز دارد؟» در چند کنترلر تکرار شد، یک متدِ کمکی روی
ClinicDoctorPermissionCheckerبساز (قرینهٔSecretaryAccessChecker::denyUnlessGranted) تا تکرار نشود. الگوی موجود منشی را دنبال کن.
۳. اعمال در پنل (Frontend)
- Sidebar (
Sidebar.tsx، بلوکdoctor && scope=clinic, L71): برای هر منبعِ مجاز آیتم منو اضافه کن، هرکدام باcan(resource,'view')— services، تنظیمات نوبتدهی، پرداختها، بیمه، انبار، تگ، … دقیقاً مثل بلوکِsecretary. منوی مطبِ شخصیِ پزشکِ مستقل (primaryRole==='doctor'بدون scope کلینیک) نباید تغییر کند — آنجا مالک است و آزاد. - Route (
App.tsx): مطمئن شو هر صفحهٔ پزشکِ عضو باblockClinicScope permission={[resource,'view']}گِیت شده (این الگو از قبل برای پزشکِ مهمان طراحی شده؛ فقط منابعِ جدید را پوشش بده). - 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'/ کلینیک از محیطِ فعال). mergePermissionswhitelist دارد → منبع جدید حتماً درDEFAULT_PERMISSIONS.- الگوی enforcement را از
InsuranceController::76و کارِ منشی (SecretaryAccessChecker) بگیر؛ SOLID، بدون تکرار. - تاریخها Unix timestamp؛ پاسخها
$this->success()/$this->error()؛ لیستها array-hydration. - رشتههای UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفتوگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیادهسازی.
- بعد از تغییر endpointها:
docs/api/clinic.md(+ هر domain متأثر) بهروز شود.