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:
hamed
2026-07-28 12:27:27 +03:30
co-authored by Claude Opus 5
parent 2e0888e0ef
commit 75d5052f72
14 changed files with 1522 additions and 7 deletions
+15
View File
@@ -23,6 +23,15 @@ final class EntityContext
public readonly ?int $id,
public readonly ?Clinic $clinic = null,
public readonly ?Doctor $doctor = null,
/**
* محیط از انتخابِ صریح کاربر آمده (clinic_uuid درخواست یا UserActiveContext)
* و نه از fallbackِ نقش.
*
* جداسازیِ سختِ TenantFilter فقط روی محیط انتخاب‌شده اعمال می‌شود: کاربری
* که هنوز محیطی برنگزیده در هیچ محیطی «نیست»، و قفل‌کردنش روی حدسِ نقش،
* دادهٔ کلینیکی‌اش را که قانوناً حقش است پنهان می‌کند.
*/
public readonly bool $chosen = false,
) {}
public static function forDoctor(?Doctor $doctor): self
@@ -30,6 +39,12 @@ final class EntityContext
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
}
/** همان محیط، با علامتِ «کاربر خودش انتخابش کرده». */
public function asChosen(): self
{
return new self($this->type, $this->id, $this->clinic, $this->doctor, true);
}
public static function forClinic(Clinic $clinic): self
{
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
+3 -3
View File
@@ -49,7 +49,7 @@ class EntityContextResolver
}
$this->assertCanActInClinic($user, $clinic);
return EntityContext::forClinic($clinic);
return EntityContext::forClinic($clinic)->asChosen();
}
$fromActive = $this->fromActiveContext($user);
@@ -107,14 +107,14 @@ class EntityContextResolver
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
return $clinic !== null && $this->canActInClinic($user, $clinic)
? EntityContext::forClinic($clinic)
? EntityContext::forClinic($clinic)->asChosen()
: null;
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
return $doctor !== null && $this->canActForDoctor($user, $doctor)
? EntityContext::forDoctor($doctor)
? EntityContext::forDoctor($doctor)->asChosen()
: null;
}
@@ -4,6 +4,7 @@ namespace App\Shared\EventSubscriber;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityNotFoundException;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -67,6 +68,21 @@ class ExceptionSubscriber implements EventSubscriberInterface
return;
}
// با فیلتر محیط روشن، مقداردهیِ یک proxy به موجودیتی که بیرون از محیط جاری
// است این استثنا را می‌دهد — نه ۵۰۰. «بیرون از محیط تو» یعنی «برای تو وجود
// ندارد». همچنان لاگ می‌شود تا FK واقعاً شکسته هم دیده شود.
if ($exception instanceof EntityNotFoundException) {
$this->logger->info('Entity outside the active tenant or missing', [
'message' => $exception->getMessage(),
'path' => $event->getRequest()->getPathInfo(),
]);
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]],
404
));
return;
}
if ($exception instanceof AccessDeniedHttpException) {
$event->setResponse(new JsonResponse(
['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]],
+131
View File
@@ -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 ثبت شده، نه روی محیط',
];
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Shared\Tenant;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
/**
* جداسازی خودکار محیط: به هر کوئری DQL روی entityهای tenant-دار شرط
* (entity_type, entity_id) اضافه می‌شود.
*
* پیش‌فرض خاموش است و فقط وقتی روشن می‌شود که محیط کاربر حل شده باشد
* ({@see TenantFilterSubscriber}). مسیرهای عمومی مارکت‌پلیس و ادمین سراسری
* عمداً بیرون از آن می‌مانند.
*
* ⚠️ تور ایمنی است، نه جایگزین authorization:
*
* | مسیر | اعمال می‌شود؟ |
* |----------------------------------------|---------------|
* | DQL و QueryBuilder | ✅ |
* | findBy / findOneBy | ✅ |
* | EntityManager::find() با کلید اصلی | ✅ (در ORM ۳؛ تثبیت‌شده در TenantFilterLeakTest) |
* | بارگذاری تنبل کالکشن‌ها | ✅ |
* | entity که از قبل در identity map است | ❌ — دوباره کوئری نمی‌شود |
* | getReference() | ❌ |
* | SQL خام DBAL | ❌ |
* | فرزندان aggregate (بدون ستون tenant) | ❌ — از ریشه JOIN کن |
*
* مقداردهیِ proxy به موجودیتی که فیلتر کنارش گذاشته، EntityNotFoundException
* می‌دهد؛ ExceptionSubscriber آن را به ۴۰۴ نگاشت می‌کند («بیرون از محیط تو» یعنی
* «برای تو وجود ندارد»).
*
* پس AppointmentAccessChecker، ClinicDoctorAccessChecker، SecretaryAccessChecker
* و PatientRecordScopeResolver سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را
* جواب می‌دهند، این فیلتر فقط «کدام ردیف‌ها».
*/
final class TenantFilter extends SQLFilter
{
public const NAME = 'tenant';
public const PARAM_TYPE = 'tenant_entity_type';
public const PARAM_ID = 'tenant_entity_id';
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
if (!$targetEntity->hasField('entityType') || !$targetEntity->hasField('entityId')) {
return '';
}
return sprintf(
'%s.%s = %s AND %s.%s = %s',
$targetTableAlias,
$targetEntity->getColumnName('entityType'),
$this->getParameter(self::PARAM_TYPE),
$targetTableAlias,
$targetEntity->getColumnName('entityId'),
$this->getParameter(self::PARAM_ID),
);
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Shared\Tenant;
use App\Auth\Entity\User;
use App\Shared\Context\EntityContextResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* فیلتر محیط را در ابتدای هر درخواست روشن می‌کند — و فقط وقتی که واقعاً محیطی
* حل شود.
*
* دو مسیر عمداً بیرون می‌مانند:
* • مسیرهای عمومی مارکت‌پلیس (nobat724_front) کاربر پنل ندارند، پس محیطی حل
* نمی‌شود و جستجوی چند-کلینیکی دست‌نخورده کار می‌کند.
* • ادمین ذاتاً سراسری است؛ فیلتر کردنش پنل مدیریت را کور می‌کند.
*/
final class TenantFilterSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly Security $security,
private readonly EntityContextResolver $contextResolver,
private readonly EntityManagerInterface $em,
) {}
public static function getSubscribedEvents(): array
{
// بعد از فایروال (اولویت ۸) تا توکن در دسترس باشد.
return [KernelEvents::REQUEST => ['onRequest', 5]];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User || $user->hasRole('ROLE_ADMIN')) {
return;
}
// فقط محیطِ انتخاب‌شده. کاربری که هنوز محیطی برنگزیده در هیچ محیطی «نیست»؛
// قفل‌کردنش روی حدسِ نقش، دادهٔ کلینیکی‌ای را که قانوناً حقش است پنهان می‌کند
// و دسترسی را همان checkerهای دامنه تعیین می‌کنند.
$context = $this->contextResolver->tryResolve($user);
if ($context === null || !$context->isResolved() || !$context->chosen) {
return;
}
[$type, $id] = $context->toEntityPair();
$this->em->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
}
}