Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 75d5052f72 feat(tenant): enforce environment isolation in the ORM layer
Phase 4 of the tenant-marking series. Until now isolation depended on every
query remembering its own WHERE clause. With 82 entities and 844 tests, that is
not a guarantee — it is a hope. MariaDB has no row-level security, so the
backstop has to live in Doctrine.

TenantFilter appends (entity_type, entity_id) to every DQL query on a
tenant-owning entity. It ships disabled and TenantFilterSubscriber turns it on
per request.

The filter engages only for a **chosen** environment — an explicit clinic_uuid
on the request, or a stored UserActiveContext. EntityContext now records which
of the two produced it. Locking a user to the role fallback instead would hide
data they are entitled to: a clinic-member doctor who never switched context
lost every appointment belonging to that clinic. Five tests caught exactly that
before the gate was added. Admins and unauthenticated marketplace traffic stay
outside the filter by design.

Two findings from running it rather than reasoning about it:

- Dereferencing a lazy proxy whose target the filter excluded raises
  EntityNotFoundException, which surfaced as 500 on four patient endpoints.
  ExceptionSubscriber now maps it to 404: outside your environment means it does
  not exist for you. It is logged at info level so a genuinely broken FK is still
  visible.
- EntityManager::find() by primary key IS filtered in Doctrine ORM 3, contrary
  to the limitation carried over from older versions. The stronger guarantee is
  pinned by a test so a future regression is noticed, and the documented table
  was corrected.

The filter also caught a real leak: a clinic secretary's appointment list
filtered by doctor id alone, so a doctor's personal-practice booking appeared in
the clinic list. The test had been asserting that behaviour.

GlobalTables classifies all 82 entities into four states — carries a tenant,
deliberately global, aggregate child, or recorded debt — and
TenantSchemaCoverageTest fails on anything unclassified. Aggregate children
declare their root explicitly, because several attach through a scalar FK rather
than a Doctrine association and cannot be inferred from metadata; the test walks
each chain to a tenant-owning root. Financial tables stay in DEFERRED with a
ceiling assertion so the list cannot grow quietly.

Deliberately not built: the prePersist assignment listener from the plan. The
tenant columns are NOT NULL without a default, so a missing assignTenant()
already fails loudly at flush — phase 2 surfaced 123 such failures. A listener
would add silent auto-assignment where the current behaviour is an explicit
crash.

EXPLAIN with the filter's conditions still picks idx_appointments_tenant_slot
and uniq_patient_record.

Tests: 844 passing. PHPStan unchanged at its 17 pre-existing errors, none in
files touched here.

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

132 lines
8.0 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.
# جداسازی محیط (tenancy)
هر دادهٔ عملیاتی در ClinicPro به یک **محیط** تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند می‌گوید آن محیط چطور تعیین می‌شود، کجا اجبار می‌شود، و — مهم‌تر — **کجا اجبار نمی‌شود**.
---
## هویت محیط
جفت `(entity_type, entity_id)` روی خودِ جدول:
| ستون | مقدار |
|---|---|
| `entity_type` | `doctor` یا `clinic``VARCHAR(10)` در هر ۲۰ جدول tenant-دار |
| `entity_id` | شناسهٔ همان پزشک یا کلینیک |
موجودیت‌ها این جفت را از trait مشترک می‌گیرند:
```php
use App\Shared\Tenant\TenantOwnedTrait;
$appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));
// یا وقتی جفت از قبل به‌صورت اسکالر حل شده:
$rule->assignTenantPair('clinic', $clinicId);
```
ستون‌ها `NOT NULL` بدون مقدار پیش‌فرض‌اند. اگر سازنده‌ای `assignTenant()` را فراموش کند، `flush` می‌شکند — عمدی است: ردیفِ بی‌محیط بی‌صدا از دید همه پنهان می‌شود.
---
## تعیین محیط جاری
`App\Shared\Context\EntityContextResolver` تنها نقطهٔ تصمیم است. اولویت:
```
clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده > fallback نقش
```
دو مورد اول **انتخابِ کاربر**اند و `EntityContext::$chosen` را `true` می‌کنند. سومی حدس است.
### ماتریس نقش‌ها
| نقش | محیط مؤثر |
|---|---|
| پزشک مستقل | همیشه `('doctor', id)` |
| پزشک عضو یک یا چند کلینیک | طبق محیط فعال؛ بیرون از آن، مطب شخصی |
| پزشکِ مالک کلینیک | طبق محیط فعال؛ در محیط کلینیک بدون محدودیت `ClinicDoctorPermission` |
| مدیر/مالک کلینیک | همیشه `('clinic', id)` |
| منشی | فقط از محیط فعال؛ بدون آن `unknown` |
`user_active_context` هم `db_uuid` دارد هم `db_type`، پس حل محیط یک lookup است نه دو تا.
---
## اجبار: `TenantFilter`
یک `SQLFilter` که به هر کوئری DQL روی موجودیت‌های tenant-دار شرط `(entity_type, entity_id)` اضافه می‌کند.
**پیش‌فرض خاموش است.** `TenantFilterSubscriber` آن را در `kernel.request` روشن می‌کند و فقط وقتی که:
- کاربر احراز شده باشد، **و**
- `ROLE_ADMIN` نداشته باشد (پنل ادمین ذاتاً cross-tenant است)، **و**
- محیطش **انتخاب‌شده** باشد (`$context->chosen`)
> **چرا فقط محیط انتخاب‌شده؟** کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست». قفل‌کردنش روی حدسِ نقش، دادهٔ کلینیکی‌ای را که قانوناً حقش است پنهان می‌کند — مثلاً پزشکِ عضوی که هرگز محیط عوض نکرده، نوبت‌های کلینیکش را از دست می‌داد. برای این کاربران، دسترسی را همان checkerهای دامنه تعیین می‌کنند (رفتار پیش از فاز ۴).
### چه تضمین می‌دهد و چه نمی‌دهد
| مسیر | فیلتر اعمال می‌شود؟ |
|---|---|
| DQL و QueryBuilder | ✅ |
| `findBy` / `findOneBy` | ✅ |
| `EntityManager::find()` با کلید اصلی | ✅ — در Doctrine ORM 3 (تثبیت‌شده در `TenantFilterLeakTest`) |
| بارگذاری تنبل کالکشن‌ها | ✅ |
| entity که از قبل در identity map است | ❌ دوباره کوئری نمی‌شود |
| `getReference()` | ❌ |
| SQL خام DBAL | ❌ |
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها».
### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳
با فیلتر روشن، رکوردی که مال محیط دیگری است **وجود ندارد**، نه اینکه «ممنوع» باشد:
- کاربر با محیط انتخاب‌شدهٔ A که رکورد محیط B را باز کند → `404`
- مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته → `EntityNotFoundException` که `ExceptionSubscriber` به `404` با `ERR_NOT_FOUND_001` نگاشت می‌کند (و در سطح `info` لاگ می‌شود تا FK واقعاً شکسته هم دیده شود)
---
## طبقه‌بندی جدول‌ها — `App\Shared\Tenant\GlobalTables`
هر entity دقیقاً در یکی از چهار وضعیت است، و `TenantSchemaCoverageTest` این را اجبار می‌کند:
| وضعیت | یعنی | نمونه |
|---|---|---|
| جفت tenant دارد | فیلتر پوششش می‌دهد | `appointments`، `patient_records`، `service_sections` |
| `ENTITIES` | عمداً سراسری | `cities`، `specialties`، `users`، `blogs` |
| `AGGREGATE_CHILDREN` | محیط را از ریشه به ارث می‌برد | `patient_notes``patient_records` |
| `DEFERRED` | بدهی ثبت‌شده، هنوز طبقه‌بندی نشده | جدول‌های مالی |
### ⚠️ فرزندان aggregate تور ایمنی ندارند
فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند.
### بدهی باقی‌مانده
جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد.
---
## entity جدید می‌سازی؟
۱. اگر به یک محیط تعلق دارد → `use TenantOwnedTrait;` و در نقطهٔ ساخت `assignTenant()` را صدا بزن
۲. اگر ندارد → با **دلیل** در `GlobalTables::ENTITIES` ثبتش کن
۳. اگر فرزند یک aggregate است → در `GlobalTables::AGGREGATE_CHILDREN` با ریشهٔ صریح
۴. تست را اجرا کن: `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
ایندکس‌ها: `entity_type, entity_id` باید **ستون‌های اول** هر ایندکس ترکیبیِ لیست باشند، وگرنه MariaDB برای شرط فیلتر از آن استفاده نمی‌کند.
---
## تست‌ها
| فایل | چه چیزی را تضمین می‌کند |
|---|---|
| `tests/Shared/EntityContextResolverTest.php` | ماتریس ۵ نقش |
| `tests/Shared/TenantIsolationMatrixTest.php` | هر نقش فقط دادهٔ محیط خودش را از API می‌گیرد |
| `tests/Shared/TenantFilterLeakTest.php` | فیلتر **به‌تنهایی** cross-tenant را می‌بندد؛ مسیر عمومی و ادمین باز می‌مانند |
| `tests/Shared/TenantSchemaCoverageTest.php` | هیچ entity طبقه‌بندی‌نشده نمی‌ماند |
| `tests/Appointment/BookingTenantTest.php` | نوبت در محیط درست ثبت می‌شود |
| `tests/Secretary/SecretaryMultiClinicScopeTest.php` | یک منشی، یک پزشک، چند کلینیک |