Files
clinicpro/docs/architecture/tenancy.md
T
hamedandClaude Opus 5 6d2fd564e2 chore(tenant): audit the paths the filter cannot reach
Phase 5, the last of the tenant-marking series. The Doctrine filter added in
phase 4 does not see raw DBAL, so every place that writes SQL by hand was read
and classified rather than assumed safe.

The audit found no code to fix. ClaimRepository was the only tenant-owning table
reached by raw SQL, and all three of its queries already close on
c.entity_type/:entity_id. That protection had no test, so it now has one: the
claims dashboard is the only tenant surface whose isolation depends entirely on
a hand-written WHERE, and nothing would have reported its removal.

Everything else falls outside the question. AdminApiController is cross-tenant
on purpose behind a class-level ROLE_ADMIN. RepresentationActionController only
counts doctors, scoped by representation_id. CategoryImporter interpolates a
table name, but it comes from a hardcoded const map behind isValidBundle() and
ROLE_ADMIN, so it cannot be steered by input. The purge and seed commands are
console-only, dry-run by default, and blocked from prod at the kernel. The
health check is SELECT 1 and the logger writes to a global table. getReference()
appears once in src, on User, which is global.

app:tenant:dump gives one environment's rows as SQL — the practical benefit of
database-per-tenant without its cost. It reads the table list from metadata using
the same test the filter applies, so a table that gains a tenant pair later is
included automatically instead of being silently missed. The --tenant value ends
up inside a --where clause and an argv entry, so it is validated by a closed
regex rather than escaped; seven malformed inputs are covered, including SQL and
shell injection attempts.

Verified by running it against the dev database: a real clinic produced 20 tables
with only that clinic's rows and no doctor-owned row, an unknown id exited
non-zero with a Persian message, "clinic:1 OR 1=1" was refused, and a tenant with
no data still produced a valid file.

Not verified: browser-level checks of the admin panel and the public site. The
OTP login is behind an Altcha proof-of-work, so no interactive token was
obtained. What was checked instead: the admin SPA type-checks clean, the public
doctor and specialty endpoints answer 200 with cross-tenant results, and neither
nobat724_front nor clinic-pro-tauri references owner_type, owner_id, clinic_key
or db_type anywhere. The functional suite already exercises the same HTTP path
with real JWTs and the subscriber active.

Tests: 856 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:46:30 +03:30

10 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 است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند.

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

جدول‌های مالی (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 یک منشی، یک پزشک، چند کلینیک