feat: restrict guest doctor access in clinic context by updating role-based routing and sidebar menu
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# رفع باگ دسترسی کاملِ پزشکِ مهمان در محیط کلینیک
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Admin React SPA + گارد Backend). ادامه/تکمیلِ پرامپت قبلی `fix-access-scoping-doctor-clinic-representation.md`.
|
||||
|
||||
## زمینه
|
||||
|
||||
در پرامپت قبلی، نقشِ context پزشکی که به کلینیک دعوت میشود از `clinic` به `doctor` + `scope: clinic` تغییر کرد (در `buildAvailableContexts`). این درست بود ولی **ناقص**: هیچجای فرانت و بکاند به فیلد `scope` توجه نمیکند. در نتیجه وقتی پزشکِ مهمان به محیط کاریِ آن کلینیک سوییچ میکند (`db_uuid = clinicUuid`)، همان منوی کاملِ نقش `doctor` را میبیند — شامل **پرسنل، منشیها، سرویسها، اشتراک، پرونده بیماران** — و عملاً به امکانات مدیریتیِ محیط کلینیک دسترسی دارد. این یعنی باگ «پزشکِ اضافهشده به کلینیک، دسترسی به همهی امکانات کلینیک دارد» هنوز باقی است.
|
||||
|
||||
رفتار درست: پزشکِ مهمان در محیط کلینیک فقط باید **نوبتهای خودش در آن کلینیک** را ببیند (read/منظورهی محدود)، نه ابزارهای مدیریت کلینیک. در محیط «مطب شخصی» خودش (context با `scope` خالی) همهی ابزارها در دسترس میماند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
`scope: 'clinic'` که روی context پزشکِ مهمان ست شده، باید واقعاً دسترسی را محدود کند — هم در منوی Sidebar، هم در RoleRouteهای SPA، و هم با گارد در endpointهای مدیریتی (defense in depth).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Auth/Controller/AuthController.php` | `buildAvailableContexts` (از قبل `scope: 'clinic'` میدهد) و `userinfo`/`switchContext` که `context` را برمیگرداند |
|
||||
| `assets/admin/stores/authStore.ts` | نگهداری `context` فعال + `primaryRole`؛ باید `scope` در دسترس باشد |
|
||||
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش `doctor` — باید در `scope: clinic` محدود شود |
|
||||
| `assets/admin/App.tsx` | `RoleRoute` — صفحات مدیریتیِ پزشک باید در `scope: clinic` مسدود شوند |
|
||||
| `src/Staff/Controller/StaffController.php` | `resolveEntity` پزشک = scope شخصی؛ گارد لازم برای حالت مهمان |
|
||||
| `src/Secretary/Controller/SecretaryController.php` | همان منطق برای منشیها |
|
||||
| `src/ClinicService/Controller/*` (سرویسها) | همان منطق |
|
||||
| `docs/api/auth.md` | مستندسازی معنای `scope` |
|
||||
|
||||
## وضعیت فعلی (کد واقعی)
|
||||
|
||||
`buildAvailableContexts` — context مهمان دارای `scope` ولی بدون اثر:
|
||||
```php
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
$isOwner = $clinic->getUser()->getId() === $user->getId();
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName() ?? '',
|
||||
'role' => $isOwner ? 'clinic' : 'doctor',
|
||||
'scope' => $isOwner ? null : 'clinic', // ← ست میشود ولی هیچجا مصرف نمیشود
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
`Sidebar.tsx` — منوی پزشک، بدون توجه به scope (همهی آیتمهای مدیریتی نمایش داده میشوند):
|
||||
```tsx
|
||||
if (primaryRole === "doctor") {
|
||||
return [
|
||||
{ label: "عمومی", items: [{ to: "/admin/dashboard", ... }] },
|
||||
{ label: "پروفایل", items: [{ to: "/admin/profile", ... }] },
|
||||
{ label: "مدیریت", items: [
|
||||
{ to: "/admin/appointments", label: "نوبتهای من" },
|
||||
{ to: "/admin/my-patients", label: "پرونده بیماران" },
|
||||
{ to: "/admin/staff", label: "پرسنل" }, // ← نباید برای مهمان باشد
|
||||
{ to: "/admin/my-secretaries", label: "منشی ها" }, // ← نباید
|
||||
{ to: "/admin/clinic-services",label: "سرویسها" }, // ← نباید
|
||||
...
|
||||
]},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
`authStore.ts` — context فعال نگهداری میشود (شامل `role` و باید `scope`):
|
||||
```ts
|
||||
primaryRole: res.data.context?.role ?? null,
|
||||
context: res.data.context,
|
||||
```
|
||||
(نوع `ContextItem` احتمالاً `scope?` ندارد — باید اضافه شود.)
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. در دسترسبودن `scope` در authStore
|
||||
|
||||
- در `assets/admin/stores/authStore.ts`، type `ContextItem` فیلد `scope?: string | null` را داشته باشد و از `userinfo`/`switchContext` پر شود.
|
||||
- یک selector/مقدار مشتق در store یا کامپوننتها: `isClinicScopedDoctor = primaryRole === 'doctor' && context?.scope === 'clinic'`.
|
||||
|
||||
### ۲. محدودکردن منوی Sidebar برای پزشکِ مهمان
|
||||
|
||||
در `Sidebar.tsx`، شاخهی `primaryRole === "doctor"`:
|
||||
- اگر `context?.scope === 'clinic'` بود، فقط آیتمهای مجاز را برگردان:
|
||||
- داشبورد (`/admin/dashboard`)
|
||||
- نوبتها (`/admin/appointments`) — نوبتهای پزشک در آن کلینیک
|
||||
- آیتمهای `staff`, `my-secretaries`, `clinic-services`, `subscription`, `my-patients`, `profile` (مدیریت/دادهی شخصیِ مطب) را در این حالت **نشان نده**.
|
||||
- در محیط «مطب شخصی» (`scope` خالی) منوی کامل فعلی بماند.
|
||||
|
||||
### ۳. گارد RoleRoute در SPA برای صفحات مدیریتیِ مهمان
|
||||
|
||||
در `App.tsx`:
|
||||
- یک گارد سبک اضافه کن (مثلاً گسترش `RoleRoute` یا یک wrapper `ClinicScopeBlock`) که اگر کاربر `doctor` با `context.scope === 'clinic'` است، مسیرهای `staff`, `my-secretaries`, `clinic-services`, `subscription`, `my-patients`, `profile` را به `/admin/dashboard` ریدایرکت کند.
|
||||
- مسیرهای `dashboard` و `appointments` باز بمانند.
|
||||
|
||||
### ۴. گارد Backend (defense in depth)
|
||||
|
||||
حتی با محدودیت UI، endpointهای مدیریتی نباید به پزشکِ مهمان اجازهی عمل روی کلینیک بدهند. الگوی موجود این است که `StaffController::resolveEntity` برای `ROLE_DOCTOR` همیشه scope **شخصیِ** پزشک را برمیگرداند (`['doctor', doctorId]`) — یعنی پزشک فقط پرسنل/سرویسِ مطب خودش را میبیند، نه کلینیک. این را **تأیید کن** و مطمئن شو هیچ endpoint مدیریتی، `db_uuid`/clinicUuid را از کلاینت گرفته و بدون چک مالکیت (`clinic.getUser() === currentUser`) روی کلینیک عمل نمیکند:
|
||||
- `StaffController`, `SecretaryController`, `ClinicService` controllers, و `ClinicController` (update/address/logo) را مرور کن؛ هر مسیری که کلینیک را با `findByUuid($clinicUuid)` میگیرد باید مالکیت یا `ROLE_ADMIN` را چک کند (الگوی `$clinic->getUser()->getId() !== $user->getId() && !hasRole('ROLE_ADMIN')` → 403). اگر جایی این چک نیست، اضافه کن.
|
||||
- نتیجهی مورد انتظار: پزشکِ مهمان (نه مالک، نه ادمین) روی این endpointها `403` بگیرد.
|
||||
|
||||
### ۵. مستندسازی
|
||||
|
||||
- `docs/api/auth.md`: در توضیح `context`/`available_contexts`، معنای `scope: 'clinic'` را شرح بده: «پزشکِ مهمان در محیط کلینیک؛ فقط نوبتهای خودش، بدون دسترسی مدیریتی به کلینیک».
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **منبع حقیقتِ scope = context فعال**، نه `dbUuid` تنها. تمایز «مالک کلینیک» (`role: clinic`)، «پزشک در مطب شخصی» (`role: doctor`, بدون scope)، و «پزشک مهمان در کلینیک» (`role: doctor`, `scope: clinic`).
|
||||
- در محیط مطب شخصی هیچ محدودیتی اضافه نشود؛ فقط `scope: clinic` محدود میشود.
|
||||
- گارد UI برای UX است؛ **گارد واقعی Backend است** — هر دو لازماند (وظیفه ۴ نباید رها شود).
|
||||
- ادمین (`ROLE_ADMIN`) همیشه دسترسی کامل دارد؛ مالک کلینیک (`role: clinic`) هم بدون تغییر.
|
||||
- بعد از تغییر backend، `cache:clear` و بهروزرسانی `docs/api/auth.md`. تغییر فرانت با `tsc --noEmit` + `yarn dev` تست شود.
|
||||
- **تستها** (با `lexik:jwt:generate-token`):
|
||||
- پزشکِ مهمان → `userinfo` آن کلینیک `role:doctor, scope:clinic`؛ بعد از switch، Sidebar فقط «داشبورد» و «نوبتها»؛ مسیرهای `/admin/staff` و `/admin/clinic-services` ریدایرکت به داشبورد.
|
||||
- همان پزشک روی endpoint مدیریتی کلینیک (مثلاً ساخت پرسنل برای آن کلینیک) → `403`.
|
||||
- مالک واقعی کلینیک → دسترسی کامل بدون تغییر.
|
||||
- پزشک در مطب شخصی → منوی کامل و دسترسی به staff/services خودش بدون تغییر.
|
||||
+13
-8
@@ -87,10 +87,15 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
return isAuthenticated ? <Navigate to="/admin/dashboard" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
function RoleRoute({ roles, children }: { roles: string[]; children: React.ReactNode }) {
|
||||
function RoleRoute({ roles, blockClinicScope, children }: { roles: string[]; blockClinicScope?: boolean; children: React.ReactNode }) {
|
||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||
const context = useAuthStore((s) => s.context);
|
||||
if (!primaryRole) return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
|
||||
// پزشکِ مهمان در محیط کلینیک به ابزارهای مدیریتی دسترسی ندارد.
|
||||
if (blockClinicScope && primaryRole === 'doctor' && context?.scope === 'clinic') {
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -159,18 +164,18 @@ export default function App() {
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic']}><DoctorDetailPage /></RoleRoute>} />
|
||||
<Route path="profile" element={<RoleRoute roles={['doctor']}><DoctorProfilePage /></RoleRoute>} />
|
||||
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
|
||||
|
||||
{/* دکتر / منشی / کلینیک */}
|
||||
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
|
||||
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></RoleRoute>} />
|
||||
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyFinancialPage /></RoleRoute>} />
|
||||
|
||||
{/* فاز ۲ — دکتر / کلینیک */}
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']}><StaffPage /></RoleRoute>} />
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']}><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']}><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><StaffPage /></RoleRoute>} />
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین */}
|
||||
|
||||
@@ -40,7 +40,22 @@ type Section = { label: string; items: SectionItem[] };
|
||||
function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
scope: string | null,
|
||||
): Section[] {
|
||||
// پزشکِ مهمان در محیط کلینیک (scope=clinic): فقط داشبورد و نوبتهای خودش؛
|
||||
// ابزارهای مدیریتی مطب/کلینیک نمایش داده نمیشوند.
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
return [
|
||||
{
|
||||
label: "عمومی",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
{ to: "/admin/appointments", icon: CalendarDaysIcon, label: "نوبتهای من" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (primaryRole === "admin") {
|
||||
return [
|
||||
{
|
||||
@@ -366,12 +381,12 @@ interface Props {
|
||||
|
||||
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||
const { logout, primaryRole, userName, availableContexts, dbUuid } =
|
||||
const { logout, primaryRole, userName, availableContexts, dbUuid, context } =
|
||||
useAuthStore();
|
||||
const { hasFeature } = useSubscription();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sections = buildSections(primaryRole, dbUuid);
|
||||
const sections = buildSections(primaryRole, dbUuid, context?.scope ?? null);
|
||||
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ContextItem {
|
||||
db_uuid: string;
|
||||
name: string;
|
||||
role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'representation' | 'user';
|
||||
scope?: string | null;
|
||||
doctor_uuid?: string;
|
||||
permissions?: Record<string, any>;
|
||||
}
|
||||
|
||||
+1
-1
@@ -297,7 +297,7 @@ Authorization: Bearer <token>
|
||||
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
|
||||
- context منشی: `"secretary"`
|
||||
|
||||
> **نکته frontend:** پس از `switchContext`، `primaryRole` در store باید از `context.role` آپدیت شود.
|
||||
> **نکته frontend:** پس از `switchContext`، `primaryRole` در store از `context.role` و `scope` از `context.scope` آپدیت میشود. وقتی `role:"doctor"` و `scope:"clinic"` است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبتها» را نشان میدهد و مسیرهای مدیریتی (`staff`, `clinic-services`, `subscription`, `my-secretaries`, `my-patients`, `profile`) به داشبورد ریدایرکت میشوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope **شخصیِ** خودش را برمیگردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک میکنند (۴۰۳).
|
||||
|
||||
**قانون `db_uuid`**:
|
||||
- اگر یک context وجود دارد: خودکار فعال میشود
|
||||
|
||||
Reference in New Issue
Block a user