feat(tenant): implement tenant filter scope to manage cross-tenant data visibility

This commit is contained in:
hamed
2026-08-10 16:22:00 +03:30
parent ee2682e222
commit 2459625c41
8 changed files with 304 additions and 20 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Shared\Tenant;
use Doctrine\ORM\EntityManagerInterface;
/**
* روشن و خاموش کردن فیلترِ محیط — تنها جایی که اجازه دارد این کار را بکند.
*
* دو مسئولیت به هم گره خورده‌اند و برای همین یک کلاس‌اند: `enable()` در Doctrine یک
* نمونهٔ تازه از فیلتر می‌سازد و پارامترهای قبلی را دور می‌ریزد، پس هر کسی که فیلتر
* را موقتاً خاموش کند باید بتواند دقیقاً همان جفت محیط را برگرداند. نگه‌داشتن آن جفت
* اینجاست، وگرنه اولین کوئریِ بعد از روشن‌شدن دوباره با
* «Parameter 'tenant_entity_type' does not exist» می‌ترکد.
*/
final class TenantFilterScope
{
/** @var array{0: string, 1: int}|null جفتِ محیطِ فعال، اگر فیلتر روشن باشد. */
private ?array $active = null;
public function __construct(private readonly EntityManagerInterface $em) {}
/** محدودکردن همهٔ کوئری‌های این درخواست به یک محیط. */
public function apply(string $entityType, int $entityId): void
{
$this->active = [$entityType, $entityId];
$this->em->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $entityType, 'string')
->setParameter(TenantFilter::PARAM_ID, $entityId, 'integer');
}
/**
* اجرای یک کوئری بیرون از فیلتر.
*
* برای مسیرهای «مالِ خودِ کاربر» است که مجوزشان با `user_id` بررسی می‌شود: پرداختِ
* بیمار و نوبتِ او در محیطِ پزشکِ مقصد ثبت می‌شوند، پس کاربری که خودش صاحب محیط
* دیگری است رکورد خودش را نمی‌دید.
*
* @template T
* @param callable():T $query
* @return T
*/
public function withoutFilter(callable $query): mixed
{
$filters = $this->em->getFilters();
$restore = $filters->isEnabled(TenantFilter::NAME) ? $this->active : null;
if ($restore !== null) {
$filters->disable(TenantFilter::NAME);
}
try {
return $query();
} finally {
// حتی وقتی کوئری استثنا می‌دهد، بقیهٔ همان درخواست باید دوباره محدود شود.
if ($restore !== null) {
$this->apply($restore[0], $restore[1]);
}
}
}
}
+4 -4
View File
@@ -24,6 +24,7 @@ final class TenantFilterSubscriber implements EventSubscriberInterface
public function __construct(
private readonly Security $security,
private readonly EntityContextResolver $contextResolver,
private readonly TenantFilterScope $scope,
private readonly EntityManagerInterface $em,
) {}
@@ -54,9 +55,8 @@ final class TenantFilterSubscriber implements EventSubscriberInterface
[$type, $id] = $context->toEntityPair();
$this->em->getFilters()
->enable(TenantFilter::NAME)
->setParameter(TenantFilter::PARAM_TYPE, $type, 'string')
->setParameter(TenantFilter::PARAM_ID, $id, 'integer');
// روشن‌کردن از راه scope انجام می‌شود تا همان‌جا جفتِ محیط هم ثبت شود؛
// مسیرهایی که موقتاً فیلتر را خاموش می‌کنند بدون آن نمی‌توانند برش گردانند.
$this->scope->apply($type, $id);
}
}