Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot reach. Measuring first — as the plan required — moved the target: all 22 children and their 20 repositories were already sound. Every list query anchors on its root, and ServiceItemRepository even joins service_sections and filters on the pair by hand. A repository-level guard would have found nothing. The real exposure was one layer up. Where a uuid arrives from a request body or query string, the entity it names is loaded by uuid alone, and the filter is no help: aggregate children have no tenant column, and a panel user who never chose an environment is not filtered at all. Three leaks, each proven by removing the fix and watching the new tests go red: - GET /api/v1/appointment-service-slots accepted service_item_uuids from any environment. Existence, bookable state and duration leaked through the error messages and the returned slots. The booking path in the same controller had guarded this since it was written; the slot path never did. - POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid, staff_uuid and the service list without any check, and persisted them onto the appointment. A write, not just a read. - PatientService did the same in all three of its loops — pricing, session create, session update — so another environment's service price entered the invoice and its SessionService row was stored, staff included. TenantOwnershipChecker is the single place that answers "does this belong to the current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now delegates that pair to its section: an aggregate child exposing the tenant it inherits. An entity that exposes no pair throws rather than returning false — silence here builds an always-closed guard, which is its own bug. TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its place immediately: the first run found more sites than the manual grep had, and reviewing them turned up the third PatientService loop. StaffController looked unguarded until read properly — ownsStaff sits two lines below the null check. One assertion was wrong before it was right: the create-path test read `$session['services'] ?? []`, which passes vacuously. It now counts the stored rows through the repository, and fails without the fix. Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 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 است. 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 نقطهٔ واحد این بررسی است:
$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
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 |
سرویس/پرسنلِ محیط دیگر نه قیمت میخورد نه ذخیره میشود |