feat(tenant): enforce environment isolation in the ORM layer
Phase 4 of the tenant-marking series. Until now isolation depended on every query remembering its own WHERE clause. With 82 entities and 844 tests, that is not a guarantee — it is a hope. MariaDB has no row-level security, so the backstop has to live in Doctrine. TenantFilter appends (entity_type, entity_id) to every DQL query on a tenant-owning entity. It ships disabled and TenantFilterSubscriber turns it on per request. The filter engages only for a **chosen** environment — an explicit clinic_uuid on the request, or a stored UserActiveContext. EntityContext now records which of the two produced it. Locking a user to the role fallback instead would hide data they are entitled to: a clinic-member doctor who never switched context lost every appointment belonging to that clinic. Five tests caught exactly that before the gate was added. Admins and unauthenticated marketplace traffic stay outside the filter by design. Two findings from running it rather than reasoning about it: - Dereferencing a lazy proxy whose target the filter excluded raises EntityNotFoundException, which surfaced as 500 on four patient endpoints. ExceptionSubscriber now maps it to 404: outside your environment means it does not exist for you. It is logged at info level so a genuinely broken FK is still visible. - EntityManager::find() by primary key IS filtered in Doctrine ORM 3, contrary to the limitation carried over from older versions. The stronger guarantee is pinned by a test so a future regression is noticed, and the documented table was corrected. The filter also caught a real leak: a clinic secretary's appointment list filtered by doctor id alone, so a doctor's personal-practice booking appeared in the clinic list. The test had been asserting that behaviour. GlobalTables classifies all 82 entities into four states — carries a tenant, deliberately global, aggregate child, or recorded debt — and TenantSchemaCoverageTest fails on anything unclassified. Aggregate children declare their root explicitly, because several attach through a scalar FK rather than a Doctrine association and cannot be inferred from metadata; the test walks each chain to a tenant-owning root. Financial tables stay in DEFERRED with a ceiling assertion so the list cannot grow quietly. Deliberately not built: the prePersist assignment listener from the plan. The tenant columns are NOT NULL without a default, so a missing assignTenant() already fails loudly at flush — phase 2 surfaced 123 such failures. A listener would add silent auto-assignment where the current behaviour is an explicit crash. EXPLAIN with the filter's conditions still picks idx_appointments_tenant_slot and uniq_patient_record. Tests: 844 passing. PHPStan unchanged at its 17 pre-existing errors, none in files touched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
/**
|
||||
* طبقهبندی هر entity نسبت به جداسازی محیط. هر کلاس باید دقیقاً در یکی از این
|
||||
* چهار وضعیت باشد، وگرنه TenantSchemaCoverageTest قرمز میشود:
|
||||
*
|
||||
* ۱. خودش جفت (entity_type, entity_id) دارد → TenantFilter پوششش میدهد
|
||||
* ۲. {@see self::ENTITIES} → عمداً سراسری است
|
||||
* ۳. {@see self::AGGREGATE_CHILDREN} → محیط را از ریشه به ارث میبرد
|
||||
* ۴. {@see self::DEFERRED} → هنوز طبقهبندی نشده، بدهی ثبتشده
|
||||
*
|
||||
* این فهرست تنها راه فرار از پوشش tenant است؛ افزودن به آن باید دلیل داشته باشد.
|
||||
*/
|
||||
final class GlobalTables
|
||||
{
|
||||
/**
|
||||
* Entityهایی که به هیچ محیطی تعلق ندارند.
|
||||
*
|
||||
* @var array<class-string, string> کلاس => دلیل
|
||||
*/
|
||||
public const ENTITIES = [
|
||||
// دادهٔ مرجع مشترک بین همهٔ محیطها
|
||||
\App\Location\Entity\Province::class => 'تقسیمات کشوری',
|
||||
\App\Location\Entity\City::class => 'تقسیمات کشوری',
|
||||
\App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصصها',
|
||||
\App\DoctorService\Entity\DoctorService::class => 'تاکسونومی سراسری خدمات، وابسته به تخصص نه به محیط',
|
||||
\App\Insurance\Entity\Insurance::class => 'فهرست بیمههای کشور',
|
||||
\App\Insurance\Entity\InsuranceCoverageDefault::class => 'پیشفرض پوشش بیمه در سطح کشور؛ هر محیط با TenantInsurance بازنویسیاش میکند',
|
||||
\App\Tag\Entity\Tag::class => 'تاکسونومی سراسری برچسب — قرینهٔ per-tenant آن TenantTag است',
|
||||
\App\Config\Entity\SiteConfig::class => 'تنظیمات کل سامانه',
|
||||
\App\Config\Entity\TaxRateHistory::class => 'نرخ مالیات کشور',
|
||||
\App\Subscription\Entity\SubscriptionPlan::class => 'پلنهای فروش، مشترک بین همهٔ مشتریان',
|
||||
\App\Subscription\Entity\SubscriptionPeriod::class => 'دورههای قیمتی همان پلنها',
|
||||
\App\Sms\Entity\SmsTemplate::class => 'قالب پیامک سامانه',
|
||||
\App\Sms\Entity\SmsMessageTemplate::class => 'متن پیامک سامانه',
|
||||
\App\Sms\Entity\SmsLog::class => 'لاگ ارسال؛ فقط شماره و قالب دارد، مالک ندارد',
|
||||
\App\Shared\Logging\AppLog::class => 'لاگ سراسری برنامه',
|
||||
\App\Blog\Entity\Blog::class => 'محتوای عمومی مارکتپلیس',
|
||||
|
||||
// هویت — یک شخص میتواند در چند محیط حضور داشته باشد
|
||||
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records میآید',
|
||||
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
|
||||
\App\Auth\Entity\PreRegistration::class => 'پیشثبتنام، هنوز به هیچ محیطی وصل نیست',
|
||||
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیینکنندهٔ محیط است؛ فیلتر کردنش حلقه میسازد',
|
||||
|
||||
// خودِ محیطها
|
||||
\App\Doctor\Entity\Doctor::class => 'خودش یک محیط است',
|
||||
\App\Clinic\Entity\Clinic::class => 'خودش یک محیط است',
|
||||
|
||||
// دادهٔ عمومی مارکتپلیس دربارهٔ پزشک — بیمار مینویسد، نه محیط
|
||||
\App\Rating\Entity\Comment::class => 'نظر عمومی بیمار روی پروفایل پزشک',
|
||||
\App\Rating\Entity\Like::class => 'لایک عمومی روی همان نظرها',
|
||||
\App\Rating\Entity\Rate::class => 'امتیاز عمومی بیمار به پزشک',
|
||||
\App\Representation\Entity\Representation::class => 'نمایندهٔ فروش؛ بالادستِ محیطهاست نه داخل یکی',
|
||||
|
||||
// رابطهٔ بین دو محیط — فیلتر کردن با یک طرف، طرف دیگر را کور میکند
|
||||
\App\Clinic\Entity\ClinicDoctorPermission::class => 'مجوز پزشکِ عضو در یک کلینیک؛ هویتش خودِ جفت (کلینیک، پزشک) است',
|
||||
\App\ClinicInvitation\Entity\ClinicDoctorInvitation::class => 'دعوت کلینیک از پزشک؛ پیش از عضویت هر دو طرف باید ببینندش',
|
||||
\App\Doctor\Entity\DoctorClaimRequest::class => 'درخواست تصاحب پروفایل پزشک؛ متقاضی هنوز صاحب محیط نیست',
|
||||
|
||||
// دادهٔ خودِ پزشک، مستقل از اینکه در کدام کلینیک کار میکند
|
||||
\App\Doctor\Entity\DoctorAddress::class => 'آدرسهای پزشک؛ در همهٔ محیطهای او یکسان است',
|
||||
\App\Insurance\Entity\DoctorInsurance::class => 'بیمههای طرف قرارداد خودِ پزشک',
|
||||
|
||||
// استثنای مستندشده در فاز ۲
|
||||
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیطها»، نه «مطب شخصی» — جفت tenant این را نمیتواند بیان کند',
|
||||
];
|
||||
|
||||
/**
|
||||
* فرزندان aggregate: ستون tenant ندارند و محیط را از ریشه به ارث میبرند.
|
||||
* ریشه صریح اعلام میشود چون بعضیشان با FK اسکالر وصلاند (نه رابطهٔ Doctrine)
|
||||
* و از metadata قابل استنتاج نیستند.
|
||||
*
|
||||
* ⚠️ TenantFilter روی اینها اعمال نمیشود. کوئری مستقیم روی این جدولها بدون
|
||||
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
|
||||
*
|
||||
* @var array<class-string, class-string> فرزند => ریشه
|
||||
*/
|
||||
public const AGGREGATE_CHILDREN = [
|
||||
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
|
||||
|
||||
\App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class,
|
||||
\App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class,
|
||||
\App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class,
|
||||
\App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class,
|
||||
\App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class,
|
||||
|
||||
\App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class,
|
||||
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
|
||||
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
|
||||
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
|
||||
|
||||
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
|
||||
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
|
||||
\App\Billing\Entity\InvoiceItem::class => \App\Billing\Entity\Invoice::class,
|
||||
|
||||
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
|
||||
|
||||
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
|
||||
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
|
||||
|
||||
\App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* بدهی ثبتشده: مالکیتشان دوگانه است (پرداختکننده در برابر دریافتکننده) و
|
||||
* تصمیم دربارهشان تحلیل جدا میخواهد. migration اشتباه روی دادهٔ مالی برگشتپذیر
|
||||
* نیست، پس عمداً در این فاز دست نخوردند.
|
||||
*
|
||||
* این فهرست باید کوچک شود، نه بزرگ.
|
||||
*
|
||||
* @var array<class-string, string>
|
||||
*/
|
||||
public const DEFERRED = [
|
||||
\App\Payment\Entity\Payment::class => 'پرداخت بین بیمار و محیط؛ هر دو طرف باید ببینندش',
|
||||
\App\Payment\Entity\PaymentLog::class => 'فرزند Payment؛ با همان تصمیم میرود',
|
||||
\App\Settlement\Entity\Settlement::class => 'تسویهٔ سامانه با صاحب محیط',
|
||||
\App\Settlement\Entity\FinancialBreakdown::class => 'تفکیک سهمها بین چند طرف یک پرداخت',
|
||||
\App\Settlement\Entity\WalletTransaction::class => 'کیف پول کاربر، نه محیط',
|
||||
\App\Secretary\Entity\SecretaryEarning::class => 'سهم منشی از یک پرداخت',
|
||||
\App\PaymentMethod\Entity\BankAccount::class => 'حساب بانکی روی User ثبت شده، نه روی محیط',
|
||||
\App\PaymentMethod\Entity\Pos::class => 'دستگاه کارتخوان روی User ثبت شده، نه روی محیط',
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user