Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 6ab1eb6483 fix(tenant): scope the patient wallet ledger to the environment reading it
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>
2026-07-28 15:21:55 +03:30

20 KiB
Raw Blame History

جداسازی محیط (tenancy)

هر دادهٔ عملیاتی در ClinicPro به یک محیط تعلق دارد: یا مطب شخصی یک پزشک، یا یک کلینیک. این سند می‌گوید آن محیط چطور تعیین می‌شود، کجا اجبار می‌شود، و — مهم‌تر — کجا اجبار نمی‌شود.


هویت محیط

جفت (entity_type, entity_id) روی خودِ جدول:

ستون مقدار
entity_type doctor یا clinicVARCHAR(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_notespatient_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-محیط است ولی موجودی سراسری می‌ماند