# فاز ۴ — اجباری‌کردن جداسازی tenant در لایهٔ Doctrine

> پرامپت چهارم از سری پنج‌تایی. **پیش‌نیاز: فازهای ۱ تا ۳ کامل و سبز.**
> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → **۴. اجبار فیلتر (همین فایل)** → ۵. `tenant-05-audit-and-docs.md`

## زمینه

بعد از فاز ۳، هر جدولِ tenant-دار یک جفت `(entity_type, entity_id)` با املا و طول یکسان دارد. اما جداسازی هنوز **اختیاری** است: هر کوئری باید خودش `WHERE entity_type = ... AND entity_id = ...` بگذارد. یک `WHERE` فراموش‌شده در یک repository = نشت دادهٔ پزشکی بین دو کلینیک.

MariaDB معادل Row Level Security پستگرس را ندارد، پس این تضمین باید در لایهٔ ORM ساخته شود. Doctrine برای همین `SQLFilter` دارد: شرطی که خودکار به هر کوئری DQL روی entityهای هدف چسبانده می‌شود.

## مشکل / هدف

**مشکل:** جداسازی tenant به یادآوری برنامه‌نویس وابسته است. با ۱۱۸ entity و ۱۴۸ فایل تست، تضمین اینکه هیچ‌کس هرگز فراموش نکند، ممکن نیست.

**هدف:** فیلتر خودکار Doctrine + listener تخصیص خودکار + یک تست ساختاری که جلوی رگرسیون آینده را بگیرد.

### آنچه فیلتر تضمین می‌کند و آنچه نمی‌کند

این جدول باید در docblock فیلتر هم نوشته شود — امنیت کاذب خطرناک‌تر از نبود امنیت است:

| مسیر | فیلتر اعمال می‌شود؟ | چه باید کرد |
|---|---|---|
| DQL و QueryBuilder | ✅ | — |
| بارگذاری تنبل کالکشن‌ها (`OneToMany`/`ManyToMany`) | ✅ | — |
| `EntityManager::find($class, $id)` | ❌ **نه** | دریافت با DQL از repository، یا چک صریح tenant بعد از `find()` |
| `getReference()` | ❌ نه | همان بالا |
| entity که از قبل در identity map است | ❌ نه | آگاه باش |
| SQL خام DBAL (۸ فایل پروژه) | ❌ نه | `WHERE` دستی — فاز ۵ |

پس فیلتر **تور ایمنی** است، نه جایگزین `AppointmentAccessChecker` و `PatientRecordScopeResolver`. آن‌ها باقی می‌مانند.

## معیار پذیرش

- ✅ موفق: با توکن مالک کلینیک A، `GET /api/v1/patients`، `GET /api/v1/inventory/items`، `GET /api/v1/discounts` و `GET /api/v1/clinic-services/sections` هیچ رکورد متعلق به کلینیک B یا به مطب شخصی هیچ پزشکی برنمی‌گردانند — حتی اگر `WHERE` دستی از کوئری repository حذف شود. (این آخری معیار واقعی است: در تست، فیلتر باید به‌تنهایی کافی باشد.)
- ❌ خطا: ساخت entity از یک entityِ tenant-دار بدون محیط حل‌شده (مثلاً از داخل messenger consumer که کاربر ندارد) → استثنای صریح با پیام قابل فهم، نه ردیف با `entity_id = 0` و نه خطای `SQLSTATE[23000]` خام.
- ⚠️ مرزی: endpointهای عمومی سایت (`nobat724_front`) که عمداً cross-tenant هستند — جستجوی پزشک، صفحهٔ پزشک، لیست شهرها — همچنان دادهٔ همهٔ کلینیک‌ها را برمی‌گردانند و فیلتر آن‌ها را خفه نمی‌کند. تست این حالت اجباری است، وگرنه سایت عمومی بی‌صدا خالی می‌شود.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Shared/Tenant/TenantFilter.php` | فیلتر SQL — جدید |
| `src/Shared/Tenant/TenantAssignmentListener.php` | تخصیص خودکار در `prePersist` — جدید |
| `src/Shared/Tenant/TenantFilterSubscriber.php` | فعال‌سازی فیلتر در `kernel.request` — جدید |
| `src/Shared/Tenant/GlobalTables.php` | whitelist جدول‌های خارج از tenant — جدید |
| `src/Shared/Tenant/TenantOwnedTrait.php` | از فاز ۲ |
| `src/Shared/Context/EntityContextResolver.php` | منبع محیط |
| `config/packages/doctrine.yaml` | ثبت فیلتر |
| `tests/Shared/TenantIsolationMatrixTest.php` | از فاز ۳ — باید سبز بماند |
| `tests/Shared/TenantSchemaCoverageTest.php` | تست ساختاری — جدید |

## وضعیت فعلی

`doctrine.yaml` فعلاً هیچ فیلتری ثبت نکرده — فقط دو تابع DQL برای JSON:

```yaml
# config/packages/doctrine.yaml
doctrine:
    orm:
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        auto_mapping: true
        mappings:
            App:
                type: attribute
                is_bundle: false
                dir: '%kernel.project_dir%/src'
                prefix: 'App'
                alias: App
        dql:
            string_functions:
                JSON_CONTAINS: App\Shared\Doctrine\JsonContains
                JSON_EXTRACT: App\Shared\Doctrine\JsonExtract
```

## وظایف

### ۱. whitelist جدول‌های سراسری

قبل از فیلتر، فهرست صریحِ چیزهایی که **نباید** tenant داشته باشند لازم است. بدون این فهرست، تست ساختاری وظیفهٔ ۵ قابل نوشتن نیست.

```php
// src/Shared/Tenant/GlobalTables.php
namespace App\Shared\Tenant;

/**
 * Entityهایی که عمداً به هیچ tenant تعلق ندارند. هر افزوده‌ای به این فهرست
 * باید دلیل داشته باشد — این فهرست تنها راه فرار از تست پوشش tenant است.
 */
final class GlobalTables
{
    /** @var array<class-string, string> کلاس => دلیل */
    public const ENTITIES = [
        // دادهٔ مرجع مشترک بین همهٔ tenantها
        \App\Category\Entity\Category::class      => 'تاکسونومی سراسری (استان/شهر/تخصص/بیمه)',
        \App\Location\Entity\Province::class      => 'دادهٔ جغرافیایی کشور',
        \App\Location\Entity\City::class          => 'دادهٔ جغرافیایی کشور',
        \App\Specialty\Entity\Specialty::class    => 'تاکسونومی سراسری تخصص‌ها',
        \App\Insurance\Entity\Insurance::class    => 'فهرست بیمه‌های کشور',
        \App\Tag\Entity\Tag::class                => 'تاکسونومی سراسری (در برابر TenantTag که tenant دارد)',

        // هویت — بیمار می‌تواند مشتری چند tenant باشد
        \App\Auth\Entity\User::class              => 'هویت سراسری؛ رابطهٔ بیمار↔محیط از patient_records می‌آید',
        \App\UserProfile\Entity\UserProfile::class => 'پروفایل کاربر، نه دادهٔ محیط',

        // محتوای عمومی سایت
        \App\Blog\Entity\Blog::class              => 'محتوای عمومی مارکت‌پلیس',

        // خودِ موجودیت‌های tenant — نمی‌توانند به خودشان اشاره کنند
        \App\Doctor\Entity\Doctor::class          => 'خودش یک tenant است',
        \App\Clinic\Entity\Clinic::class          => 'خودش یک tenant است',

        // استثناهای مستند در فازهای قبلی
        \App\Appointment\Entity\Holiday::class    => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه مطب شخصی — فاز ۲',
        \App\Auth\Entity\MobileVerificationOtp::class => 'entity_type دارد ولی معنایش tenant نیست — فاز ۳',
        \App\Auth\Entity\UserActiveContext::class => 'خودش تعیین‌کنندهٔ محیط است',
    ];
}
```

**این فهرست را از روی این پرامپت کورکورانه کپی نکن.** هر کلاس را باز کن و تأیید کن که واقعاً بی‌tenant است. اگر entityای پیدا کردی که نه tenant دارد و نه در این فهرست جا دارد (مثل `payments`، `settlements`، `financial_breakdowns`، `claim_items`)، **آن را در گزارش به‌عنوان کار باقی‌مانده اعلام کن** و در فهرست موقت با دلیل `'TODO فاز بعدی — هنوز طبقه‌بندی نشده'` بگذار. جدول‌های مالی عمداً در دامنهٔ این سری نیستند چون مالکیتشان (پرداخت‌کننده در برابر دریافت‌کننده) تحلیل جداگانه می‌خواهد.

**نحوه تست:** بازبینی دستی + خروجی وظیفهٔ ۵.

---

### ۲. `TenantFilter`

```php
// src/Shared/Tenant/TenantFilter.php
namespace App\Shared\Tenant;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

/**
 * جداسازی خودکار محیط روی هر کوئری DQL.
 *
 * تور ایمنی است، نه جایگزین authorization: روی EntityManager::find()،
 * getReference()، entityهای موجود در identity map و SQL خام DBAL اعمال نمی‌شود.
 * checkerهای دامنه (AppointmentAccessChecker، PatientRecordScopeResolver) سر جایشان می‌مانند.
 */
final class TenantFilter extends SQLFilter
{
    public const NAME = 'tenant';

    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        if (!$targetEntity->hasField('entityType') || !$targetEntity->hasField('entityId')) {
            return '';
        }

        return sprintf(
            '%s.entity_type = %s AND %s.entity_id = %s',
            $targetTableAlias,
            $this->getParameter('entity_type'),
            $targetTableAlias,
            $this->getParameter('entity_id'),
        );
    }
}
```

`getParameter()` خودش مقدار را escape می‌کند؛ هرگز مقدار خام را داخل رشته نگذار.

ثبت در پیکربندی — **پیش‌فرض خاموش**:

```yaml
# config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            tenant:
                class: App\Shared\Tenant\TenantFilter
                enabled: false
```

`enabled: false` عمدی است: فیلتر فقط وقتی روشن می‌شود که محیط حل شده باشد. بدون این، مسیرهای عمومی و کامندهای کنسول می‌شکنند.

**نحوه تست:** `ddev exec php bin/console debug:container --parameter=doctrine.orm.filters` یا صرفاً بالا آمدن کانتینر بدون خطا؛ تست واقعی در وظیفهٔ ۳.

---

### ۳. فعال‌سازی فیلتر در ابتدای هر درخواست

```php
// src/Shared/Tenant/TenantFilterSubscriber.php
namespace App\Shared\Tenant;

use App\Shared\Context\EntityContextResolver;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Bundle\SecurityBundle\Security;

/**
 * فیلتر tenant را فقط وقتی روشن می‌کند که کاربر احراز شده و محیطش حل شود.
 *
 * مسیرهای عمومی مارکت‌پلیس کاربر پنل ندارند، پس فیلتر خاموش می‌ماند و
 * جستجوی cross-tenant سایت دست‌نخورده کار می‌کند — این رفتار عمدی است.
 */
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 \App\Auth\Entity\User) {
            return;
        }

        $context = $this->contextResolver->tryResolve($user);
        if ($context === null || !$context->isResolved()) {
            return;
        }

        [$type, $id] = $context->toEntityPair();

        $this->em->getFilters()
            ->enable(TenantFilter::NAME)
            ->setParameter('entity_type', $type, 'string')
            ->setParameter('entity_id', $id, 'integer');
    }
}
```

`tryResolve()` (نه `resolve()`) استفاده می‌شود تا محیطِ حل‌نشده استثنا پرتاب نکند و درخواست را در همان `kernel.request` نکشد.

**⚠️ نکتهٔ ادمین:** کاربر `ROLE_ADMIN` نباید فیلتر بخورد — پنل ادمین ذاتاً cross-tenant است. قبل از resolve بررسی کن:

```php
if ($user->hasRole('ROLE_ADMIN')) {
    return;   // ادمین سراسری است
}
```

**نحوه تست:**
```bash
ddev exec php bin/phpunit tests/Shared/TenantIsolationMatrixTest.php   # از فاز ۳ — باید سبز بماند
```
و تست جدید نشتی که فیلتر را به‌تنهایی می‌سنجد:

```php
// tests/Shared/TenantFilterLeakTest.php
public function testFilterAloneBlocksCrossTenantRowsWithoutManualWhere(): void
{
    // دو tenant با داده بساز، فیلتر را با tenant اول روشن کن،
    // یک DQL بدون هیچ WHERE tenant بزن → فقط ردیف‌های tenant اول برگردند
}

public function testPublicRouteWithoutAuthenticatedUserIsNotFiltered(): void
{
    // GET یک endpoint عمومی پزشکان → داده‌های چند tenant برگردد
}

public function testAdminIsNotFiltered(): void;
```

---

### ۴. تخصیص خودکار محیط هنگام درج

فراموش کردن `assignTenant()` هنگام ساخت entity، ردیفی می‌سازد که فیلتر بعداً آن را از دید همه پنهان می‌کند — بدترین نوع باگ چون خطا نمی‌دهد.

```php
// src/Shared/Tenant/TenantAssignmentListener.php
namespace App\Shared\Tenant;

use App\Shared\Context\EntityContextResolver;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;

/**
 * اگر entityِ tenant-دار بدون محیط ذخیره شود، محیط جاری را می‌نشاند؛
 * اگر محیطی هم نبود، صریح استثنا می‌دهد. ردیف بی‌tenant هرگز نباید ساخته شود:
 * فیلتر فاز ۴ آن را از دید همه پنهان می‌کند بدون اینکه خطایی دیده شود.
 */
#[AsDoctrineListener(event: Events::prePersist)]
final class TenantAssignmentListener
{
    public function __construct(
        private readonly Security              $security,
        private readonly EntityContextResolver $contextResolver,
    ) {}

    public function prePersist(PrePersistEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!in_array(TenantOwnedTrait::class, class_uses($entity) ?: [], true)) {
            return;
        }
        if ($entity->getEntityId() !== 0 && $entity->getEntityType() !== '') {
            return;   // خودِ سرویس مقدار داده — دست نزن
        }

        $user = $this->security->getUser();
        $context = $user instanceof \App\Auth\Entity\User
            ? $this->contextResolver->tryResolve($user)
            : null;

        if ($context === null || !$context->isResolved()) {
            throw new \LogicException(sprintf(
                'Entity %s requires a tenant but none is set and no context could be resolved.',
                $entity::class,
            ));
        }

        $entity->assignTenant($context);
    }
}
```

`class_uses()` فقط traitهای مستقیم را برمی‌گرداند. اگر جایی ارث‌بری وارد شد، از `class_uses_recursive` معادل خودت استفاده کن — ولی الان هیچ‌کدام از entityهای tenant-دار پدر ندارند، پس سادگی درست است.

**⚠️ مسیرهای بدون کاربر:** messenger consumerها و کامندهای کنسول کاربر ندارند. هر کدام که entityِ tenant-دار می‌سازند باید **خودشان** `assignTenant()` را صدا بزنند. آن‌ها را پیدا کن:

```bash
ddev exec grep -rln "TenantOwnedTrait" src --include="*.php" | xargs -I{} basename {} .php
ddev exec grep -rn "new \(Appointment\|DiscountRule\|ServiceSection\|PatientRecord\|InventoryItem\)(" src/*/Command src/*/MessageHandler --include="*.php"
```

**نحوه تست:**
```bash
ddev exec php bin/phpunit tests/
ddev exec php bin/console messenger:consume async --limit=1 -vv   # نباید LogicException بدهد
ddev exec php bin/console app:cancel-expired-appointments --dry-run 2>/dev/null || true
```
به‌علاوه تست واحد: `persist` یک `DiscountRule` بدون tenant و بدون کاربر → `LogicException`.

---

### ۵. تست پوشش ساختاری — مهم‌ترین خروجی این فاز

بدون این تست، فاز ۴ فقط وضعیت امروز را درست می‌کند؛ با آن، entity جدیدی که فردا کسی بسازد و tenant یادش برود، در CI قرمز می‌شود.

```php
// tests/Shared/TenantSchemaCoverageTest.php
namespace App\Tests\Shared;

use App\Shared\Tenant\GlobalTables;
use App\Shared\Tenant\TenantOwnedTrait;

final class TenantSchemaCoverageTest extends ApiTestCase
{
    /**
     * هر entity یا tenant دارد یا با دلیل در GlobalTables ثبت شده.
     * شکست این تست یعنی entity جدیدی اضافه شده و طبقه‌بندی نشده — نه اینکه تست خراب است.
     */
    public function testEveryEntityIsEitherTenantScopedOrExplicitlyGlobal(): void
    {
        $em = static::getContainer()->get('doctrine')->getManager();
        $unclassified = [];

        foreach ($em->getMetadataFactory()->getAllMetadata() as $meta) {
            $class = $meta->getName();

            if (isset(GlobalTables::ENTITIES[$class])) {
                continue;
            }
            if (in_array(TenantOwnedTrait::class, class_uses($class) ?: [], true)) {
                continue;
            }

            $unclassified[] = $class;
        }

        self::assertSame([], $unclassified, sprintf(
            "این entityها نه tenant دارند نه در GlobalTables ثبت شده‌اند:\n%s",
            implode("\n", $unclassified),
        ));
    }

    /** فهرست سراسری نباید بدون دلیل رشد کند. */
    public function testEveryGlobalEntityHasAReason(): void
    {
        foreach (GlobalTables::ENTITIES as $class => $reason) {
            self::assertNotSame('', trim($reason), "دلیل سراسری بودن {$class} خالی است.");
            self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}");
        }
    }
}
```

**واقع‌بینی:** اولین اجرای این تست قطعاً قرمز می‌شود، با فهرستی از entityهای مالی و فرزندان aggregate (مثل `patient_attachments`، `session_payments`، `claim_items`). این **خروجی مطلوب** است، نه شکست: همان فهرست، ورودی برنامه‌ریزی فاز بعدی است. آن‌ها را با دلیل `'TODO — طبقه‌بندی نشده'` در `GlobalTables` ثبت کن، فهرست را در گزارش پایانی بیاور، و تست را سبز کن.

فرزندان aggregate (مثل `patient_attachments` که از طریق `record` به `patient_records` وصل است) گزینهٔ سومی هم دارند: به‌جای ستون tenant، از ریشه به ارث می‌رسند. اگر این مسیر را انتخاب کردی، در `GlobalTables` با دلیل صریح `'فرزند aggregate؛ tenant از PatientRecord به ارث می‌رسد'` ثبتشان کن — نه به‌عنوان TODO.

**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`

## نکات مهم

- **الگو: Filter + Event Listener (هر دو مکانیزم خود Doctrine/Symfony).** دلیل انتخاب: هدف «هیچ‌کس نتواند فراموش کند» است و این فقط با قلاب‌زدن به چرخهٔ عمر ORM ممکن است، نه با disciplineِ کدنویسی. جایگزین بررسی‌شده — trait روی repositoryها که خودش `WHERE` را اضافه کند — رد شد چون هر repository جدید باید یادش باشد trait را بگیرد؛ همان مسئله، یک لایه بالاتر.
- **فیلتر جایگزین checkerهای موجود نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` باقی می‌مانند: آن‌ها «این کاربر با این نقش چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها» را. حذف هیچ‌کدام جزو این فاز نیست.
- **جدول `SQLFilter` limitations را در docblock بنویس.** توسعه‌دهندهٔ بعدی که فرض کند فیلتر همه‌جا کار می‌کند، `find()` را امن می‌پندارد و باگ می‌سازد.
- **ادمین و مسیرهای عمومی از فیلتر مستثنی‌اند.** این را با تست تثبیت کن، نه فقط با کامنت — بدون تست، اولین refactor آینده سایت عمومی را بی‌صدا خالی می‌کند.
- **جدول‌های مالی عمداً خارج از دامنهٔ این سری‌اند.** `payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings` مالکیت دوگانه دارند (پرداخت‌کننده در برابر دریافت‌کننده) و طبقه‌بندی‌شان تحلیل جدا می‌خواهد. آن‌ها را با TODO ثبت کن و در گزارش پایانی به‌عنوان کار باقی‌مانده اعلام کن — بی‌صدا رد نشو.
- **کارایی:** فیلتر به هر کوئری دو شرط اضافه می‌کند. ایندکس‌های tenant-پیشرو فاز ۲ همین را جبران می‌کنند. بعد از فعال‌سازی، `EXPLAIN` سنگین‌ترین لیست پنل (نوبت‌ها و بیماران) را بگیر و مطمئن شو هنوز از ایندکس استفاده می‌شود و به `ALL` نیفتاده.
- **مستندات:** یک بخش کوتاه «جداسازی tenant» به `docs/api/README.md` (یا سند معماری معادل) اضافه کن که بگوید فیلتر چه تضمینی می‌دهد و چه تضمینی نمی‌دهد. این تنها سندی است که توسعه‌دهندهٔ بعدی قبل از نوشتن repository جدید می‌خواند.
