Files
clinicpro/.claude/prompt/secretary-permissions-full-coverage-phase2.md
T
hamedandClaude Opus 4.8 9d46577181 feat(secretary): add services permission resource + panel gating (phase A)
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>
2026-07-23 17:10:54 +03:30

18 KiB
Raw Blame History

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.tscan(resource, action) (خالی‌بودن permissions = محیط شخصی، آزاد). Sidebar از آن استفاده می‌کند.
  • UI مجوزها: assets/admin/pages/MySecretariesPage.tsxPERMISSION_SECTIONS + EMPTY_PERMISSIONS همان ۸ منبع را نمایش می‌دهد. SecretaryPermissions type در assets/admin/types/index.ts:458.

مشکل / هدف — شکاف‌های باقی‌مانده

سه شکاف مشخص، مطابق پلن ۱۰-پرامپتی کاربر:

  1. منابع مجوزِ جانداده‌شده (Coverage gap). این صفحات/ماژول‌ها منشی به آن‌ها دسترسی دارد یا در منوی تنظیمات دیده می‌شوند ولی هیچ toggle مجوز ندارند و enforce نمی‌شوند: services (سرویس‌ها)، discounts (تخفیف‌ها)، sms (پیامک‌ها)، staff (پرسنل)، clinic_doctors (مدیریت پزشکان کلینیک — فقط حالت کلینیک)، appointment_settings (تنظیمات نوبت‌دهی)، subscription (خرید اشتراک/پرداخت‌ها).
  2. اعمال ناقص در 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 صفحه پنهان می‌شوند.
  3. حساب کاربری (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)، (۳) SecretaryPermissions type. سپس backend enforce + frontend gate. بعد از هر تغییر endpoint: docs/api/* همان session به‌روز شود. بدون تست (موفق+۴۰۳+مرزی) هیچ تسکی تمام نیست.

۰. ممیزی و تصمیم (اول این)

  1. grep -rln ROLE_SECRETARY src/ را با فهرست صفحاتی که منشی در Sidebar/پنل می‌بیند تطبیق بده. جدول بساز: منبع | کنترلر(ها) | toggle دارد؟ | backend enforce؟ | sidebar gate؟ | route guard؟ | CRUD gate؟.
  2. برای هر ماژول تصمیم بگیر: الف) منشی می‌تواند دسترسی داشته باشد → 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 (servicesDiscountController (discountsSms*Controller (smsStaffController (staffAppointmentSettingsController (appointment_settings)، بخش پزشکانِ ClinicController (clinic_doctors).

۳. Enforcement در Frontend — Sidebar + Route + CRUD (Prompt 5 + 7)

  1. Sidebar (Sidebar.tsx، بخش secretary): هر آیتم منو را با can(resource,'view') بپیچ — شامل inventory, tags و همهٔ منابع جدید. منشی نباید هیچ منوی بدون‌مجوز ببیند و نباید هیچ منوی اضافی بماند.
  2. Route guard (App.tsx): هر Route صفحهٔ منشی را گارد کن؛ ورود مستقیم با URL بدون مجوز → redirect یا صفحهٔ «دسترسی ندارید» فارسی استاندارد (نه صفحهٔ سفید). اگر helper مشترکی نیست، یک <RequirePermission resource action> سبک بساز که از usePermissions().can استفاده کند (از FeatureGate موجود الگو بگیر).
  3. CRUD/Action buttons در هر صفحه: دکمه‌های create/edit/delete/import/export و FloatingAction/ContextMenu/QuickAction را با can(resource, action) شرطی کن. نبودِ مجوز = پنهان، نه disable.
  4. 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.permissions JSON. 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.