# جداسازی محیط (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 است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ `ServiceItem` این کار را با delegate به `ServiceSection` انجام می‌دهد. ### uuid از درخواست — خطرناک‌ترین الگو سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: | مسیر | چه بود | |---|---| | `GET /api/v1/appointment-service-slots` | با uuid سرویسِ محیط دیگر، وجود/فعال‌بودن/مدتش لو می‌رفت و اسلات‌ها با آن محاسبه می‌شد | | `POST /api/v1/my/appointment` | بخش/سرویس/پرسنلِ محیط دیگر به نوبت **چسبانده و ذخیره** می‌شد | | `POST/PATCH` مراجعه | قیمتِ سرویسِ محیط دیگر وارد **فاکتور** می‌شد و `SessionService` با آن ذخیره می‌ماند | `App\Shared\Tenant\TenantOwnershipChecker` نقطهٔ واحد این بررسی است: ```php $this->tenantOwnership->belongsTo($context, $entity); // با EntityContext $this->tenantOwnership->belongsToPair($type, $id, $entity); // وقتی جفت اسکالر است $this->tenantOwnership->allBelongTo($context, $entities); // یک بیگانه = رد کل فهرست ``` موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است. `TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### بدهی باقی‌مانده جدول‌های مالی (`payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings`، `bank_accounts`، `pos_devices`) در `DEFERRED` ثبت شده‌اند. مالکیتشان دوگانه است — پرداخت‌کننده در برابر دریافت‌کننده — و تصمیم درباره‌شان تحلیل جدا می‌خواهد. `testDeferredDebtDoesNotGrow` جلوی رشد بی‌صدای این فهرست را می‌گیرد. --- ## SQL خام — آدیت‌شده فیلتر روی `Connection::executeQuery/executeStatement` اعمال نمی‌شود. هر نقطه‌ای که SQL خام می‌زند بررسی و طبقه‌بندی شده: | فایل | دسته | چرا امن است | |---|---|---| | `Billing/Repository/ClaimRepository` | **tenant-دار** | هر سه کوئری `WHERE c.entity_type = :type AND c.entity_id = :id` دارند؛ `ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard` تثبیتش می‌کند | | `Admin/Controller/AdminApiController` | ادمین | `#[IsGranted('ROLE_ADMIN')]` سطح کلاس؛ عمداً cross-tenant | | `Representation/Controller/RepresentationActionController` | نماینده | فقط `doctors`، اسکوپ `representation_id` | | `Category/Service/CategoryImporter` | سراسری | فقط جدول‌های مرجع؛ نام جدول از ثابت `TABLES` می‌آید (نه ورودی کاربر) و `isValidBundle()` + `ROLE_ADMIN` گیتش می‌کنند | | `Doctor/Command/Purge*Command` · `Shared/Command/SeedDemoDataCommand` | کنسول | dry-run پیش‌فرض، `--force` لازم، prod از سطح kernel مسدود | | `Shared/Controller/HealthController` | سراسری | `SELECT 1` | | `Shared/Logging/DbLogger` | سراسری | `app_log` در `GlobalTables` | `getReference()` در کل `src/` یک مورد است و روی `User` (سراسری) — بدون اثر tenant. --- ## بکاپ per-tenant ```bash php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql ``` جدول‌ها از metadata خوانده می‌شوند (همان معیار `TenantFilter`)، پس جدولی که فردا جفت tenant بگیرد خودکار وارد خروجی می‌شود. ⚠️ فرزندان aggregate ستون محیط ندارند و در خروجی **نمی‌آیند**. برای بکاپ کامل یک محیط، آن‌ها باید از ریشه دنبال شوند. ورودی `--tenant` با regex بسته اعتبارسنجی می‌شود چون مستقیم داخل `--where` و خط فرمان می‌رود؛ `TenantDumpCommandTest` هفت ورودی بدشکل (تزریق SQL و شل، نوع ناشناخته، id صفر/منفی) را می‌سنجد. --- ## 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` | یک منشی، یک پزشک، چند کلینیک | | `tests/Shared/TenantOwnershipCheckerTest.php` | خودِ checker: null، محیط حل‌نشده، نوعِ متفاوت با شناسهٔ یکسان | | `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده | | `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | | `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود |