# جداسازی محیط (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` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### جدول‌های مالی بدهی صفر است. `testThereIsNoUnclassifiedDebtLeft` خالی‌ماندنش را اجبار می‌کند. استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ **گیرنده** تعلق دارد و پرداخت‌کننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است. | جدول | تصمیم | چرا | |---|---|---| | `payments` | جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول | | `payment_logs` · `financial_breakdowns` | فرزند `Payment` | با FK به پرداخت لنگر می‌خورند | | `secretary_earnings` | فرزند `FinancialBreakdown` | زنجیره تا `payments` می‌رسد | | `wallet_transactions` | `ENTITIES` + انتسابِ per-ردیف | کیف پولِ **شخص** است و موجودی سراسری می‌ماند؛ ولی هر ردیف محیطِ ثبتش را در `recorded_entity_*` نگه می‌دارد تا دفتری که کلینیک می‌بیند به همان محیط محدود شود (پایین) | | `settlements` | `ENTITIES` | برداشت از همان کیف پولِ شخصی (`SettlementController` موجودی را با `getWalletBalance(user)` می‌سنجد) | | `bank_accounts` · `pos_devices` | جفت محیط، **تهی‌پذیر** | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) | نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به `payments` لنگر بزند. `SecretaryEarningRepository::reportFor` این کار را با `join('b.payment','p')` می‌کند و فیلتر روی همان می‌نشیند؛ `FinancialChainTenantTest` همین را می‌سنجد. ### کیف پول: موجودی سراسری، دفتر per-محیط کیف پول ستون tenant ندارد و نباید داشته باشد: پول مالِ شخص است و اگر فیلتر روی موجودی می‌نشست، بیمار در هر محیط بخشی از پول خودش را می‌دید. ولی دفترِ تراکنش را کلینیک هم می‌بیند (`GET /api/v1/patient/{uuid}/wallet/transactions` و `recent_transactions`)، و گاردِ `ownsRecord` فقط **پرونده** را می‌سنجد نه سطرها. پس بدون تفکیک، کلینیک A می‌خواند بیمار در کلینیک B چه پرداخت کرده و چه کسی ثبتش کرده. راه‌حل، انتساب per-ردیف است نه مالکیت per-کیف‌پول: ```php // WalletTransaction — نامِ ستون‌ها عمداً entity_type/entity_id نیست #[ORM\Column(name: 'recorded_entity_type', ...)] private ?string $recordedEntityType = null; #[ORM\Column(name: 'recorded_entity_id', ...)] private ?int $recordedEntityId = null; ``` ⚠️ **نام‌ها باید همین بمانند.** `TenantFilter` روی `entityType/entityId` کلید می‌زند؛ اگر این دو همان نام را می‌گرفتند، فیلتر خودکار روی محاسبهٔ موجودی هم می‌نشست و پول بیمار را نصف نشان می‌داد. اینجا انتساب است، نه مالکیت. نتیجه: `balance_rials` سراسری، سطرهای دفتر per-محیط — پس **جمع سطرها با موجودی برابر نیست** و UI باید بگوید. ردیف‌های بی‌انتساب (پیش از این تفکیک، یا بیرون از هر محیط مثل سهم نماینده) در همه‌جا دیده می‌شوند تا تاریخچهٔ موجود ناپدید نشود. `PatientWalletTenantTest` هر سه را می‌سنجد: تفکیک سطرها، سراسری‌ماندن موجودی، و دیده‌شدن ردیف بی‌انتساب. ### ⚠️ نقطهٔ ضعف: کارتِ بی‌محیط در هیچ محیطی دیده نمی‌شود `bank_accounts` و `pos_devices` تنها جدول‌هایی‌اند که جفت محیطشان **تهی‌پذیر** است ({@see `NullableTenantOwnedTrait`}). دلیل: تا فاز ۶ روی `User` ثبت می‌شدند و برای کاربری که چند محیط دارد هیچ ستونی نمی‌گفت کدام کارت مال کدام محیط است. تصمیم گرفته شد **حدس زده نشود**؛ ردیف مبهم تهی می‌ماند تا مالک خودش تعیین کند. هزینه‌اش این است: فیلتر شرط تساوی می‌گذارد و `NULL` با هیچ مقداری برابر نیست، پس چنین ردیفی از هر کوئری DQL غایب است — حتی برای کسی که خودش ثبتش کرده. راه خروج، تنها استثنای این دامنه است: `findUnassignedByUser()` و `assignEntity()` عمداً با DBAL خام اجرا می‌شوند (فیلتر رویشان اعمال نمی‌شود) و به‌جای فیلتر، محدودیت `user_id` را در خودِ کوئری دارند. انتساب با `PATCH /api/v1/my/payment-methods/{bank-accounts|pos}/{uuid}/environment` انجام می‌شود و شرط‌های مالکیت و بی‌محیط بودن داخل خودِ `UPDATE`اند تا دو درخواست هم‌زمان یک کارت را به دو محیط نچسبانند. پنل ادمین این ردیف‌ها را با نشانهٔ «محیط تعیین‌نشده» و دکمهٔ انتساب نشان می‌دهد، وگرنه کاربر چندمحیطی فکر می‌کند کارتش گم شده. --- ## 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` | | `PaymentMethod/Repository/{BankAccount,Pos}Repository` | **عمداً بیرون فیلتر** | تنها راه رسیدن به ردیفِ بی‌محیط؛ هر دو کوئری `WHERE user_id = ?` دارند و `assignEntity` شرط `entity_type IS NULL` را هم داخل `UPDATE` نگه می‌دارد. `PaymentMethodTenantTest` تلاش برای تصاحب کارت شخص دیگر را می‌سنجد | `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` `NullableTenantOwnedTrait` برای entity **جدید** نیست. فقط برای جدولی است که از قبل وجود داشته و مالکِ بعضی ردیف‌هایش از داده قابل تشخیص نیست؛ entity جدید از روز اول محیط دارد، پس ستون تهی‌پذیر فقط تور ایمنی را سوراخ می‌کند. `DEFERRED` هم راه فرار نیست: خالی است و باید خالی بماند. ایندکس‌ها: `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` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | | `tests/Payment/PaymentTenantTest.php` | پرداخت به محیط گیرنده می‌نشیند؛ بیمار پرداخت خودش را می‌بیند، محیط دیگر نمی‌بیند | | `tests/Settlement/FinancialChainTenantTest.php` | زنجیرهٔ مالی از راه لنگر به `payments` جدا می‌شود؛ کیف پول عمداً سراسری می‌ماند | | `tests/PaymentMethod/PaymentMethodTenantTest.php` | کارت‌ها per-محیط‌اند؛ ردیف بی‌محیط دیده می‌شود ولی تا انتساب قابل ویرایش نیست | | `tests/Patient/PatientWalletTenantTest.php` | دفتر کیف پول per-محیط است ولی موجودی سراسری می‌ماند |