Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 d2f4b5c428 fix(tenant): check the environment wherever a uuid comes from the request
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>
2026-07-28 13:39:59 +03:30

13 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 تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند.

بدهی باقی‌مانده

جدول‌های مالی (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 سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود