Files
clinicpro/.claude/prompt/secretary-permissions-coverage-and-panel-enforcement.md
hamed 5c4976d65f feat: Implement secretary permissions enforcement across multiple resources
- Added SecretaryAccessChecker to manage resource access for secretaries.
- Integrated permission checks for payments, inventory, and tags in relevant controllers.
- Updated PaymentController and PaymentMethodController to enforce secretary permissions.
- Enhanced TenantTagController to check permissions for tag management actions.
- Introduced tests for secretary resource enforcement, ensuring proper access control.
- Updated DoctorSecretary entity to include inventory and tags permissions.
- Created a comprehensive audit document for secretary permissions coverage and enforcement.
- Fixed potential crashes in SecretaryDashboard when rendering without doctor data.
2026-07-23 16:36:35 +03:30

18 KiB
Raw Permalink Blame History

حسابرسی و رفع سیستم مجوز منشی: پوشش کامل + اعمال در پنل + خطای ورود/کرش

پروژه

clinicpro (backend Symfony + پنل ادمین React). تک‌پروژه، cross-repo نیست.

پیش از هر گرِپ/خواندن، طبق قانون پروژه اول graphify query "..." بزن.

زمینه

سیستم منشی دو لایه دارد که فعلاً ناهماهنگ‌اند:

  1. تعریف مجوز: هر منشی یک ردیف DoctorSecretary دارد با ستون JSON permission. ساختار پیش‌فرض در src/Secretary/Entity/DoctorSecretary.php::DEFAULT_PERMISSIONS:

    'resources' => [
        'appointments' => ['view','create','cancel','update_status'],
        'patients'     => ['view','create','update','delete'],
        'payments'     => ['view','create','update','delete'],
        'insurances'   => ['view','create','update','delete'],
        'addresses'    => ['view','create','update','delete'],
        'clinic_info'  => ['view','update'],
    ]
    

    UI افزودن/ویرایش منشی (assets/admin/pages/MySecretariesPage.tsxPERMISSION_SECTIONS) دقیقاً همین ۶ منبع را نمایش می‌دهد.

  2. اعمال مجوز: بررسی واقعی فقط با src/Secretary/Security/SecretaryPermissionChecker.php::can() انجام می‌شود و این checker تنها در src/Appointment/Security/AppointmentAccessChecker.php صدا زده می‌شود.

نتیجه: فقط منبع appointments واقعاً enforce می‌شود؛ بقیهٔ toggleها (patients/payments/insurances/addresses/clinic_info) ذخیره می‌شوند ولی هیچ‌جا چک نمی‌شوند. علاوه‌بر این چند صفحه/قابلیت که منشی به آن‌ها دسترسی دارد اصلاً toggle ندارند.

مشکل / هدف

سه خواسته، به ترتیب اولویت:

  1. پوشش کامل مجوزها (Coverage): هر صفحه/ماژول/قابلیتی که منشی می‌تواند به آن دسترسی داشته باشد، باید یک toggle مجوز در بخش «مجوزهای دسترسی» فرم افزودن/ویرایش منشی داشته باشد. هیچ قابلیتی نباید خارج از این ماتریس باقی بماند.
  2. اعمال واقعی (Enforcement): هر toggle باید هم در API (۴۰۳ اگر مجوز نبود) و هم در پنل (پنهان‌کردن صفحه/دکمه اگر مجوز نبود) اثر کند. هیچ دسترسی‌ای خارج از سیستم Permission نباشد.
  3. رفع خطای ورود/کرش منشی: رفع ۴۰۱ روی /api/v1/dashboard/secretary و /api/v1/subscription/my و کرش فرانت‌اند Cannot read properties of undefined (reading 'name').

فایل‌های مرتبط

فایل نقش
src/Secretary/Entity/DoctorSecretary.php تعریف DEFAULT_PERMISSIONS + merge/مدل مجوز
src/Secretary/Security/SecretaryPermissionChecker.php تنها checker مجوز منشی (can, canAll)
src/Secretary/Service/SecretaryService.php ساخت/تخصیص منشی (resolveSecretaryUser) — رمز عبور نمی‌سازد
src/Secretary/Controller/SecretaryController.php endpointهای CRUD منشی (owner می‌سازد/ویرایش می‌کند)
src/Appointment/Security/AppointmentAccessChecker.php تنها جای فعلی که SecretaryPermissionChecker صدا زده می‌شود
src/Patient/Security/PatientRecordScopeResolver.php scope بیماران منشی — فقط بر اساس پزشکِ تخصیص‌یافته، بدون چک patients permission
src/Dashboard/Controller/DashboardController.php secretary() (L531)، secretaryDoctorDashboard() (L560)، secretaryClinicDashboard() (L615)
src/Subscription/Controller/SubscriptionController.php my() (L55) با IsGranted('IS_AUTHENTICATED_FULLY')
src/Inventory/Controller/InventoryController.php ROLE_SECRETARY دارد ولی toggle ندارد
src/Tag/Controller/TenantTagController.php ROLE_SECRETARY دارد ولی toggle ندارد
assets/admin/pages/MySecretariesPage.tsx فرم افزودن/ویرایش + PERMISSION_SECTIONS + EMPTY_PERMISSIONS
assets/admin/pages/DashboardPage.tsx SecretaryDashboard() (L878) — محل کرش .name
assets/admin/App.tsx جدول route؛ گِیت‌کردن صفحات منشی
assets/admin/stores/authStore.ts context.permissions، primaryRole، availableContexts
src/Auth/Controller/AuthController.php userinfo (L~588)، buildAvailableContexts (L697) — permissions منشی داخل context
config/packages/security.yaml firewall api (jwt) + access_control

وضعیت فعلی (واقعیت‌های تأییدشده)

الف) ماتریس فعلی و شکاف پوشش

ROLE_SECRETARY در این کنترلرها ظاهر می‌شود: MyAppointmentsController, PaymentMethodController, SecretaryController, AdminApiController, DashboardController, SubscriptionController, Inventory/InventoryController, Tag/TenantTagController, و scope در Patient/PatientRecordScopeResolver.

اما toggle فقط برای ۶ منبع appointments/patients/payments/insurances/addresses/clinic_info وجود دارد. → شکاف پوشش: inventory (انبار) و tags (تگ‌ها) — و هر ماژول دیگری که در ممیزی پیدا شد (services/sms/staff/settlement اگر منشی دسترسی دارد) — toggle ندارند.

ب) شکاف اعمال (Enforcement gap)

SecretaryPermissionChecker::can() فقط از AppointmentAccessChecker صدا زده می‌شود:

// src/Appointment/Security/AppointmentAccessChecker.php (تنها مصرف‌کننده)
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);

در PatientRecordScopeResolver::forSecretary() هیچ چکی روی permissions['resources']['patients'] نیست — منشی با patients.view=false هم بیماران را می‌بیند:

// src/Patient/Security/PatientRecordScopeResolver.php:87
private function forSecretary(User $user): PatientRecordScope
{
    // ... فقط scope بر اساس پزشکانِ تخصیص‌یافته؛ toggle مجوز اصلاً خوانده نمی‌شود
    return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), $doctorIds);
}

منابع payments/insurances/addresses/clinic_info هم هیچ نقطهٔ enforcement مبتنی‌بر DoctorSecretary.permissions ندارند.

توجه: یک سیستم مجوز دوم و جدا برای پزشکِ عضو کلینیک وجود دارد: src/Clinic/Security/ClinicDoctorPermissionChecker.php (can($user,$clinic,$resource,$action)) که در PatientRecordScopeResolver, InsuranceController, ClinicController استفاده می‌شود. این برای پزشکان است نه منشی. هنگام طراحی enforcement منشی، الگوی این checker را دنبال کن ولی منبع حقیقت را DoctorSecretary.permissions بگذار.

ج) خطای ۴۰۱ + کرش .name

واقعیت دیتابیس (تأییدشده): شماره ۰۹۱۵۰۰۰۰۰۰۱ = مالک کلینیک نمونه (users.id=2, roles ["ROLE_USER","ROLE_CLINIC"]) است، نه منشی؛ هیچ ردیف doctor_secretaries ندارد. منشی‌های واقعی: 09150000021, 09150000041, 09150000042, 09150000043. پس فرض «منشی ۰۹۱۵۰۰۰۰۰۰۱» غلط است و باید با یک منشیِ واقعی (یا منشیِ تازه‌ساخته از UI) بازتولید شود.

کرش .name (تأییدشده در کد): SecretaryDashboard فقط شکل «مطب پزشک» را می‌خواند:

// assets/admin/pages/DashboardPage.tsx
const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
// L892 — اگر d موجود ولی stats نباشد:
value: formatNumber(d?.stats.today_appointments ?? 0),
// L904 و L913 — کرش وقتی منشیِ scope=clinic است و doctor وجود ندارد:
منشی {displayDoctorName(d?.doctor.name)}
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} ... />

ولی برای منشیِ scope کلینیک، بک‌اند شکل متفاوت برمی‌گرداند (بدون کلید doctor):

// src/Dashboard/Controller/DashboardController.php:666 (secretaryClinicDashboard)
return $this->success([
    'scope'  => 'clinic',
    'clinic' => ['uuid' => ..., 'name' => ...],   // ← doctor وجود ندارد
    'permissions' => $permissions,
    'stats' => [...],
    'today_appointments' => $todayAppts,
]);

d.doctor تعریف‌نشده است و d?.doctor.name (نه d?.doctor?.name) کرش می‌کند. همین‌طور d?.stats.today_appointments.

۴۰۱ (نیازمند تشخیص runtime): هر دو endpoint گارد نقش دارند (/dashboard/secretaryROLE_SECRETARY، /subscription/myIS_AUTHENTICATED_FULLY). چون /subscription/my فقط احراز هویت می‌خواهد، ۴۰۱ روی آن یعنی توکن پذیرفته نشده = مشکل Authentication نه Authorization. دو فرضیهٔ محتمل که باید runtime رد/تأیید شوند:

  • منشیِ ساخته‌شده از UI رمز عبور ندارد (SecretaryService::resolveSecretaryUser فقط اگر password پاس داده شود ست می‌کند و فرم UI فیلد رمز ندارد) → با endpoint ورودِ رمزی (که تنها راه ورود staff است) اصلاً نمی‌تواند لاگین کند.
  • یا توکن صادر می‌شود ولی context/نقش سرِ درخواست‌ها درست منتقل نمی‌شود.

وظایف

بعد از هر تغییر کد: graphify update . (بعد از commit). هر تغییر endpoint → به‌روزرسانی docs/api/* در همان session. هر تغییر Entity → doctrine:migrations:diff + migrate. هیچ تسک بدون تست (موفق+خطا+مرزی) تمام‌شده نیست.

۰. بازتولید و تشخیص دقیق (اول این)

  1. یک منشیِ تازه از UI به «کلینیک نمونه» اضافه کن (با owner 09150000001 لاگین شو؛ رمزش را از clinicpro-QA accounts/create_test_users.php بردار). دقت کن آیا فرم رمز عبور می‌گیرد یا نه.
  2. با همان منشی تلاش به لاگین کن و مشخص کن ۴۰۱ در کدام مرحله است: خودِ oauth/token (ورود)، یا oauth/userinfo، یا /dashboard/secretary. با curl/driver مقدار HTTP و بدنه را ثبت کن.
  3. نتیجه را صریح بنویس: ۴۰۱ به‌خاطر «نبود رمز/عدم‌احراز» است یا «نبود مجوز». مسیر رفع را بر همین اساس انتخاب کن.

۱. پوشش کامل مجوزها

  1. ممیزی کن: هر مسیر/کنترلری که ROLE_SECRETARY می‌پذیرد یا منشی از طریق context به آن می‌رسد را فهرست کن (شروع از grep -rln ROLE_SECRETARY src/ و بررسی صفحات پنل که منشی می‌بیند).
  2. برای هر قابلیتی که toggle ندارد (حداقل inventory, tags؛ و هرچه در ممیزی پیدا شد) یک منبع جدید به هر سه جای هم‌زمان اضافه کن تا از هم نشکنند:
    • DoctorSecretary::DEFAULT_PERMISSIONS['resources']
    • EMPTY_PERMISSIONS و PERMISSION_SECTIONS در MySecretariesPage.tsx
    • نوع SecretaryPermissions در assets/admin/types/index.ts
  3. اگر قابلیتی نباید هرگز در دسترس منشی باشد، به‌جای toggle، ROLE_SECRETARY را از آن کنترلر بردار و در پرامپت مستند کن چرا.
// نمونه افزودن منبع به DEFAULT_PERMISSIONS
'inventory' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'tags'      => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],

mergePermissions عمیق merge می‌کند، پس منشی‌های موجود با نبودِ کلید جدید نمی‌شکنند؛ اما یک migration دادهٔ اختیاری برای backfill کلیدهای جدید روی ردیف‌های قدیمی در نظر بگیر (یا در زمان خواندن با DEFAULT_PERMISSIONS ادغام کن — همان کاری که getPermissions() تا حدی می‌کند).

۲. اعمال واقعی مجوز (API + پنل)

  1. API: برای هر منبع، در نقطهٔ درست enforce کن با SecretaryPermissionChecker::can($rel, $resource, $action). الگو را از AppointmentAccessChecker بگیر. حداقل:
    • patients: در PatientRecordScopeResolver::forSecretary() اگر patients.view=falsePatientRecordScope::unknown() (یا معادل «هیچ»)؛ و برای create/update/delete در PatientController گارد بگذار.
    • payments, insurances, addresses, clinic_info: در کنترلرهای متناظر (به‌ازای هر اکشن) گارد بیفزا. اگر یک نقطهٔ مشترک (voter/checker سرویس) تمیزتر است، یک SecretaryAccessChecker بساز تا SOLID رعایت شود و منطق تکرار نشود.
    • نبودِ مجوز → پاسخ ۴۰۳ استاندارد ($this->error(ErrorCodes::ERR_FORBIDDEN_001, ...) یا AppException).
  2. پنل: صفحه/دکمه‌ای که مجوزش نیست نباید رندر شود. context.permissions از قبل در authStore هست (buildAvailableContexts آن را داخل context منشی می‌گذارد). یک helper مثل useSecretaryCan(resource, action) بساز و در App.tsx (گِیت route) و در صفحات (پنهان‌کردن اکشن) استفاده کن. از FeatureGate موجود اگر مناسب بود بهره ببر.
  3. مطمئن شو منشیِ بدون مجوز یک منبع، نه صفحه را می‌بیند نه می‌تواند API را صدا بزند (تست هر دو لایه).

۳. رفع کرش داشبورد منشی

SecretaryDashboard را طوری بازنویسی کن که هر دو scope را بپذیرد و هرگز روی undefined کرش نکند:

interface SecretaryDashboardData {
  scope: 'doctor' | 'clinic';
  doctor?: { uuid: string; name: string; degree: string | null };
  clinic?: { uuid: string; name: string };
  permissions: Record<string, unknown>;
  stats: { today_appointments: number; tomorrow_appointments: number };
  today_appointments: ApptRow[];
}

// optional chaining کامل روی همه‌جا:
const scopeName =
  d?.scope === 'clinic' ? d?.clinic?.name : displayDoctorName(d?.doctor?.name);
value: formatNumber(d?.stats?.today_appointments ?? 0),
initials={(scopeName ?? 'D').slice(0, 1)}

اگر q.isError بود، به‌جای رندر داده، یک پیام خطای مناسب فارسی نشان بده (نه صفحهٔ سفید).

۴. رفع ۴۰۱ ورود منشی

بر اساس تشخیص وظیفهٔ ۰:

  • اگر علت نبود رمز عبور است: در جریان افزودن منشی (SecretaryService/SecretaryController/فرم MySecretariesPage) یک راه ورود فراهم کن — یا فیلد رمز در فرم، یا اجازهٔ ورود منشی با OTP (user/otp-login) مثل کاربر عادی، یا لینک set-password در پیامک خوش‌آمد. تصمیم را مستند کن.
  • اگر علت عدم انتقال نقش/توکن است: firewall api (jwt) و access_control را بررسی کن و نقطهٔ رد شدن توکن را رفع کن.
  • در فرانت‌اند: اگر یک endpoint برای منشی مجاز نیست، اصلاً صدا زده نشود (بر اساس primaryRole/permissions شرطی کن — مثل useSubscription که نباید برای منشیِ بدون دسترسی اشتراک، ۴۰۱ بگیرد و کرش کند).

۵. تست‌ها

  • بک‌اند (ddev exec php bin/phpunit): برای هر منبع، تست منشیِ مجاز (۲۰۰) و غیرمجاز (۴۰۳)؛ تست scope بیمار با patients.view=false.
  • فرانت‌اند (yarn test): SecretaryDashboard با payload کلینیک (بدون doctor)، با payload پزشک، و با حالت خطا — بدون کرش.
  • دستی: با منشیِ واقعی لاگین، تأیید نبودِ ۴۰۱ و نبودِ کرش، و اعمال‌شدن toggleها.

نکات مهم

  • منبع حقیقت مجوز منشی = DoctorSecretary.permissions JSON. enforcement جدید باید همین را بخواند، نه سیستم ClinicDoctorPermission (که مال پزشک است).
  • هر تغییر در سه‌گانهٔ (Entity default / UI sections / TS type) باید هم‌زمان باشد وگرنه merge/نمایش می‌شکند.
  • mergePermissions فقط کلیدهای ارسالی را به‌روز می‌کند؛ حذف toggle از UI داده را پاک نمی‌کند.
  • تاریخ‌ها Unix timestamp؛ پاسخ‌ها با $this->success()/$this->error()؛ لیست‌ها array-hydration.
  • رشته‌های UI فارسی، RTL، تاریخ شمسی.
  • بعد از هر تغییر API، فایل مربوط در docs/api/ (secretary.md, dashboard.md, patient.md, subscription.md, ...) به‌روز شود.
  • SOLID: اگر enforcement در چند کنترلر تکرار شد، یک سرویس/voter مشترک بساز.
  • کد/کامیت/مستندات انگلیسی؛ گفت‌وگو فارسی. اول spec انگلیسی و تأیید فارسی برداشت، بعد پیاده‌سازی.