Files
clinicpro/.claude/prompt/fix-access-scoping-doctor-clinic-representation.md

14 KiB
Raw Permalink Blame History

رفع مشکلات دسترسی: پزشکِ عضو کلینیک + scope نماینده

پروژه

clinicpro (Backend auth/permission + Admin React SPA). کاملاً داخل همین پروژه است.

زمینه

دو نشتیِ دسترسی در پنل ادمین وجود دارد:

  1. پزشکِ عضو کلینیک، دسترسی کاملِ مالک کلینیک می‌گیرد. وقتی یک پزشک از طریق دعوتنامه به کلینیک اضافه می‌شود، در buildAvailableContexts برایش یک context با 'role' => 'clinic' ساخته می‌شود. چون switchContext در فرانت primaryRole = context.role می‌گذارد، آن پزشک با سوییچ به این context عملاً «مالک کلینیک» می‌شود و به کل پنل کلینیک (پرسنل، مالی، خدمات، اشتراک، ویرایش کلینیک و...) دسترسی پیدا می‌کند. درست این است که او فقط «پزشکِ شاغل در آن کلینیک» باشد (نوبت‌های خودش در آن کلینیک)، نه مدیر کلینیک.

  2. نماینده همه‌ی پزشکان/کلینیک‌ها را می‌بیند، نه فقط مالِ خودش. صفحات DoctorsPage/ClinicsPage در حالت نماینده از endpointهای عمومی /api/v1/doctors و /api/v1/clinics استفاده می‌کنند که هیچ فیلتری روی representation_id ندارند. ضمناً RepresentationActionController::createClinic هنگام ساخت کلینیک، representation_id را ست نمی‌کند (فقط createDoctor این کار را می‌کند). نتیجه: نماینده همه‌ی دکترها/کلینیک‌های سیستم را می‌بیند و کلینیک‌های خودش هم تگ‌گذاری نمی‌شوند.

مشکل / هدف

  • پزشکِ عضو کلینیک نباید نقش/دسترسی مالک کلینیک بگیرد.
  • نماینده فقط باید پزشکان و کلینیک‌هایی را ببیند که representation_id آن‌ها = id همان نماینده است (همان‌هایی که خودش ثبت کرده).

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

فایل نقش
src/Auth/Controller/AuthController.php buildAvailableContexts() — ساخت context پزشکِ عضو کلینیک با role اشتباه clinic
assets/admin/stores/authStore.ts switchContextprimaryRole = context.role؛ و type نقش
assets/admin/components/layout/Sidebar.tsx منوی هر نقش (باید برای پزشکِ مهمانِ کلینیک محدود باشد)
assets/admin/App.tsx RoleRoute صفحات کلینیک
src/Representation/Controller/RepresentationActionController.php createClinic که representation_id ست نمی‌کند؛ مرجع createDoctor که می‌کند
src/Doctor/Repository/DoctorRepository.php findWithFilters — فاقد فیلتر representation
src/Clinic/Repository/ClinicRepository.php findWithFilters — فاقد فیلتر representation
src/Doctor/Controller/DoctorController.php / src/Clinic/Controller/ClinicController.php endpointهای عمومی GET /api/v1/doctors و /api/v1/clinics
assets/admin/pages/DoctorsPage.tsx / ClinicsPage.tsx لیست نماینده که از endpoint عمومی بدون scope استفاده می‌کند
docs/api/auth.md, docs/api/doctor.md, docs/api/clinic.md, docs/api/representation.md مستندسازی

وضعیت فعلی (کد واقعی)

buildAvailableContexts — context پزشکِ عضو کلینیک با role اشتباه:

if ($doctor = $this->doctorRepo->findByUser($user)) {
    $contexts[] = [ 'type'=>'doctor', 'db_uuid'=>$doctor->getUuid(), 'name'=>'مطب شخصی '.$doctor->getName(), 'role'=>'doctor' ];
    foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
        $contexts[] = [
            'type'        => 'clinic',
            'db_uuid'     => $clinic->getUuid(),
            'name'        => $clinic->getName() ?? '',
            'role'        => 'clinic',          // ← اشتباه: پزشکِ عضو، نقش مالک کلینیک می‌گیرد
            'doctor_uuid' => $doctor->getUuid(),
        ];
    }
}

// مالک واقعی کلینیک (این درست است — فقط وقتی clinic.user === خود کاربر)
if ($clinic = $this->clinicRepo->findByUser($user)) { ... 'role'=>'clinic' ... }

switchContext در authStore (نقش از context می‌آید):

primaryRole: res.data.context?.role ?? null,

RepresentationActionController::createClinic — بدون ست representation_id:

$clinic = new Clinic($ownerUser);
$clinic->setName($name);
// ... telephone/address/info
$this->em->persist($clinic);   // ← representation_id ست نمی‌شود

(در مقابل، createDoctor این را دارد: $doctor->setRepresentationId($rep->getId());)

DoctorsPage/ClinicsPage (حالت نماینده، بدون scope):

const base = isRepresentation ? '/api/v1/doctors'  : '/api/v1/admin/doctors';   // عمومی، بدون representation
const listBase = isRepresentation ? '/api/v1/clinics' : '/api/v1/admin/clinics'; // عمومی، بدون representation

وظایف

۱. نقشِ محدود برای پزشکِ عضو کلینیک (رفع نشتی دسترسی)

در buildAvailableContexts، context کلینیک برای پزشکِ عضو باید نقش مالک کلینیک ندهد. یک نقش/scope محدود بده، مثلاً role => 'doctor' با scope => 'clinic' (یعنی همان پزشک، ولی در محیط آن کلینیک — برای دیدن نوبت‌های خودش در آن کلینیک):

foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
    $contexts[] = [
        'type'        => 'clinic',
        'db_uuid'     => $clinic->getUuid(),
        'name'        => $clinic->getName() ?? '',
        'role'        => 'doctor',     // پزشک می‌ماند، نه مالک کلینیک
        'scope'       => 'clinic',
        'doctor_uuid' => $doctor->getUuid(),
    ];
}
  • بخش «صاحب کلینیک» ($this->clinicRepo->findByUser($user)) باید دست‌نخورده بماند و همان role => 'clinic' را داشته باشد — فقط مالک واقعی، نقش کامل کلینیک می‌گیرد.
  • نکته‌ی مهم Backend: هر endpointی که فرض می‌کند «کاربرِ با context کلینیک = مالک کلینیک» باید بازبینی شود تا با scope محدود سازگار باشد (دسترسی نوشتن روی پرسنل/خدمات/اشتراک/ویرایش کلینیک نباید برای پزشکِ مهمان باز باشد). مالکیت را با clinic.getUser()->getId() === $user->getId() چک کن (الگوی موجود در ClinicInvitationController خط ۲۰۰).

۲. Admin SPA — منو و مسیرهای محدود برای پزشکِ مهمانِ کلینیک

چون با اصلاح وظیفه ۱ نقش این کاربر در آن context doctor می‌شود (نه clinicSidebar.tsx و RoleRouteهای App.tsx خودبه‌خود منوی پزشک را نشان می‌دهند و صفحات اختصاصی کلینیک (/admin/staff, /admin/my-financial با نقش clinic، ویرایش کلینیک، اشتراک) برای او باز نمی‌شوند. این را تأیید کن:

  • در App.tsx مطمئن شو صفحاتی مثل clinics/:uuid (ClinicDetailPage)، staff, subscription برای نقش doctor فقط در حدی باز است که منطقی است (مشاهده، نه مدیریت). اگر صفحه‌ای با roles={['doctor','clinic']} هست که نباید برای پزشکِ مهمان نوشتنی باشد، در همان صفحه بر اساس مالکیت (context.role === 'clinic') دکمه‌های مدیریتی را پنهان کن.
  • اگر scope در context هست، می‌توان در فرانت از context.scope === 'clinic' برای تمایز «پزشک در محیط کلینیک» استفاده کرد.

۳. نماینده — ست‌کردن representation_id روی کلینیک

در RepresentationActionController::createClinic، مثل createDoctor مالکیت نماینده را ست کن:

$clinic = new Clinic($ownerUser);
$clinic->setName($name);
// ...
$rep = $this->representationRepo->findByUser($user);
if ($rep !== null) {
    $clinic->setRepresentationId($rep->getId());
}
$this->em->persist($clinic);

۴. فیلتر representation در لیست پزشکان و کلینیک‌ها

در DoctorRepository::findWithFilters و ClinicRepository::findWithFilters یک فیلتر اختیاری representation اضافه کن:

// DoctorRepository
if (!empty($filters['representation'])) {
    $qb->andWhere('d.representationId = :rep')->setParameter('rep', (int) $filters['representation']);
}
// ClinicRepository
if (!empty($filters['representation'])) {
    $qb->andWhere('c.representationId = :rep')->setParameter('rep', (int) $filters['representation']);
}
  • در controllerهای عمومی GET /api/v1/doctors و GET /api/v1/clinics، پارامتر representation از query عبور داده شود به findWithFilters (همان الگوی بقیه‌ی فیلترها). چون این پارامتر اختیاری است، رفتار عمومی سایت تغییری نمی‌کند.

هشدار امنیتی: نماینده نباید بتواند با دست‌کاری representation در query، لیست نماینده‌ی دیگری را ببیند. بهترین کار: یک endpoint اختصاصیِ scoped برای نماینده بساز که id را از کاربر جاری می‌گیرد (مثل /api/v1/representation/doctors و /api/v1/representation/clinics در همان RepresentationActionController با findByUser)، نه از query. این امن‌تر از پارامتر عمومی است. (پیشنهادِ ترجیحی.)

۵. Admin SPA — اتصال لیست‌های نماینده به endpoint scoped

در DoctorsPage.tsx و ClinicsPage.tsx حالت نماینده را به endpoint scoped (وظیفه ۴) وصل کن:

// به‌جای /api/v1/doctors عمومی:
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
// به‌جای /api/v1/clinics عمومی:
const listBase = isRepresentation ? '/api/v1/representation/clinics' : '/api/v1/admin/clinics';
  • adapter نگاشت شکل پاسخ (که قبلاً برای DoctorsPage اضافه شده) را با شکل خروجی endpoint جدید هماهنگ کن. ساده‌ترین کار: endpoint scoped همان شکل /api/v1/admin/doctors و /api/v1/admin/clinics را برگرداند تا نیازی به adapter نباشد.

۶. داشبورد نماینده — اعداد فقط از دامنه‌ی خودش

مطمئن شو کارت‌ها/آمار داشبورد نماینده (RepresentationDashboard در DashboardPage.tsx) از endpointهای نماینده می‌آیند که از قبل scoped هستند (/representation/{uuid}/dashboard/* و /representation/appointments). اگر شمارش «تعداد پزشکان من / کلینیک‌های من» اضافه می‌شود، از همان endpoint scoped جدید بگیر (نه عمومی).

۷. مستندسازی

  • docs/api/auth.md: در توضیح available_contexts/primary_role، تفاوت «مالک کلینیک» (role: clinic) و «پزشکِ عضو در محیط کلینیک» (role: doctor, scope: clinic) را شرح بده.
  • docs/api/doctor.md و docs/api/clinic.md: پارامتر/endpoint جدید فیلتر representation.
  • docs/api/representation.md: endpointهای جدید GET /api/v1/representation/doctors و /clinics (اگر مسیر scoped انتخاب شد)؛ و اینکه createClinic حالا representation_id ست می‌کند.

نکات مهم

  • امنیت اول: مالکیت همیشه از #[CurrentUser] تعیین شود؛ هرگز به representation در query برای scope اعتماد نکن (وظیفه ۴ پیشنهادِ endpoint scoped را ترجیح می‌دهد).
  • Clinic.representationId و Doctor.representationId ستون scalar هستند؛ فیلتر مستقیم روی همان ستون.
  • نقش clinic در سیستم = «مالک/مدیر کلینیک». پزشکِ عضو هرگز نباید این نقش را در context بگیرد.
  • تغییر buildAvailableContexts رفتار سوییچ context را برای پزشکانِ چندکلینیکه عوض می‌کند؛ تست کن که مالک واقعی کلینیک هنوز نقش کامل دارد و پزشکِ مهمان فقط نوبت‌های خودش را می‌بیند.
  • بعد از تغییر هر controller/مسیر، cache:clear و به‌روزرسانی docs/api/* در همان session.
  • migration لازم نیست (هر دو ستون representation_id از قبل وجود دارند؛ این تسک فقط منطق/فیلتر/نقش است).
  • تست‌ها (با lexik:jwt:generate-token):
    • پزشکی که عضو یک کلینیک است → available_contexts آن کلینیک باید role: doctor/scope: clinic باشد، نه clinic؛ و بعد از switch، منوی پزشک ببیند نه پنل کامل کلینیک.
    • مالک واقعی کلینیک → همچنان role: clinic و دسترسی کامل.
    • نماینده → GET /api/v1/representation/doctors|clinics فقط ردیف‌های با representation_id = نماینده‌ی جاری؛ و کلینیکِ تازه‌ساخته توسط نماینده باید representation_id داشته باشد.
    • نماینده نتواند با تغییر query، داده‌ی نماینده‌ی دیگر را ببیند.