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.
This commit is contained in:
hamed
2026-07-23 16:36:35 +03:30
parent f00ed23f00
commit 5c4976d65f
24 changed files with 790 additions and 87 deletions
@@ -0,0 +1,210 @@
# حسابرسی و رفع سیستم مجوز منشی: پوشش کامل + اعمال در پنل + خطای ورود/کرش
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React). تک‌پروژه، cross-repo نیست.
> پیش از هر گرِپ/خواندن، طبق قانون پروژه اول `graphify query "..."` بزن.
## زمینه
سیستم منشی دو لایه دارد که فعلاً ناهماهنگ‌اند:
1. **تعریف مجوز:** هر منشی یک ردیف `DoctorSecretary` دارد با ستون JSON `permission`.
ساختار پیش‌فرض در `src/Secretary/Entity/DoctorSecretary.php::DEFAULT_PERMISSIONS`:
```php
'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.tsx` → `PERMISSION_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` صدا زده می‌شود:
```php
// src/Appointment/Security/AppointmentAccessChecker.php (تنها مصرف‌کننده)
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
```
در `PatientRecordScopeResolver::forSecretary()` هیچ چکی روی `permissions['resources']['patients']` نیست — منشی با `patients.view=false` هم بیماران را می‌بیند:
```php
// 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` فقط شکل «مطب پزشک» را می‌خواند:
```tsx
// 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`):
```php
// 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/secretary`→`ROLE_SECRETARY`، `/subscription/my`→`IS_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` را از آن کنترلر بردار و در پرامپت مستند کن چرا.
```php
// نمونه افزودن منبع به 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=false` → `PatientRecordScope::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` کرش نکند:
```tsx
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 انگلیسی و تأیید فارسی برداشت، بعد پیاده‌سازی.