Files
clinicpro/.claude/prompt/tenant-05-audit-and-docs.md
T
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:59:22 +03:30

249 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# فاز ۵ — آدیت نقاط فرار، تأیید کلاینت‌ها و مستندسازی
> پرامپت پنجم و آخر سری. **پیش‌نیاز: فازهای ۱ تا ۴ کامل و سبز.**
> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → **۵. آدیت و مستندسازی (همین فایل)**
## زمینه
فاز ۴ فیلتر خودکار را روشن کرد، اما فیلتر Doctrine روی SQL خام DBAL اعمال نمی‌شود. پروژه هشت فایل دارد که مستقیم SQL می‌زنند:
```
src/Category/Service/CategoryImporter.php
src/Doctor/Command/PurgeDoctorsCommand.php
src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
src/Admin/Controller/AdminApiController.php
src/Shared/Controller/HealthController.php
src/Shared/Command/SeedDemoDataCommand.php
src/Representation/Controller/RepresentationActionController.php
src/Billing/Repository/ClaimRepository.php
```
هم‌زمان، تغییرات فاز ۲ و ۳ ممکن است شکل پاسخ برخی endpointها را عوض کرده باشند و **build هیچ‌کدام از سه کلاینت خطا نمی‌دهد** — نه پنل ادمین، نه `nobat724_front`، نه `clinic-pro-tauri`.
## مشکل / هدف
**مشکل:** سه دستهٔ ریسک باقی‌مانده که هیچ‌کدام خودکار کشف نمی‌شوند:
1. **نقاط فرار از فیلتر** — SQL خام، `EntityManager::find()`، `getReference()`
2. **رگرسیون خاموش کلاینت‌ها** — تغییر قرارداد API که فقط در رانتایم می‌شکند
3. **دانش از دست رفته** — بدون سند، توسعه‌دهندهٔ بعدی repository جدیدی می‌نویسد که فرض می‌کند فیلتر همه‌جا کار می‌کند
**هدف:** آدیت کامل، تأیید دستی سه کلاینت با هر پنج نقش، سند معماری، و ابزار بکاپ per-tenant که مزیت عملی جداسازی را در دسترس بگذارد.
## معیار پذیرش
- ✅ موفق: هر هشت فایل SQL خام آدیت و طبقه‌بندی شده‌اند (نیاز به `WHERE` دارد / سراسری است / ادمین است)؛ سایت عمومی `nobat724_front` با هر سه صفحهٔ اصلی (لیست پزشکان شهر، صفحهٔ پزشک، صفحهٔ کلینیک) داده برمی‌گرداند؛ پنل ادمین با هر پنج نقش ماتریس بدون خطای کنسول کار می‌کند.
- ❌ خطا: `ddev exec php bin/console app:tenant:dump --tenant=clinic:999` برای tenant ناموجود → پیام خطای واضح و خروج با کد غیرصفر، نه فایل خالی و نه stack trace.
- ⚠️ مرزی: `app:tenant:dump` برای tenantی که هیچ داده‌ای ندارد (کلینیک تازه ثبت‌نام‌شده) → فایل معتبر با ساختار جدول‌ها و صفر ردیف، بدون خطا.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Category/Service/CategoryImporter.php` | `SET FOREIGN_KEY_CHECKS` + `DELETE FROM` روی جدول‌های مرجع |
| `src/Doctor/Command/PurgeDoctorsCommand.php` | حذف انبوه با SQL خام |
| `src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php` | `DELETE c FROM ... JOIN` |
| `src/Admin/Controller/AdminApiController.php` | کوئری‌های ادمین — عمداً cross-tenant |
| `src/Shared/Controller/HealthController.php` | `SELECT 1` — بی‌خطر |
| `src/Shared/Command/SeedDemoDataCommand.php` | `INSERT` انبوه دادهٔ نمونه |
| `src/Representation/Controller/RepresentationActionController.php` | پنل نماینده |
| `src/Billing/Repository/ClaimRepository.php` | گزارش‌های صورتحساب |
| `src/Shared/Tenant/GlobalTables.php` | فهرست TODOهای فاز ۴ که اینجا تعیین تکلیف می‌شوند |
| `docs/api/` | مستندات endpointهای متأثر |
| `src/Shared/Command/TenantDumpCommand.php` | کامند بکاپ per-tenant — جدید |
## وضعیت فعلی
نمونهٔ SQL خامی که فیلتر tenant نمی‌بیند:
```php
// src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
// ...
$this->conn->executeStatement(
"DELETE c FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}",
$params,
);
$deleted = $this->conn->executeStatement("DELETE d FROM doctors d WHERE {$where}", $params);
```
```php
// src/Category/Service/CategoryImporter.php
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
$this->db->executeStatement('DELETE FROM ' . $table);
foreach ($rows as $row) {
$this->db->insert($table, $row);
}
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
```
## وظایف
### ۱. آدیت هشت فایل SQL خام
هر فایل را باز کن و هر دستور SQL را در یکی از این سه دسته بگذار، سپس **در docblock همان متد** دسته و دلیل را بنویس:
| دسته | معنی | اقدام |
|---|---|---|
| **سراسری** | فقط روی جدول‌های `GlobalTables` کار می‌کند | یادداشت در docblock، بدون تغییر کد |
| **ادمین** | عمداً cross-tenant است و پشت `ROLE_ADMIN` قفل است | تأیید کن `#[IsGranted('ROLE_ADMIN')]` واقعاً هست |
| **نیازمند دامنه** | روی جدول tenant-دار کار می‌کند و کاربر غیرادمین به آن می‌رسد | `WHERE entity_type/entity_id` دستی اضافه کن |
خروجی این وظیفه یک جدول در گزارش پایانی است، نه فقط تغییر کد. برای هر فایل: مسیر، دسته، دلیل.
نکات از پیش معلوم:
- `HealthController` فقط `SELECT 1` است → سراسری، بدون اقدام.
- `CategoryImporter` روی `categories` کار می‌کند که در `GlobalTables` است → سراسری. اما تأیید کن که `$table` واقعاً از یک allowlist می‌آید و از ورودی کاربر نمی‌آید — اگر می‌آید، این یک مسئلهٔ امنیتی جدا از tenant است و باید گزارش شود.
- `AdminApiController` و `SeedDemoDataCommand` و دو `Purge*Command` مسیر ادمین/کنسول‌اند → دستهٔ ادمین، فقط تأیید گارد.
- `ClaimRepository` و `RepresentationActionController` را با دقت بخوان — این دو محتمل‌ترین موارد دستهٔ «نیازمند دامنه» هستند.
**نحوه تست:** برای هر مورد دستهٔ «نیازمند دامنه»، یک تست بنویس که با کاربر tenant A فراخوانی شود و هیچ ردیف tenant B برنگردد. برای بقیه، تست لازم نیست ولی یادداشت docblock اجباری است.
---
### ۲. آدیت `find()` و `getReference()` روی entityهای tenant-دار
فیلتر روی این دو اعمال نمی‌شود. نقاط را پیدا کن:
```bash
ddev exec grep -rn "->find(\|getReference(" src --include="*.php" | grep -v "findBy\|findOneBy\|findAll"
```
برای هر مورد، تعیین کن روی entityِ tenant-دار است یا نه. اگر بله، یکی از دو کار:
- **جایگزینی با repository + DQL** (ترجیح) — فیلتر خودکار اعمال می‌شود
- **چک صریح بعد از `find()`** اگر جایگزینی ممکن نبود:
```php
$item = $this->repo->find($id);
if ($item === null || $item->getEntityId() !== $context->id || $item->getEntityType() !== $context->type) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, null, 403);
}
```
**نحوه تست:** برای هر نقطهٔ اصلاح‌شده، یک تست: کاربر tenant A با شناسهٔ رکورد tenant B → `403`، نه `200` و نه `404` مبهم.
---
### ۳. تعیین تکلیف TODOهای `GlobalTables`
فاز ۴ فهرستی از entityهای طبقه‌بندی‌نشده تولید کرد (احتمالاً جدول‌های مالی و فرزندان aggregate). هر کدام را در یکی از سه دسته قطعی کن:
| دسته | مثال | اقدام |
|---|---|---|
| فرزند aggregate | `patient_attachments`, `session_payments`, `claim_items`, `invoice_items` | در `GlobalTables` با دلیل «tenant از ریشه به ارث می‌رسد»؛ تأیید کن کوئری‌هایشان همیشه از ریشه JOIN می‌خورند |
| نیازمند tenant | آنچه مستقیم کوئری می‌شود و ریشهٔ tenant-دار ندارد | ستون اضافه شود — **پرامپت فاز ۶ برایش نوشته شود، در این فاز پیاده نشود** |
| سراسری | دادهٔ مرجع | ثبت با دلیل |
**دستهٔ «نیازمند tenant» را در این فاز پیاده نکن.** جدول‌های مالی مالکیت دوگانه دارند (پرداخت‌کننده در برابر دریافت‌کننده) و migration اشتباه روی داده‌های مالی قابل برگشت نیست. خروجی این وظیفه برای آن دسته فقط یک فهرست مستند در گزارش پایانی است، به‌علاوهٔ پیشنهاد اینکه فاز ۶ لازم است یا نه.
**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` سبز، و هیچ دلیلی در `GlobalTables` با متن `TODO` باقی نمانده باشد.
---
### ۴. کامند بکاپ per-tenant
مزیت عملی‌ای که کاربر از «دیتابیس مجزا» می‌خواست، بدون هزینهٔ آن:
```php
// src/Shared/Command/TenantDumpCommand.php
#[AsCommand(name: 'app:tenant:dump', description: 'خروجی SQL از دادهٔ یک محیط (پزشک یا کلینیک)')]
```
ورودی: `--tenant=clinic:12` یا `--tenant=doctor:5`، خروجی: `--output=/path/file.sql`.
پیاده‌سازی: فهرست جدول‌های tenant-دار را از metadata بگیر (همان منطق `TenantSchemaCoverageTest`)، و برای هر کدام `mysqldump --where` بزن:
```bash
mysqldump db <table> --where="entity_type='clinic' AND entity_id=12"
```
**اعتبارسنجی ورودی اجباری است:** `entity_type` باید یکی از `doctor`/`clinic` باشد و `entity_id` عدد صحیح — این مقادیر مستقیم داخل رشتهٔ `--where` می‌روند و بدون اعتبارسنجی، تزریق شل/SQL می‌شود. مقدار را با `in_array()` و `(int)` قفل کن، نه با escape.
اگر tenant وجود نداشت (نه کلینیک نه پزشک با آن شناسه)، پیام خطای فارسی و `Command::FAILURE`.
**نحوه تست:**
```bash
ddev exec php bin/console app:tenant:dump --tenant=clinic:1 --output=/tmp/c1.sql
ddev exec php bin/console app:tenant:dump --tenant=clinic:999999 --output=/tmp/x.sql # باید FAILURE بدهد
ddev exec php bin/console app:tenant:dump --tenant="clinic:1 OR 1=1" --output=/tmp/y.sql # باید رد شود
```
و بررسی محتوای `/tmp/c1.sql`: هیچ ردیفی با `entity_id` غیر از ۱ نباشد.
---
### ۵. تأیید دستی سه کلاینت
هیچ تست خودکاری این را پوشش نمی‌دهد (guidelines §۳). هر سه را دستی بررسی کن و نتیجه را گزارش بده.
**الف) پنل ادمین ClinicPro** — با هر پنج نقش ماتریس وارد شو (اعتبارنامه‌ها از `TEST_USERS.md` / حساب‌های QA محلی):
| نقش | چه باید ببیند |
|---|---|
| پزشک مستقل | فقط دادهٔ مطب شخصی |
| پزشک عضو کلینیک، محیط = کلینیک | دادهٔ کلینیک، محدود به بیماران خودش |
| پزشک عضو کلینیک، محیط = شخصی | فقط دادهٔ مطب شخصی |
| پزشک مالک کلینیک | همهٔ دادهٔ کلینیک، بدون محدودیت permission |
| مدیر کلینیک | همهٔ دادهٔ کلینیک |
برای هر نقش، صفحات نوبت‌ها، بیماران، خدمات، انبار و تخفیف‌ها را باز کن و کنسول مرورگر را برای خطا چک کن. برای این کار می‌توانی از skill `qa-clinicpro` استفاده کنی.
**ب) `nobat724_front`** — مسیرهای عمومی نباید فیلتر بخورند:
- لیست پزشکان یک شهر
- صفحهٔ یک پزشک (`/doctor/{uuid}`)
- صفحهٔ یک کلینیک (`/clinic/{uuid}`)
- گرفتن نوبت به‌عنوان بیمار
هر کدام که خالی برگشت، یعنی فیلتر روی مسیر عمومی روشن شده — باگ فاز ۴.
**ج) `clinic-pro-tauri`**`src/service/response.js` همان `/api/v1/...` را صدا می‌زند. اگر شکل پاسخ نوبت یا خدمات عوض شده، اینجا در رانتایم می‌شکند. حداقل مسیر ورود و لیست نوبت‌ها را بررسی کن.
**نحوه تست:** همان بالا — گزارش با ذکر اینکه هر مورد تأیید شد یا نه. اگر موردی بررسی نشد، صریح بنویس بررسی نشد؛ ننویس «احتمالاً سالم است».
---
### ۶. مستندسازی
**الف) سند معماری** — فایل `docs/architecture/tenancy.md` (جدید):
- تعریف tenant: جفت `(entity_type, entity_id)` با مقادیر `doctor`/`clinic`
- ماتریس پنج نقش با نحوهٔ حل محیط هر کدام
- `EntityContextResolver` تنها نقطهٔ تصمیم
- `TenantFilter` چه تضمین می‌کند و **چه تضمین نمی‌کند** (جدول محدودیت‌ها از فاز ۴)
- `GlobalTables` و قاعدهٔ افزودن به آن
- «entity جدید می‌سازی؟ این چک‌لیست» — سه خط
**ب) `docs/api/`** — هر فایلی که endpointش شکل پاسخ عوض کرده، با **JSON واقعیِ خروجی اجرا** به‌روز شود، نه دست‌ساز. حداقل: `appointment.md`، `patient.md`، `discount.md`، `secretary.md`.
**ج) `CLAUDE.md`** — یک بند کوتاه زیر «قواعد غیرقابل‌مذاکره» یا بخش الگوها:
```markdown
## جداسازی محیط (tenant)
هر entity جدید یا `TenantOwnedTrait` می‌گیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables`
ثبت می‌شود. تست `TenantSchemaCoverageTest` هر دو حالت را اجبار می‌کند.
محیط جاری همیشه از `EntityContextResolver` گرفته می‌شود، نه از نقش کاربر.
جزئیات: `docs/architecture/tenancy.md`
```
**نحوه تست:** سند را بخوان و مطمئن شو با کد واقعی می‌خواند — به‌ویژه جدول محدودیت‌های فیلتر. سند ناهم‌خوان بدتر از نبود سند است (guidelines §۴).
---
### ۷. گزارش پایانی
یک خلاصه بنویس شامل:
1. جدول آدیت هشت فایل SQL خام (مسیر، دسته، اقدام)
2. فهرست نقاط `find()` اصلاح‌شده
3. entityهای دستهٔ «نیازمند tenant» که به فاز بعدی موکول شدند + پیشنهاد اینکه فاز ۶ لازم است یا نه
4. نتیجهٔ تأیید دستی سه کلاینت — با ذکر صریح هر موردی که بررسی نشد
5. هر رفتاری که عمداً عوض شد (مثل رفع باگ یکتایی `doctor_secretaries` در فاز ۳)
## نکات مهم
- **این فاز کد کمی دارد و بررسی زیاد.** وسوسهٔ رد شدن سریع از وظیفه‌های ۱ و ۵ همان چیزی است که نشتی را زنده نگه می‌دارد. اگر وقت کم آمد، وظیفهٔ ۴ (کامند بکاپ) را به تعویق بینداز، نه آدیت را.
- **هیچ migration جدیدی در این فاز نیست.** اگر لازم شد، یعنی فازهای قبلی ناقص مانده‌اند — برگرد و همان‌جا درست کن، اینجا وصله نزن.
- **اعتبارسنجی ورودی `app:tenant:dump` امنیتی است، نه سلیقه‌ای.** مقدار مستقیم داخل رشتهٔ shell و SQL می‌رود.
- **`SET FOREIGN_KEY_CHECKS=0` در سه فایل**: این الگو در MariaDB سراسری است و در طول اجرا تمام محافظت ارجاعی را برای همهٔ کانکشن‌ها خاموش می‌کند. جزو دامنهٔ این فاز نیست، ولی اگر روی جدول tenant-دار اجرا می‌شود، در گزارش به‌عنوان ریسک ذکرش کن.
- **کلاینت‌های cross-repo تست خودکار ندارند** (guidelines §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.