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>
212 lines
8.0 KiB
PHP
212 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Shared;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Auth\Entity\UserActiveContext;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Clinic\Entity\ClinicDoctorPermission;
|
|
use App\Discount\Entity\DiscountRule;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Shared\Context\EntityContext;
|
|
use App\Shared\Tenant\TenantFilter;
|
|
use App\Tests\ApiTestCase;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
|
|
/**
|
|
* فیلتر باید **بهتنهایی** جلوی cross-tenant را بگیرد — یعنی حتی کوئریای که هیچ
|
|
* شرط دستی روی محیط ندارد. بقیهٔ تستها رفتار endpointها را میسنجند؛ اینجا خودِ
|
|
* تور ایمنی سنجیده میشود.
|
|
*/
|
|
class TenantFilterLeakTest extends ApiTestCase
|
|
{
|
|
private function em(): EntityManagerInterface
|
|
{
|
|
return static::getContainer()->get(EntityManagerInterface::class);
|
|
}
|
|
|
|
private function makeDoctor(?User $user = null): Doctor
|
|
{
|
|
$doctor = new Doctor($user ?? $this->createUser(['ROLE_DOCTOR']), 'دکتر نشتی');
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
private function makeClinic(?User $owner = null): Clinic
|
|
{
|
|
$clinic = new Clinic($owner ?? $this->createUser(['ROLE_CLINIC']));
|
|
$clinic->setName('کلینیک نشتی');
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
return $clinic;
|
|
}
|
|
|
|
private function rule(string $type, int $id, string $name): DiscountRule
|
|
{
|
|
$rule = new DiscountRule($type, $id, $name, DiscountRule::TYPE_INVOICE_AMOUNT);
|
|
$this->em->persist($rule);
|
|
$this->em->flush();
|
|
|
|
return $rule;
|
|
}
|
|
|
|
private function enableFilterFor(string $type, int $id): void
|
|
{
|
|
$this->em()->getFilters()
|
|
->enable(TenantFilter::NAME)
|
|
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
|
|
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$filters = $this->em()->getFilters();
|
|
if ($filters->isEnabled(TenantFilter::NAME)) {
|
|
$filters->disable(TenantFilter::NAME);
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/** ✅ کوئری بدون هیچ WHERE دستی، فقط ردیفهای محیط جاری را میدهد. */
|
|
public function testFilterAloneBlocksCrossTenantRowsWithoutAnyManualCondition(): void
|
|
{
|
|
$mine = $this->makeDoctor();
|
|
$other = $this->makeDoctor();
|
|
|
|
$this->rule('doctor', $mine->getId(), 'قانون من');
|
|
$this->rule('doctor', $other->getId(), 'قانون دیگری');
|
|
$this->em->clear();
|
|
|
|
$this->enableFilterFor('doctor', $mine->getId());
|
|
|
|
$names = array_map(
|
|
static fn(DiscountRule $r) => $r->getName(),
|
|
$this->em()->createQuery('SELECT r FROM ' . DiscountRule::class . ' r')->getResult(),
|
|
);
|
|
|
|
self::assertContains('قانون من', $names);
|
|
self::assertNotContains('قانون دیگری', $names);
|
|
}
|
|
|
|
/** findOneBy هم فیلتر میخورد — رکورد محیط دیگر «وجود ندارد». */
|
|
public function testFindOneByCannotReachAnotherTenantsRow(): void
|
|
{
|
|
$mine = $this->makeDoctor();
|
|
$other = $this->makeDoctor();
|
|
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
|
|
$this->em->clear();
|
|
|
|
$this->enableFilterFor('doctor', $mine->getId());
|
|
|
|
self::assertNull(
|
|
$this->em()->getRepository(DiscountRule::class)->findOneBy(['uuid' => $foreign->getUuid()]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* find() با کلید اصلی هم فیلتر میخورد — در Doctrine ORM 3 برخلاف نسخههای قدیمی
|
|
* که این مسیر را استثنا میکردند. تضمین قویتری است، ولی چون رفتارِ کتابخانه است
|
|
* نه قرارداد ما، اینجا تثبیتش میکنیم: اگر روزی برگردد، این تست خبر میدهد و سند
|
|
* tenancy باید بهروز شود.
|
|
*/
|
|
public function testFindByPrimaryKeyIsAlsoFiltered(): void
|
|
{
|
|
$mine = $this->makeDoctor();
|
|
$other = $this->makeDoctor();
|
|
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
|
|
$id = $foreign->getId();
|
|
$this->em->clear();
|
|
|
|
$this->enableFilterFor('doctor', $mine->getId());
|
|
|
|
self::assertNull($this->em()->find(DiscountRule::class, $id));
|
|
}
|
|
|
|
/**
|
|
* ⚠️ محدودیت واقعی: entity که از قبل در identity map است دوباره کوئری نمیشود،
|
|
* پس فیلتر آن را نمیبیند. به همین دلیل فیلتر جایگزین authorization نیست.
|
|
*/
|
|
public function testAnEntityAlreadyInMemoryIsNotHiddenByTheFilter(): void
|
|
{
|
|
$mine = $this->makeDoctor();
|
|
$other = $this->makeDoctor();
|
|
$foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه');
|
|
$id = $foreign->getId();
|
|
|
|
$this->enableFilterFor('doctor', $mine->getId());
|
|
|
|
self::assertNotNull($this->em()->find(DiscountRule::class, $id), 'از identity map برمیگردد، نه از دیتابیس');
|
|
}
|
|
|
|
/** ⚠️ مسیر عمومی مارکتپلیس کاربر پنل ندارد؛ فیلتر نباید سایت را خالی کند. */
|
|
public function testPublicDoctorSearchStaysCrossTenant(): void
|
|
{
|
|
$this->makeDoctor();
|
|
$this->makeDoctor();
|
|
|
|
$this->client->request('GET', '/api/v1/doctors?limit=5');
|
|
|
|
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
|
}
|
|
|
|
/** ادمین سراسری است و فیلتر نمیخورد. */
|
|
public function testAdminIsNotFiltered(): void
|
|
{
|
|
$admin = $this->createUser(['ROLE_ADMIN']);
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
|
|
$this->rule('doctor', $doctor->getId(), 'قانون پزشک');
|
|
$this->rule('clinic', $clinic->getId(), 'قانون کلینیک');
|
|
|
|
$this->authJson('GET', '/api/v1/admin/discount-rules', $admin);
|
|
|
|
self::assertNotSame(500, $this->responseCode(), 'ادمین نباید با فیلتر بشکند');
|
|
}
|
|
|
|
/**
|
|
* ⚠️ کاربری که هنوز محیطی انتخاب نکرده فیلتر نمیخورد — در هیچ محیطی «نیست» و
|
|
* دسترسیاش را همان checkerهای دامنه تعیین میکنند.
|
|
*/
|
|
public function testUserWithoutAChosenContextIsNotFiltered(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
|
|
$this->em->flush();
|
|
|
|
$resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class);
|
|
|
|
self::assertFalse(
|
|
$resolver->resolve($doctor->getUser())->chosen,
|
|
'محیطِ برآمده از نقش، انتخاب کاربر نیست',
|
|
);
|
|
}
|
|
|
|
/** محیطی که کاربر صریحاً انتخاب کرده، «انتخابشده» علامت میخورد. */
|
|
public function testStoredActiveContextCountsAsChosen(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
|
|
$this->em->persist(new UserActiveContext(
|
|
$doctor->getUser(),
|
|
$clinic->getUuid(),
|
|
EntityContext::TYPE_CLINIC,
|
|
));
|
|
$this->em->flush();
|
|
|
|
$resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class);
|
|
$context = $resolver->resolve($doctor->getUser());
|
|
|
|
self::assertTrue($context->chosen);
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
}
|
|
}
|