Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.
payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.
Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:
- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
SmsWalletController and already carries its environment in the metadata;
without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
so it cannot drive the subscription backfill. The environment is derived
the way handleSubscriptionActivation derives it — and that method now
reads the pair off the payment instead of re-deriving it, so a payment and
the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
none of the four creation sites set it; the wallet is a person's, with a
running balance per user. It and Settlement, which withdraws from that same
wallet, are global with a recorded reason instead.
bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.
Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 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 تازه روی موجودیت محیطدار تست را قرمز میکند تا کسی ثابت کند محیطش بررسی میشود و بعد عدد را بهروز کند.
جدولهای مالی
بدهی صفر است. testThereIsNoUnclassifiedDebtLeft خالیماندنش را اجبار میکند.
استدلالِ اولیهٔ «مالکیتشان دوگانه است» درست نبود: پرداخت به محیطِ گیرنده تعلق دارد و پرداختکننده مانعی نیست، چون بیمار محیطی انتخاب نکرده و فیلتر برایش خاموش است.
| جدول | تصمیم | چرا |
|---|---|---|
payments |
جفت محیط | نوبت → محیط نوبت · اشتراک → محیطی که خریدار صاحبش است · شارژ پیامک → محیط همان کیف پول |
payment_logs · financial_breakdowns |
فرزند Payment |
با FK به پرداخت لنگر میخورند |
secretary_earnings |
فرزند FinancialBreakdown |
زنجیره تا payments میرسد |
wallet_transactions |
ENTITIES |
کیف پولِ شخص است: موجودی از مجموع credit−debitِ همان کاربر مشتق میشود و payment_id تهیپذیر است — تفکیک به محیط، خودِ موجودی را بیمعنا میکند |
settlements |
ENTITIES |
برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) میسنجد) |
bank_accounts · pos_devices |
جفت محیط، تهیپذیر | از کاربر به محیط منتقل شدند؛ موارد مبهم تهی ماندند (پایین) |
نتیجهٔ عملی برای زنجیره: تضمین فقط تا جایی است که کوئری به payments لنگر بزند. SecretaryEarningRepository::reportFor این کار را با join('b.payment','p') میکند و فیلتر روی همان مینشیند؛ FinancialChainTenantTest همین را میسنجد.
⚠️ نقطهٔ ضعف: کارتِ بیمحیط در هیچ محیطی دیده نمیشود
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
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-محیطاند؛ ردیف بیمحیط دیده میشود ولی تا انتساب قابل ویرایش نیست |