Phase 5, the last of the tenant-marking series. The Doctrine filter added in phase 4 does not see raw DBAL, so every place that writes SQL by hand was read and classified rather than assumed safe. The audit found no code to fix. ClaimRepository was the only tenant-owning table reached by raw SQL, and all three of its queries already close on c.entity_type/:entity_id. That protection had no test, so it now has one: the claims dashboard is the only tenant surface whose isolation depends entirely on a hand-written WHERE, and nothing would have reported its removal. Everything else falls outside the question. AdminApiController is cross-tenant on purpose behind a class-level ROLE_ADMIN. RepresentationActionController only counts doctors, scoped by representation_id. CategoryImporter interpolates a table name, but it comes from a hardcoded const map behind isValidBundle() and ROLE_ADMIN, so it cannot be steered by input. The purge and seed commands are console-only, dry-run by default, and blocked from prod at the kernel. The health check is SELECT 1 and the logger writes to a global table. getReference() appears once in src, on User, which is global. app:tenant:dump gives one environment's rows as SQL — the practical benefit of database-per-tenant without its cost. It reads the table list from metadata using the same test the filter applies, so a table that gains a tenant pair later is included automatically instead of being silently missed. The --tenant value ends up inside a --where clause and an argv entry, so it is validated by a closed regex rather than escaped; seven malformed inputs are covered, including SQL and shell injection attempts. Verified by running it against the dev database: a real clinic produced 20 tables with only that clinic's rows and no doctor-owned row, an unknown id exited non-zero with a Persian message, "clinic:1 OR 1=1" was refused, and a tenant with no data still produced a valid file. Not verified: browser-level checks of the admin panel and the public site. The OTP login is behind an Altcha proof-of-work, so no interactive token was obtained. What was checked instead: the admin SPA type-checks clean, the public doctor and specialty endpoints answer 200 with cross-tenant results, and neither nobat724_front nor clinic-pro-tauri references owner_type, owner_id, clinic_key or db_type anywhere. The functional suite already exercises the same HTTP path with real JWTs and the subscriber active. Tests: 856 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
جداسازی محیط (tenancy)
هر دادهٔ عملیاتی در ClinicPro به یک محیط تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند میگوید آن محیط چطور تعیین میشود، کجا اجبار میشود، و — مهمتر — کجا اجبار نمیشود.
هویت محیط
جفت (entity_type, entity_id) روی خودِ جدول:
| ستون | مقدار |
|---|---|
entity_type |
doctor یا clinic — VARCHAR(10) در هر ۲۰ جدول tenant-دار |
entity_id |
شناسهٔ همان پزشک یا کلینیک |
موجودیتها این جفت را از trait مشترک میگیرند:
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 جلوی رشد بیصدای این فهرست را میگیرد.
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
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 |
یک منشی، یک پزشک، چند کلینیک |