Secretaries could reach neither the services module (EntityContextResolver does not recognise a secretary as clinic owner, so they resolved to `unknown` → 403) nor had any toggle to grant it. Add `services` as a first-class secretary permission resource, enforced end-to-end. Backend - DoctorSecretary::DEFAULT_PERMISSIONS: new `services` resource (default-deny). - SecretaryAccessChecker::resolveOwnerEntity(): reusable owner (clinic/doctor) resolution from the secretary's active context, for controllers whose data is fetched by [entityType, entityId] and whose generic resolver is not secretary-aware. - ClinicServiceController: resolveEntity() is now secretary-aware; every action (sections, items, tariffs — 13 total) guards with `services` view/create/ update/delete via denyUnlessGranted, ahead of the subscription gate. Frontend - SecretaryPermissions type + MySecretariesPage + SecretariesPage: `services` section so owners can grant it. - Sidebar (secretary branch): services / inventory / tags menu items gated by can(resource, 'view'). - RoleRoute: a secretary now needs the page's `permission` to open it (direct URL entry included); clinic-services, inventory, tags-settings routes accept secretary + permission gate. Tests - SecretaryResourceEnforcementTest: services denied-by-default, allowed-when- granted, create-denied-while-view-granted. - Sidebar.test: secretary menu gating for services/inventory/tags. Docs: secretary.md + clinic-services.md updated with the `services` resource and the resolveOwnerEntity note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Secretary Permissions — Full Coverage & Panel Enforcement (Phase 2 / completion)
پروژه
clinicpro (backend Symfony + پنل ادمین React). تکپروژه، cross-repo نیست.
پیش از هر گرِپ/خواندن، طبق قانون پروژه اول
graphify query "..."بزن. بعد از هر تغییر کد (پس از commit):graphify update ..
زمینه — چه چیزی از قبل انجام شده (تأییدشده در کد)
فاز اول (secretary-permissions-coverage-and-panel-enforcement.md) بخش زیادی را ساخته است. دوباره نساز، فقط شکافها را کامل کن.
آنچه قبلاً هست:
- مدل مجوز:
DoctorSecretary::DEFAULT_PERMISSIONS['resources']با ۸ منبع:appointments, patients, payments, insurances, addresses, clinic_info, inventory, tags(src/Secretary/Entity/DoctorSecretary.php:20). - Enforcement مشترک:
src/Secretary/Security/SecretaryAccessChecker.php— نقطهٔ واحد (can(),canOrNonSecretary(),denyUnlessGranted()). صدا زده میشود در:PaymentController,PatientController,InventoryController,TenantTagController,InsuranceController,PaymentMethodController, وAppointmentAccessChecker. - اسکوپ per-doctor نوبتها:
MyAppointmentsController::resolveSecretaryFilter()(L316) لیست را بهdoctorIdsمجاز (ردیفهایDoctorSecretary) محدود میکند؛AppointmentRepository::findByUserAndDoctorIds()وPatientRecordScopeResolver::forSecretary()(L87) هم بر همان مجموعه پزشکان فیلتر میکنند. - Frontend gating primitive:
assets/admin/hooks/usePermissions.ts→can(resource, action)(خالیبودنpermissions= محیط شخصی، آزاد). Sidebar از آن استفاده میکند. - UI مجوزها:
assets/admin/pages/MySecretariesPage.tsx→PERMISSION_SECTIONS+EMPTY_PERMISSIONSهمان ۸ منبع را نمایش میدهد.SecretaryPermissionstype درassets/admin/types/index.ts:458.
مشکل / هدف — شکافهای باقیمانده
سه شکاف مشخص، مطابق پلن ۱۰-پرامپتی کاربر:
- منابع مجوزِ جاندادهشده (Coverage gap). این صفحات/ماژولها منشی به آنها دسترسی دارد یا در
منوی تنظیمات دیده میشوند ولی هیچ toggle مجوز ندارند و enforce نمیشوند:
services(سرویسها)،discounts(تخفیفها)،sms(پیامکها)،staff(پرسنل)،clinic_doctors(مدیریت پزشکان کلینیک — فقط حالت کلینیک)،appointment_settings(تنظیمات نوبتدهی)،subscription(خرید اشتراک/پرداختها). - اعمال ناقص در Frontend (Panel gap).
usePermissions().canفقط برایappointments/patients/payments/insurancesدر Sidebar استفاده شده (assets/admin/components/layout/Sidebar.tsx:365-388).inventory,tagsو منابع جدید نه در Sidebar گِیت میشوند، نه Route آنها درApp.tsxگارد دارد، نه دکمههای CRUD صفحه پنهان میشوند. - حساب کاربری (Account) نباید هیچ مجوزی داشته باشد — باید همیشه برای منشی باز باشد؛ مطمئن شو هیچ گارد اشتباهی رویش نیست (Prompt 3).
هدف نهایی: مالک (پزشک مستقل یا کلینیک) دقیقاً تعیین کند هر منشی به کدام صفحه/عملیات دسترسی دارد، و منشی فقط همانها را در Sidebar/Route/دکمهها ببیند و API هم خارج از مجوز ۴۰۳ بدهد. تست برای دو حالت: پزشک مستقل و کلینیک چند-پزشکه.
فایلهای مرتبط
| فایل | نقش | کار لازم |
|---|---|---|
src/Secretary/Entity/DoctorSecretary.php |
DEFAULT_PERMISSIONS |
افزودن منابع جدید |
src/Secretary/Security/SecretaryAccessChecker.php |
نقطهٔ واحد enforcement | بدون تغییر ساختار؛ فقط صدا زدن در کنترلرهای جدید |
src/DoctorService/Controller/DoctorServiceController.php · src/ClinicService/Controller/ClinicServiceController.php |
سرویسها | گارد services |
src/Discount/Controller/DiscountController.php |
تخفیفها | گارد discounts |
src/Sms/Controller/{SmsController,SmsMessageController,SmsWalletController}.php |
پیامکها | گارد sms |
src/Staff/Controller/StaffController.php |
پرسنل | گارد staff |
src/Clinic/Controller/ClinicController.php (بخش پزشکان کلینیک) |
مدیریت پزشکان کلینیک | گارد clinic_doctors — فقط clinic |
src/Appointment/Controller/AppointmentSettingsController.php |
تنظیمات نوبتدهی | گارد appointment_settings |
src/Subscription/Controller/SubscriptionController.php |
اشتراک | گارد subscription (یا حذف ROLE_SECRETARY اگر نباید ببیند) |
assets/admin/pages/MySecretariesPage.tsx |
فرم مجوز | افزودن PERMISSION_SECTIONS + EMPTY_PERMISSIONS منابع جدید |
assets/admin/types/index.ts (SecretaryPermissions L458) |
type | افزودن کلیدهای جدید |
assets/admin/hooks/usePermissions.ts |
can() |
بدون تغییر — استفاده گستردهتر |
assets/admin/components/layout/Sidebar.tsx (بخش primaryRole === "secretary" L361) |
منوی منشی | گِیت هر آیتم با can(resource,'view') |
assets/admin/App.tsx |
جدول Route | گارد Route هر صفحهٔ منشی |
| صفحات پنل هر منبع (Services/Discounts/Sms/Staff/…Page.tsx) | دکمههای CRUD | پنهانکردن دکمه create/edit/delete با can() |
وضعیت فعلی (کد واقعی)
منبع حقیقت مجوز:
// src/Secretary/Entity/DoctorSecretary.php:20
public const DEFAULT_PERMISSIONS = [
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'patients' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'payments' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'clinic_info' => ['view' => true, 'update' => false],
'inventory' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'tags' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
// ← منابع جدید اینجا اضافه میشوند
],
];
نقطهٔ واحد enforcement (از این الگو در کنترلرهای جدید استفاده کن — چیز جدید نساز):
// src/Secretary/Security/SecretaryAccessChecker.php:73
public function denyUnlessGranted(User $user, string $resource, string $action): void
{
if (!$this->canOrNonSecretary($user, $resource, $action)) {
// → ۴۰۳ استاندارد
}
}
Frontend فقط ۴ منبع را در Sidebar گِیت کرده:
// assets/admin/components/layout/Sidebar.tsx (primaryRole === "secretary")
if (can("appointments", "view")) { ... }
if (can("patients", "view")) { ... }
if (can("payments", "view")) { ... }
if (can("insurances", "view")) { ... }
// ← inventory / tags / services / discounts / sms / staff / clinic_doctors / appointment_settings گِیت نشدهاند
وظایف
قانون هر منبع: همزمان در هر ۳ جا اضافه شود وگرنه merge/نمایش میشکند — (۱)
DoctorSecretary::DEFAULT_PERMISSIONS، (۲)MySecretariesPage.tsx(EMPTY_PERMISSIONS+PERMISSION_SECTIONS)، (۳)SecretaryPermissionstype. سپس backend enforce + frontend gate. بعد از هر تغییر endpoint:docs/api/*همان session بهروز شود. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.
۰. ممیزی و تصمیم (اول این)
grep -rln ROLE_SECRETARY src/را با فهرست صفحاتی که منشی در Sidebar/پنل میبیند تطبیق بده. جدول بساز: منبع | کنترلر(ها) | toggle دارد؟ | backend enforce؟ | sidebar gate؟ | route guard؟ | CRUD gate؟.- برای هر ماژول تصمیم بگیر: الف) منشی میتواند دسترسی داشته باشد → toggle اضافه کن؛
ب) هرگز نباید ببیند →
ROLE_SECRETARYرا از کنترلر بردار و در پرامپت دلیل را بنویس. (مثلاًsubscriptionاحتمالاً باید فقط برای owner باشد؛ اگر منشی نباید بخرد، بهجای toggle نقش را حذف کن.)
۱. افزودن منابع مجوز جدید (Prompt 2 + 4 + 9)
برای هر منبعِ زیر که در ممیزی «باید toggle داشته باشد»، در هر ۳ جا اضافه کن:
// DoctorSecretary::DEFAULT_PERMISSIONS['resources'] — همه پیشفرض false (اصل least-privilege)
'services' => ['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],
'staff' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false],
'appointment_settings' => ['view' => false, 'update' => false],
'clinic_doctors' => ['view' => false, 'create' => false, 'update' => false, 'delete' => false], // فقط clinic
mergePermissionsعمیق merge میکند؛ منشیهای موجود نمیشکنند. اما یک data migration اختیاری برای backfill کلیدهای جدید روی ردیفهای قدیمی در نظر بگیر (یا درgetPermissions()باDEFAULT_PERMISSIONSادغام کن).clinic_doctorsفقط در حالتOWNER_CLINICمعنا دارد: درMySecretariesPage.tsxاین section را فقط وقتی نمایش بده که owner کلینیک است (پزشک مستقل نباید این toggle را ببیند — Prompt 9).
۲. Enforcement در Backend (Prompt 6)
در هر کنترلر جدید، در ابتدای هر اکشن با نقطهٔ واحد گارد بگذار (چیز جدید نساز):
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'view'); // GET
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'create'); // POST
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'update'); // PUT/PATCH
$this->secretaryAccess->denyUnlessGranted($user, 'services', 'delete'); // DELETE
canOrNonSecretaryتضمین میکند برای owner/پزشک/ادمین رفتار تغییر نکند (فقط منشی محدود شود).- نبود مجوز → همان ۴۰۳ استاندارد
AccessChecker(نه ۲۰۰ با لیست خالی، نه ۵۰۰). - کنترلرهای:
DoctorServiceController/ClinicServiceController(services)،DiscountController(discounts)،Sms*Controller(sms)،StaffController(staff)،AppointmentSettingsController(appointment_settings)، بخش پزشکانِClinicController(clinic_doctors).
۳. Enforcement در Frontend — Sidebar + Route + CRUD (Prompt 5 + 7)
- Sidebar (
Sidebar.tsx، بخشsecretary): هر آیتم منو را باcan(resource,'view')بپیچ — شاملinventory,tagsو همهٔ منابع جدید. منشی نباید هیچ منوی بدونمجوز ببیند و نباید هیچ منوی اضافی بماند. - Route guard (
App.tsx): هر Route صفحهٔ منشی را گارد کن؛ ورود مستقیم با URL بدون مجوز → redirect یا صفحهٔ «دسترسی ندارید» فارسی استاندارد (نه صفحهٔ سفید). اگر helper مشترکی نیست، یک<RequirePermission resource action>سبک بساز که ازusePermissions().canاستفاده کند (ازFeatureGateموجود الگو بگیر). - CRUD/Action buttons در هر صفحه: دکمههای create/edit/delete/import/export و
FloatingAction/ContextMenu/QuickAction را با
can(resource, action)شرطی کن. نبودِ مجوز = پنهان، نه disable. - Breadcrumb صفحهٔ بدونمجوز هم نباید ساخته شود.
۴. حساب کاربری بدون مجوز (Prompt 3)
- صفحهٔ Account/پروفایل کاربری باید برای منشی همیشه باز باشد. بررسی کن هیچ
ROLE_*سختگیرانه یاdenyUnlessGrantedاشتباه رویش نیست و هیچ toggle برایش تعریف نشده. اگر endpoint اکانتIS_AUTHENTICATED_FULLYدارد کافی است — دست نزن.
۵. اسکوپ per-doctor نوبتها — تکمیل پوشش عملیات (Prompt 8)
اسکوپ لیست از قبل هست (resolveSecretaryFilter)؛ فقط مطمئن شو همهٔ عملیات نوبت هم به مجموعهٔ
پزشکان مجاز محدودند، نه فقط GET لیست:
- create / update / cancel / update_status / قطعیکردن / پرداخت / فاکتور / تغییر وضعیت / مشاهدهٔ پروندهٔ بیمار.
- در هر اکشن، پیش از عمل چک کن
doctorهدف درallowed doctorIdsمنشی باشد (ازDoctorSecretaryRepository)؛ در غیر این صورت ۴۰۳ — حتی اگرappointments.<action>=true. (مجوز action و اسکوپ doctor هر دو لازماند.) - تست هر دو سناریو: پزشک مستقل (فقط نوبتهای خودش) و کلینیک با منشیِ محدود به زیرمجموعهای از پزشکان.
۶. تست نهایی و ماتریس (Prompt 10)
- Backend (
ddev exec php bin/phpunit): برای هر منبع تازه، تست منشی مجاز (۲۰۰) و غیرمجاز (۴۰۳)، و برای نوبتها تست دسترسی به پزشک خارج از اسکوپ (۴۰۳). - Frontend (
yarn test): Sidebar با permissionهای مختلف فقط آیتمهای مجاز را رندر کند؛ دکمههای CRUD بدون مجوز رندر نشوند. - دستی با منشیِ واقعی (
clinicpro-QA accounts) در دو حالت پزشک مستقل و کلینیک. - جدول نهایی را در خروجی بده و فقط وقتی «تمام» اعلام کن که همه PASS باشند:
Resource | Toggle | Sidebar | Route | API(403) | CRUD | Tested
appointments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
patients | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
payments | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
insurances | ✓ | ✓ | ✓ | ✓ | ✓ | PASS
addresses | ✓ | … | … | ✓ | … | ?
clinic_info | ✓ | … | … | ✓ | … | ?
inventory | ✓ | ? | ? | ✓ | ? | ?
tags | ✓ | ? | ? | ✓ | ? | ?
services | + | + | + | + | + | ?
discounts | + | + | + | + | + | ?
sms | + | + | + | + | + | ?
staff | + | + | + | + | + | ?
appointment_settings | + | + | + | + | + | ?
clinic_doctors(clinic)| + | + | + | + | + | ?
(+ = این پرامپت باید بسازد، …/? = ممیزی وظیفهٔ ۰ وضعیت واقعی را پر کند.)
نکات مهم
- منبع حقیقت =
DoctorSecretary.permissionsJSON. enforcement جدید همین را بخواند، نهClinicDoctorPermissionChecker(که مال پزشکِ عضو کلینیک است، نه منشی). - نقطهٔ واحد enforcement از قبل هست:
SecretaryAccessChecker. کنترلر/checker موازی نساز — فقطdenyUnlessGrantedصدا بزن (اصل ۲ CLAUDE.md: اول موجود را استفاده کن). - هر منبع همزمان در سهگانهٔ Entity default / UI section / TS type اضافه شود.
clinic_doctorsفقط حالت کلینیک؛ برای پزشک مستقل نه toggle نه منو.- least-privilege: منابع جدید پیشفرض همه
false. - تاریخها Unix timestamp؛ پاسخها
$this->success()/$this->error()؛ لیستها array-hydration. - رشتههای UI فارسی، RTL، شمسی. کد/کامیت/مستندات انگلیسی؛ گفتوگو فارسی. اول spec انگلیسی، تأیید فارسی، بعد پیادهسازی.
- بعد از تغییر endpointها، این فایلهای
docs/api/بهروز:secretary.md,service.md/clinic-service.md,discount.md,sms.md,staff.md,clinic.md,appointment.md,subscription.md.