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 انگلیسی و تأیید فارسی برداشت، بعد پیاده‌سازی.
+45 -41
View File
@@ -359,51 +359,55 @@ function buildSections(
}
if (primaryRole === "secretary") {
// منو از روی مجوزهای همان منشی ساخته می‌شود: آیتمی که مجوز مشاهده‌اش را
// ندارد اصلاً نمایش داده نمی‌شود (هم‌راستا با enforcement سمت API).
const items: SectionItem[] = [];
if (can("appointments", "view")) {
items.push({
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
});
}
if (can("patients", "view")) {
items.push({
to: "/admin/patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
});
}
if (can("payments", "view")) {
items.push({
to: "/admin/my-payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
});
}
if (can("insurances", "view")) {
items.push(
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
feature: "insurance",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
feature: "insurance",
},
);
}
return [
{
label: "عمومی",
items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
},
{
label: "مدیریت",
items: [
{
to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
},
{
to: "/admin/patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{
to: "/admin/my-payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
},
{
to: "/admin/insurance-pricing",
icon: ShieldCheckIcon,
label: "قیمت‌گذاری بیمه",
feature: "insurance",
},
{
to: "/admin/claims",
icon: DocumentTextIcon,
label: "مطالبات بیمه",
feature: "insurance",
},
],
items: [{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" }],
},
{ label: "مدیریت", items },
];
}
+27 -8
View File
@@ -869,7 +869,9 @@ function DoctorDashboard() {
// ── Secretary Dashboard ───────────────────────────────────────────────────
interface SecretaryDashboardData {
doctor: { uuid: string; name: string; degree: string | null };
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[];
@@ -888,20 +890,37 @@ function SecretaryDashboard() {
if (q.isLoading) return <LoadingSkeleton />;
if (q.isError || !d) {
return (
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
<p className="muted" style={{ fontSize: 13.5 }}>در حال حاضر اطلاعات داشبورد در دسترس نیست.</p>
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
تلاش دوباره
</button>
</div>
);
}
// منشیِ scope=clinic فیلد doctor ندارد و scope=doctor فیلد clinic؛ نام محیط بر اساس scope.
const scopeName = d.scope === 'clinic' ? (d.clinic?.name ?? '') : displayDoctorName(d.doctor?.name);
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
const kpiCards = [
{ label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'نوبت‌های امروز', value: formatNumber(d.stats?.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'نوبت‌های فردا', value: formatNumber(d.stats?.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false;
const canViewAppts = (d.permissions as any)?.resources?.appointments?.view ?? false;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">داشبورد منشی</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {displayDoctorName(d?.doctor.name)}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {scopeLabel} {scopeName}</div>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
@@ -910,10 +929,10 @@ function SecretaryDashboard() {
</div>
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
<AvatarEl initials={(scopeName || 'D').slice(0, 1)} hue={256} size="lg" />
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{displayDoctorName(d?.doctor.name) || '—'}</div>
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
<div style={{ fontWeight: 700, fontSize: 16 }}>{scopeName || '—'}</div>
{d.scope === 'doctor' && d.doctor?.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
</div>
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>
+22
View File
@@ -73,6 +73,8 @@ const EMPTY_PERMISSIONS: SecretaryPermissions = {
insurances: { view: false, create: false, update: false, delete: false },
addresses: { view: false, create: false, update: false, delete: false },
clinic_info: { view: false, update: false },
inventory: { view: false, create: false, update: false, delete: false },
tags: { view: false, create: false, update: false, delete: false },
};
type PermSection = keyof SecretaryPermissions;
@@ -140,6 +142,26 @@ const PERMISSION_SECTIONS: {
{ key: "update", label: "ویرایش اطلاعات" },
],
},
{
key: "inventory",
title: "انبار",
items: [
{ key: "view", label: "مشاهده انبار" },
{ key: "create", label: "ایجاد کالا/بسته" },
{ key: "update", label: "ویرایش انبار" },
{ key: "delete", label: "حذف از انبار" },
],
},
{
key: "tags",
title: "تگ‌ها",
items: [
{ key: "view", label: "مشاهده تگ‌ها" },
{ key: "create", label: "ایجاد تگ" },
{ key: "update", label: "ویرایش تگ" },
{ key: "delete", label: "حذف تگ" },
],
},
];
function PermissionAccordions({
+20
View File
@@ -19,6 +19,8 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
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 },
};
type PermSection = keyof SecretaryPermissions;
@@ -76,6 +78,24 @@ const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: st
{ key: 'delete', label: 'حذف' },
],
},
inventory: {
label: 'انبار',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
tags: {
label: 'تگ‌ها',
actions: [
{ key: 'view', label: 'مشاهده' },
{ key: 'create', label: 'ایجاد' },
{ key: 'update', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' },
],
},
};
function PermissionsMatrix({
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import DashboardPage from './DashboardPage';
const get = api.get as ReturnType<typeof vi.fn>;
const perms = {
version: 1,
resources: { appointments: { view: true, create: true, cancel: false, update_status: true } },
};
/** منشیِ scope=clinic: پاسخ کلید `doctor` ندارد — نباید کرش کند. */
const clinicScope = {
success: true,
data: {
scope: 'clinic',
clinic: { uuid: 'clinic-1', name: 'کلینیک نمونه' },
permissions: perms,
stats: { today_appointments: 3, tomorrow_appointments: 5 },
today_appointments: [],
},
};
const doctorScope = {
success: true,
data: {
scope: 'doctor',
doctor: { uuid: 'doc-1', name: 'دکتر رضایی', degree: 'متخصص قلب' },
permissions: perms,
stats: { today_appointments: 1, tomorrow_appointments: 2 },
today_appointments: [],
},
};
describe('SecretaryDashboard', () => {
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'secretary', dbUuid: 'clinic-1', context: null } as never);
});
it('منشیِ کلینیک را بدون کرش رندر می‌کند (پاسخ بدون فیلد doctor)', async () => {
get.mockImplementation((url: string) =>
url.includes('/dashboard/secretary') ? Promise.resolve(clinicScope) : Promise.resolve({ success: true, data: [] }),
);
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
expect(await screen.findByText('داشبورد منشی')).toBeInTheDocument();
expect(screen.getAllByText(/کلینیک نمونه/).length).toBeGreaterThan(0);
expect(screen.getAllByText('نوبت‌های امروز').length).toBeGreaterThan(0);
});
it('منشیِ مطب پزشک را با نام پزشک رندر می‌کند', async () => {
get.mockImplementation((url: string) =>
url.includes('/dashboard/secretary') ? Promise.resolve(doctorScope) : Promise.resolve({ success: true, data: [] }),
);
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
expect(await screen.findByText('داشبورد منشی')).toBeInTheDocument();
expect(screen.getAllByText(/دکتر رضایی/).length).toBeGreaterThan(0);
});
it('پاسخ خالی/خطا را بدون کرش با پیام مناسب نشان می‌دهد', async () => {
get.mockImplementation((url: string) =>
url.includes('/dashboard/secretary')
? Promise.resolve({ success: false, data: null, errors: [{ code: 'X', message: 'x' }] })
: Promise.resolve({ success: true, data: [] }),
);
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
expect(await screen.findByText('در حال حاضر اطلاعات داشبورد در دسترس نیست.')).toBeInTheDocument();
});
});
+12
View File
@@ -490,6 +490,18 @@ export interface SecretaryPermissions {
view: boolean;
update: boolean;
};
inventory: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
tags: {
view: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
}
export interface Specialty {
+27 -25
View File
@@ -196,41 +196,43 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin
### Response `200`
پاسخ بر اساس `scope` محیطِ فعالِ منشی دو شکل دارد. **کلید `scope` تمایزدهنده است**: در `scope=doctor` فیلد `doctor` هست (نه `clinic`) و در `scope=clinic` فیلد `clinic` (نه `doctor`). کلاینت باید هر دو را مدیریت کند و به‌صورت مستقیم به `data.doctor.name` دسترسی نگیرد.
**منشیِ مطبِ شخصی (`scope=doctor`):**
```json
{
"success": true,
"data": {
"doctor": {
"uuid": "string",
"name": "string",
"degree": "string | null"
},
"permissions": {
"resources": {
"appointments": {
"view": true,
"edit": false
}
}
},
"stats": {
"today_appointments": 8,
"tomorrow_appointments": 5
},
"scope": "doctor",
"doctor": { "uuid": "string", "name": "string", "degree": "string | null" },
"permissions": { "resources": { "appointments": { "view": true, "update_status": false } } },
"stats": { "today_appointments": 8, "tomorrow_appointments": 5 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string",
"slot_start": 1700000000,
"status": "reserved"
}
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved" }
]
}
}
```
`today_appointments` — only populated when `permissions.resources.appointments.view === true`; up to 10 records when visible.
**منشیِ کلینیک (`scope=clinic`) — بدون کلید `doctor`:**
```json
{
"success": true,
"data": {
"scope": "clinic",
"clinic": { "uuid": "string", "name": "string" },
"permissions": { "resources": { "appointments": { "view": true } } },
"stats": { "today_appointments": 12, "tomorrow_appointments": 7 },
"today_appointments": [
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved", "doctor_name": "string" }
]
}
}
```
`today_appointments` — only populated when `permissions.resources.appointments.view === true`; up to 10 records (doctor scope) / 20 (clinic scope) when visible. در scope کلینیک هر ردیف `doctor_name` هم دارد.
### Errors
+2
View File
@@ -2,6 +2,8 @@
> **Prefix:** `/api/v1/insurances`, `/api/v1/insurance`, `/api/v1/admin/insurance`
> **دسترسی منشی:** endpointهای غیرادمینِ بیمه (insurance-pricing, billing/tenant-insurances, service-coverage, `/api/v1/insurance/*`) برای `ROLE_SECRETARY` روی منبع `insurances` اعمال می‌شوند (`SecretaryAccessChecker`): GET→`view`, POST→`create`, PATCH/PUT→`update`, DELETE→`delete`؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
Two resource types:
1. **Insurance** — master list of insurance companies managed by admin
2. **DoctorInsurance** — a doctor's acceptance of a specific insurance (with optional price)
+2
View File
@@ -2,6 +2,8 @@
> **Prefix:** `/api/v1/inventory-*`
> **دسترسی منشی:** برای `ROLE_SECRETARY` روی منبع `inventory` اعمال می‌شود (`SecretaryAccessChecker`): GET→`view`, POST→`create`, PATCH→`update`, DELETE→`delete`؛ نبودِ مجوز → `403`. پیش‌فرضِ منشی برای این منبع همه `false` است. جزئیات: [secretary.md](secretary.md).
Per-tenant (doctor/clinic) consumable-stock management: **items** and **packages**
(bundles of items). Every row is scoped to the caller's resolved entity
(`doctor` / `clinic`), exactly like Tenant Tags — a tenant only ever sees and
+2
View File
@@ -4,6 +4,8 @@
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
> **دسترسی منشی:** برای `ROLE_SECRETARY` روی منبع `patients` اعمال می‌شود (`SecretaryAccessChecker`). خواندن‌ها از طریق `scope()` کنترل می‌شوند: منشیِ بدون `patients.view` هیچ پرونده‌ای نمی‌بیند (scope = unknown → 404/403). نوشتن‌ها guard جداگانه دارند: ایجاد بیمار→`patients.create`؛ ویرایش/زیرمنابع (note/call/message/medical-record/attachment/session)→`patients.update`. عملیاتِ مالیِ بیمار (کیف‌پول، پرداختِ جلسه) روی منبع `payments` اعمال می‌شوند. نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor, clinic, or secretary)
+2
View File
@@ -4,6 +4,8 @@
Per-clinic payment methods managed from the settings screen (`/admin/my-financial`,
tab "مدیریت پرداخت"). Two resources: **bank accounts** and **POS (card reader) devices**.
> **دسترسی منشی:** روش‌های پرداخت زیرمجموعهٔ منبع `payments` هستند. برای `ROLE_SECRETARY` (`SecretaryAccessChecker`): GET→`payments.view`, POST→`payments.create`, PUT/PATCH→`payments.update`؛ نبودِ مجوز یا رابطهٔ فعال → `403`. جزئیات: [secretary.md](secretary.md).
Records are stored so a patient invoice can later reference which account/device a
service payment was made to.
+2
View File
@@ -3,6 +3,8 @@
> **Prefix:** `/api/v1/payment`, `/api/v1/subscription-payment`
> **Supported Gateways:** `mellat` (Mellat Bank SOAP) | `sep` (SEP REST)
> **دسترسی منشی:** `GET /api/v1/my/payments` برای `ROLE_SECRETARY` به مجوز `payments.view` نیاز دارد (`SecretaryAccessChecker`)؛ نبودِ مجوز → `403`. جزئیات: [secretary.md](secretary.md).
---
## معماری (Flow & مسئولیت‌ها)
+39 -2
View File
@@ -69,7 +69,19 @@ Create a secretary for a doctor.
"update": false,
"delete": false
},
"clinic_info": { "view": true, "update": 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
}
}
}
}
@@ -107,7 +119,20 @@ Create a secretary for a doctor.
**Permissions Structure:**
مجموعهٔ منابع (resources) بر اساس صفحات موجود پنل ادمین است. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند؛ فقط `appointments` در بک‌اند enforce می‌شود (`MyAppointmentsController`, `DashboardController`)، بقیه UI/ذخیره‌ای هستند.
مجموعهٔ منابع (resources) بر اساس صفحات و ماژول‌های در دسترسِ منشی است: `appointments`, `patients`, `payments`, `insurances`, `addresses`, `clinic_info`, `inventory`, `tags`. `mergePermissions` هر منبع/اکشن ارسال‌شده را deep-merge می‌کند. منابع `inventory` و `tags` به‌صورت پیش‌فرض همه `false`‌اند (default-deny)؛ بقیه طبق `DEFAULT_PERMISSIONS`.
**اعمال (enforcement):** همهٔ منابع در بک‌اند enforce می‌شوند، نه فقط `appointments`. منبعِ حقیقت، ستون JSON `permission` روی ردیفِ فعالِ `DoctorSecretary` در محیطِ فعالِ کاربر (`UserActiveContext.db_uuid`) است؛ نقطهٔ مرکزی `App\Secretary\Security\SecretaryAccessChecker` (`can` / `canOrNonSecretary` / `denyUnlessGranted`). نبودِ مجوز → `403 ERR_FORBIDDEN_001`. نقشه:
| Resource | Enforced in | Action → endpoint |
| --- | --- | --- |
| `appointments` | `AppointmentAccessChecker`, `MyAppointmentsController`, `DashboardController` | view/create/cancel/update_status |
| `patients` | `PatientController` (خواندن‌ها via `scope()` → بدون `view` هیچ پرونده‌ای؛ نوشتن‌ها با guard) | view/create/update/delete |
| `payments` | `PaymentController::myPayments`, `PaymentMethodController` (bank/pos), `PatientController` (کیف‌پول + پرداختِ جلسه) | view/create/update/delete |
| `insurances` | `InsuranceController` (insurance-pricing, tenant-insurances, service-coverage, doctor-insurance) | view/create/update/delete |
| `inventory` | `InventoryController` (items + packages) | view/create/update/delete |
| `tags` | `TenantTagController` (لیست با `tags.view` یا `patients.view`؛ نوشتن‌ها با `tags.*`) | view/create/update/delete |
نقش‌های غیرمنشی (`ROLE_CLINIC`/`ROLE_DOCTOR`/`ROLE_ADMIN`) از این چک عبور می‌کنند (`canOrNonSecretary` برایشان `true`). منشیِ بدون رابطهٔ فعال/context هیچ مجوزی ندارد → همه‌چیز `403`.
```json
{
@@ -146,6 +171,18 @@ Create a secretary for a doctor.
"clinic_info": {
"view": true,
"update": false
},
"inventory": {
"view": false, // انبار: مشاهده
"create": false, // ایجاد کالا/بسته
"update": false,
"delete": false
},
"tags": {
"view": false, // تگ‌ها؛ لیست با tags.view یا patients.view
"create": false,
"update": false,
"delete": false
}
}
}
@@ -18,6 +18,7 @@ use App\Insurance\Repository\TenantInsuranceRepository;
use App\Insurance\Repository\TenantServiceCoverageRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Shared\Constant\ErrorCodes;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -44,6 +45,7 @@ class InsuranceController extends BaseController
private readonly FileValidatorService $fileValidator,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Patient\Security\PatientRecordScopeResolver $scopeResolver,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly string $projectDir,
) {}
@@ -252,6 +254,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -301,6 +304,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -370,6 +374,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listTenantInsurances(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -400,6 +405,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function activateTenantInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -435,6 +441,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -480,6 +487,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deactivateTenantInsurance(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'update');
if ($err !== null) {
return $err;
@@ -499,6 +507,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view');
if ($err !== null) {
return $err;
@@ -529,6 +538,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function setServiceCoverage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$data = json_decode($request->getContent(), true) ?? [];
[$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update');
@@ -574,6 +584,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addDoctorInsurance(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'create');
$data = json_decode($request->getContent(), true) ?? [];
$doctorId = $data['doctor_id'] ?? null;
$insuranceId = $data['insurance_id'] ?? null;
@@ -614,6 +625,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'view');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -630,6 +642,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateDoctorInsurance(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'update');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -652,6 +665,7 @@ class InsuranceController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteDoctorInsurance(int $id, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'insurances', 'delete');
$doctorInsurance = $this->doctorInsuranceRepo->find($id);
if ($doctorInsurance === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه پزشک یافت نشد', 404);
@@ -11,6 +11,7 @@ use App\Inventory\Entity\InventoryPackage;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Inventory\Service\InventoryService;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
@@ -37,6 +38,7 @@ class InventoryController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
// ── Items ────────────────────────────────────────────────────────────────
@@ -44,6 +46,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-items', methods: ['GET'])]
public function listItems(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -60,6 +63,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-categories', methods: ['GET'])]
public function listCategories(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -80,6 +84,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item', methods: ['POST'])]
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -101,6 +106,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])]
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -123,6 +129,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])]
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
@@ -138,6 +145,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-packages', methods: ['GET'])]
public function listPackages(#[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'view');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -152,6 +160,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package', methods: ['POST'])]
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -173,6 +182,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])]
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'update');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
@@ -199,6 +209,7 @@ class InventoryController extends BaseController
#[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])]
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'inventory', 'delete');
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
+34 -1
View File
@@ -13,6 +13,7 @@ use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Security\PatientRecordScope;
use App\Patient\Security\PatientRecordScopeResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Patient\Service\PatientService;
use App\UserProfile\Entity\UserProfile;
use App\Shared\Constant\ErrorCodes;
@@ -57,6 +58,7 @@ class PatientController extends BaseController
private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo,
private readonly \App\Patient\Repository\SessionPaymentRepository $sessionPaymentRepo,
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly LoggerInterface $logger,
) {}
@@ -141,6 +143,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/wallet/charge', methods: ['POST'])]
public function chargeWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -176,6 +179,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/wallet/withdraw', methods: ['POST'])]
public function withdrawWallet(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -235,6 +239,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/call', methods: ['POST'])]
public function createCall(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -271,6 +276,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/call/{uuid}', methods: ['DELETE'])]
public function deleteCall(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$call = $this->callRepo->findByUuid($uuid);
if ($call === null || !$this->ownsRecord($call->getRecord(), $entityType, $entityId, $user)) {
@@ -302,6 +308,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/message', methods: ['POST'])]
public function createMessage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -327,6 +334,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/message/{uuid}', methods: ['DELETE'])]
public function deleteMessage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$message = $this->messageRepo->findByUuid($uuid);
if ($message === null || !$this->ownsRecord($message->getRecord(), $entityType, $entityId, $user)) {
@@ -361,6 +369,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/note', methods: ['POST'])]
public function createNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -383,6 +392,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/note/{uuid}', methods: ['PATCH'])]
public function updateNote(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -408,6 +418,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/note/{uuid}', methods: ['DELETE'])]
public function deleteNote(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$note = $this->noteRepo->findByUuid($uuid);
if ($note === null || !$this->ownsRecord($note->getRecord(), $entityType, $entityId, $user)) {
@@ -439,6 +450,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/medical-record', methods: ['POST'])]
public function createMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -463,6 +475,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['PATCH'])]
public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -493,6 +506,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])]
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId, $user)) {
@@ -524,6 +538,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])]
public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId, $user)) {
@@ -546,6 +561,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])]
public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$attachment = $this->attachmentRepo->findByUuid($uuid);
if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId, $user)) {
@@ -683,6 +699,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -776,6 +793,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -985,6 +1003,7 @@ class PatientController extends BaseController
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1028,6 +1047,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1103,6 +1123,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments', methods: ['POST'])]
public function addSessionPayment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'create');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1128,6 +1149,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['PATCH'])]
public function updateSessionPayment(string $uuid, string $paymentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'update');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1149,6 +1171,7 @@ class PatientController extends BaseController
#[Route('/api/v1/session/{uuid}/payments/{paymentUuid}', methods: ['DELETE'])]
public function deleteSessionPayment(string $uuid, string $paymentUuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'delete');
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
@@ -1186,7 +1209,17 @@ class PatientController extends BaseController
private function scope(User $user): PatientRecordScope
{
return $this->scopeCache[$user->getId()] ??= $this->scopeResolver->resolve($user);
return $this->scopeCache[$user->getId()] ??= $this->resolveScope($user);
}
/** منشیِ بدون مجوز patients.view هیچ پرونده‌ای نمی‌بیند؛ سایر نقش‌ها از رزولور. */
private function resolveScope(User $user): PatientRecordScope
{
if ($user->hasRole('ROLE_SECRETARY') && !$this->secretaryAccess->can($user, 'patients', 'view')) {
return PatientRecordScope::unknown();
}
return $this->scopeResolver->resolve($user);
}
/** @return array{0: string, 1: int|null} */
@@ -10,6 +10,7 @@ use App\Config\Repository\SiteConfigRepository;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\PaymentManager;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
@@ -35,6 +36,7 @@ class PaymentController extends BaseController
private readonly GatewayFactory $gateways,
private readonly PaymentManager $paymentManager,
private readonly SiteConfigRepository $configRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -505,6 +507,7 @@ class PaymentController extends BaseController
#[Route('/api/v1/my/payments', methods: ['GET'])]
public function myPayments(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'payments', 'view');
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$status = $request->query->get('status');
@@ -4,6 +4,7 @@ namespace App\PaymentMethod\Controller;
use App\Auth\Entity\User;
use App\PaymentMethod\Service\PaymentMethodService;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -25,15 +26,23 @@ class PaymentMethodController extends BaseController
private const ALLOWED_ROLES = ['ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
public function __construct(
private readonly PaymentMethodService $service,
private readonly PaymentMethodService $service,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
/** نقش مجاز + مجوز منشی روی منبع payments (روش‌های پرداخت زیرمجموعهٔ مالی است). */
private function guard(User $user, string $action): void
{
$this->assertRole($user);
$this->secretaryAccess->denyUnlessGranted($user, 'payments', $action);
}
// ---- Bank accounts -----------------------------------------------------
#[Route('/bank-accounts', methods: ['GET'])]
public function listBankAccounts(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'view');
return $this->success($this->service->listBankAccounts($user));
}
@@ -41,7 +50,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts', methods: ['POST'])]
public function createBankAccount(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createBankAccount($user, $data), 201);
@@ -50,7 +59,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts/{uuid}', methods: ['PUT'])]
public function updateBankAccount(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updateBankAccount($user, $uuid, $data));
@@ -59,7 +68,7 @@ class PaymentMethodController extends BaseController
#[Route('/bank-accounts/{uuid}/status', methods: ['PATCH'])]
public function toggleBankAccountStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
return $this->success($this->service->toggleBankAccountStatus($user, $uuid));
}
@@ -69,7 +78,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['GET'])]
public function listPos(#[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'view');
return $this->success($this->service->listPos($user));
}
@@ -77,7 +86,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos', methods: ['POST'])]
public function createPos(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'create');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->createPos($user, $data), 201);
@@ -86,7 +95,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos/{uuid}', methods: ['PUT'])]
public function updatePos(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
$data = json_decode($request->getContent(), true) ?? [];
return $this->success($this->service->updatePos($user, $uuid, $data));
@@ -95,7 +104,7 @@ class PaymentMethodController extends BaseController
#[Route('/pos/{uuid}/status', methods: ['PATCH'])]
public function togglePosStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->assertRole($user);
$this->guard($user, 'update');
return $this->success($this->service->togglePosStatus($user, $uuid));
}
+2
View File
@@ -26,6 +26,8 @@ class DoctorSecretary
'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],
],
];
@@ -0,0 +1,79 @@
<?php
namespace App\Secretary\Security;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* Single entry point that answers «آیا این منشی مجاز به resource/action هست؟».
*
* منبع حقیقت، ستون JSON `permission` روی ردیف فعالِ DoctorSecretary در محیطِ
* فعال کاربر (UserActiveContext.db_uuid) است — دقیقاً مثل PatientRecordScopeResolver
* و DashboardController::secretary. کنترلرهایی که چند نقش می‌گیرند فقط وقتی کاربر
* ROLE_SECRETARY دارد این checker را صدا می‌زنند؛ نقش‌های دیگر دست‌نخورده می‌مانند.
*/
class SecretaryAccessChecker
{
public function __construct(
private readonly UserActiveContextRepository $contextRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly SecretaryPermissionChecker $permissions,
) {}
/** ردیف فعالِ منشی در محیط فعال؛ null اگر محیط تنظیم نشده یا رابطه‌ای نیست. */
public function activeRelation(User $user): ?DoctorSecretary
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
return null;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
}
return null;
}
public function can(User $user, string $resource, string $action): bool
{
$relation = $this->activeRelation($user);
return $relation !== null && $this->permissions->can($relation, $resource, $action);
}
/**
* برای مسیرهایی که چند نقش دارند: فقط منشی را محدود کن. سایر نقش‌ها true.
*/
public function canOrNonSecretary(User $user, string $resource, string $action): bool
{
if (!$user->hasRole('ROLE_SECRETARY')) {
return true;
}
return $this->can($user, $resource, $action);
}
/** 403 اگر منشی مجاز نباشد؛ نقش‌های دیگر بدون تغییر عبور می‌کنند. */
public function denyUnlessGranted(User $user, string $resource, string $action): void
{
if (!$this->canOrNonSecretary($user, $resource, $action)) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
}
@@ -6,8 +6,10 @@ use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Tag\Entity\TenantTag;
use App\Tag\Repository\TenantTagRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -32,11 +34,22 @@ class TenantTagController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly SecretaryAccessChecker $secretaryAccess,
) {}
/** تگ‌ها ابزار پروندهٔ بیمار هم هستند؛ مشاهده با tags.view یا patients.view مجاز است. */
private function guardTagView(User $user): void
{
if (!$this->secretaryAccess->canOrNonSecretary($user, 'tags', 'view')
&& !$this->secretaryAccess->canOrNonSecretary($user, 'patients', 'view')) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
}
#[Route('/api/v1/tenant-tags', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
$this->guardTagView($user);
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -51,6 +64,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'create');
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
@@ -79,6 +93,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'update');
$tag = $this->ownedTag($uuid, $user);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);
@@ -111,6 +126,7 @@ class TenantTagController extends BaseController
#[Route('/api/v1/tenant-tag/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$this->secretaryAccess->denyUnlessGranted($user, 'tags', 'delete');
$tag = $this->ownedTag($uuid, $user);
if ($tag === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'برچسب یافت نشد', 404);
+37 -1
View File
@@ -162,7 +162,8 @@ class PaymentMethodTest extends ApiTestCase
public function testTogglePosStatus(): void
{
$user = $this->createUser(['ROLE_SECRETARY']);
// منشی به روش‌های پرداخت فقط با مجوز payments از طریق رابطهٔ فعال دسترسی دارد.
$user = $this->createSecretaryWithPayments(['view' => true, 'create' => true, 'update' => true]);
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'تجارت',
'terminal_number' => '345678',
@@ -175,6 +176,41 @@ class PaymentMethodTest extends ApiTestCase
$this->assertFalse($toggled['data']['is_active']);
}
public function testSecretaryWithoutPaymentsPermissionCannotManagePos(): void
{
$user = $this->createSecretaryWithPayments(['view' => false, 'create' => false, 'update' => false]);
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
'bank_name' => 'تجارت',
'terminal_number' => '345678',
]);
$this->assertSame(403, $this->responseCode());
}
/** منشی با رابطهٔ فعالِ کلینیک + context + مجوز payments مشخص. */
private function createSecretaryWithPayments(array $payments): \App\Auth\Entity\User
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new \App\Clinic\Entity\Clinic($owner);
$this->em->persist($clinic);
$doctor = new \App\Doctor\Entity\Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new \App\Secretary\Entity\DoctorSecretary(
$doctor, $secretary, \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC, $clinic
);
$rel->mergePermissions(['resources' => ['payments' => $payments]]);
$this->em->persist($rel);
$this->em->persist(new \App\Auth\Entity\UserActiveContext($secretary, $clinic->getUuid()));
$this->em->flush();
return $secretary;
}
public function testPosListIsolatedPerUser(): void
{
$a = $this->createUser(['ROLE_CLINIC']);
@@ -0,0 +1,80 @@
<?php
namespace App\Tests\Secretary;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* A secretary's per-resource permission JSON (DoctorSecretary.permission) must be
* enforced by the API, not only for appointments. Resources the secretary was not
* granted return 403; granted ones pass.
*/
class SecretaryResourceEnforcementTest extends ApiTestCase
{
private function makeClinicSecretary(): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->em->persist($rel);
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid()));
return [$secretary, $rel];
}
public function testInventoryDeniedByDefault(): void
{
// DEFAULT_PERMISSIONS: inventory.* = false
[$secretary] = $this->makeClinicSecretary();
$this->em->flush();
$this->authJson('GET', '/api/v1/inventory-items', $secretary);
$this->assertSame(403, $this->responseCode());
}
public function testInventoryAllowedWhenGranted(): void
{
[$secretary, $rel] = $this->makeClinicSecretary();
$rel->mergePermissions(['resources' => ['inventory' => ['view' => true]]]);
$this->em->flush();
$this->authJson('GET', '/api/v1/inventory-items', $secretary);
$this->assertSame(200, $this->responseCode());
}
public function testPatientCreateDeniedByDefault(): void
{
// DEFAULT_PERMISSIONS: patients.create = false (view is true)
[$secretary] = $this->makeClinicSecretary();
$this->em->flush();
$this->authJson('POST', '/api/v1/patient', $secretary, ['name' => 'x']);
$this->assertSame(403, $this->responseCode());
}
public function testInventoryCreateDeniedButViewGranted(): void
{
[$secretary, $rel] = $this->makeClinicSecretary();
$rel->mergePermissions(['resources' => ['inventory' => ['view' => true, 'create' => false]]]);
$this->em->flush();
// مشاهده مجاز
$this->authJson('GET', '/api/v1/inventory-items', $secretary);
$this->assertSame(200, $this->responseCode());
// ایجاد ممنوع
$this->authJson('POST', '/api/v1/inventory-item', $secretary, ['name' => 'گاز استریل']);
$this->assertSame(403, $this->responseCode());
}
}