ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.
The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.
The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.
Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).
Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.
Removing the fix turns 3 of the 6 new tests red.
Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 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 + انتسابِ 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-کیفپول:
// 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
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-محیط است ولی موجودی سراسری میماند |