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
+6
View File
@@ -547,6 +547,12 @@ Get all appointments for the authenticated user.
**Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری.
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
> این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان
> بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است.
> آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص
+12
View File
@@ -131,6 +131,12 @@ List the **authenticated user's own** payments (derived from the token — there
**Permission:** `IS_AUTHENTICATED_FULLY`
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
@@ -405,6 +411,12 @@ Get payment status and details.
**Permission:** `AUTH` — must be the payment owner or `ROLE_ADMIN`
> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی
> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک،
> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت.
> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و
> مجوز دست‌نخورده باقی می‌ماند.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
+24
View File
@@ -76,6 +76,30 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده
| SQL خام DBAL | ❌ |
| فرزندان aggregate | ❌ — همیشه از ریشه JOIN کن |
### استثنای مجاز: دادهٔ «مالِ خودِ کاربر»
چند رکورد در محیطِ یک مطب ثبت می‌شوند ولی مالکشان بیمار است، نه آن مطب: پرداختِ نوبت و
خودِ نوبتِ بیمار. مجوزشان با `user_id` بررسی می‌شود، نه با محیط.
اینجا فیلتر چیزی به امنیت اضافه نمی‌کند و فقط ضرر می‌زند: کاربری که خودش صاحب محیط
دیگری است — پزشکی که از مطب دیگری نوبت می‌گیرد، منشی‌ای که جایی بیمار است — رکورد
خودش را نمی‌دید و صفحهٔ نتیجهٔ پرداخت ۴۰۴ می‌شد.
تنها راه مجاز دورزدن، `App\Shared\Tenant\TenantFilterScope::withoutFilter()` است.
عمداً یک کلاس جداست تا جاهای دورزدن قابل شمردن بمانند، و فیلتر را در `finally`
برمی‌گرداند تا بقیهٔ همان درخواست دوباره محدود شود.
مصرف‌کنندگان فعلی — هر سه کاربرمحور، نه محیط‌محور:
| اندپوینت | چرا |
|---|---|
| `GET /api/v1/payment/{uuid}` | پرداخت‌کننده باید پرداخت خودش را ببیند |
| `GET /api/v1/my/payments` | همان، به شکل فهرست |
| `GET /api/v1/appointments/user` | نوبت‌های خودِ فرد به‌عنوان بیمار |
`PayerSeesOwnPaymentTest` هر سه را می‌بندد و هم‌زمان تأیید می‌کند که پرداختِ کاربر
دیگر همچنان ۴۰۳ می‌گیرد.
**فیلتر جایگزین authorization نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها».
### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳
@@ -55,6 +55,7 @@ class AppointmentController extends BaseController
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
private readonly \App\Resource\Service\PublicResourceBookingService $publicResources,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
private readonly \Psr\Log\LoggerInterface $logger,
) {}
@@ -885,9 +886,18 @@ class AppointmentController extends BaseController
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
{
$status = $request->query->get('status');
$appointments = $this->appointmentRepo->findByUser($user, $status);
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
// نوبتِ بیمار در محیطِ پزشکِ مقصد ثبت می‌شود. کاربری که خودش صاحب محیط دیگری
// است — پزشک، منشی، مالک کلینیک — با فیلترِ محیطِ خودش نوبت‌های خودش را
// نمی‌دید. صاحب اینجا با `user_id` تعیین می‌شود، نه با محیط.
// `toArray()` هم داخل محدوده است: proxyهای پزشک و کلینیک آنجا باز می‌شوند و
// با فیلترِ برگشته، Doctrine `EntityNotFoundException` می‌دهد.
$rows = $this->tenantScope->withoutFilter(fn () => array_map(
fn(Appointment $a) => $a->toArray(),
$this->appointmentRepo->findByUser($user, $status),
));
return $this->success(['data' => $rows]);
}
private function canView(Appointment $a, User $user): bool
+28 -7
View File
@@ -44,6 +44,7 @@ class PaymentController extends BaseController
private readonly \App\Subscription\Repository\SubscriptionPeriodRepository $subscriptionPeriodRepo,
private readonly \App\Subscription\Service\SubscriptionTaxCalculator $subscriptionTax,
private readonly \App\Payment\Service\PaymentTaxCalculator $paymentTax,
private readonly \App\Shared\Tenant\TenantFilterScope $tenantScope,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -530,11 +531,16 @@ class PaymentController extends BaseController
$limit = min(100, max(1, (int) $request->query->get('limit', 20)));
$status = $request->query->get('status');
$items = array_map(
// پرداختِ بیمار به محیطِ پزشکِ مقصد تعلق دارد؛ اگر پرداخت‌کننده خودش پزشک یا
// منشی باشد، فیلترِ محیطِ او همان رکورد را پنهان می‌کند. مجوز اینجا با
// `user_id` بررسی می‌شود، پس فیلتر محیط چیزی به امنیت اضافه نمی‌کند.
[$items, $total] = $this->tenantScope->withoutFilter(fn () => [
array_map(
fn(Payment $p) => $p->toArray(),
$this->paymentRepo->findByUser($user, $status, $page, $limit)
);
$total = $this->paymentRepo->countByUser($user, $status);
$this->paymentRepo->findByUser($user, $status, $page, $limit),
),
$this->paymentRepo->countByUser($user, $status),
]);
return $this->paginated($items, $total, $page, $limit);
}
@@ -543,16 +549,31 @@ class PaymentController extends BaseController
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
{
// مثل myPayments: صاحبِ پرداخت با `user_id` تعیین می‌شود، نه با محیط.
//
// سریال‌سازی هم داخل همین محدوده است، نه فقط کوئری: `toArray()` proxyهای
// نوبت و پزشک را باز می‌کند و اگر فیلتر تا آن لحظه برگشته باشد، Doctrine
// `EntityNotFoundException` می‌دهد که همان ۴۰۴ را برمی‌گرداند.
$result = $this->tenantScope->withoutFilter(function () use ($uuid, $user) {
$payment = $this->paymentRepo->findByUuid($uuid);
if ($payment === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404);
return ['error' => [ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404]];
}
if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
return ['error' => [ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403]];
}
return $this->success($payment->toArray());
return ['data' => $payment->toArray()];
});
if (isset($result['error'])) {
[$code, $message, $status] = $result['error'];
return $this->error($code, $message, $status);
}
return $this->success($result['data']);
}
// ── Private helpers ───────────────────────────────────────────────────────
+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);
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Tests\Payment;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
* پرداخت‌کننده باید پرداخت خودش را ببیند، حتی وقتی خودش صاحب یک محیط دیگر است.
*
* پرداختِ بیمار به محیطِ پزشکِ مقصد تعلق دارد. `TenantFilterSubscriber` برای کاربری
* که محیط حل‌شده دارد فیلتر را روشن می‌کند، و چون `Payment` محیط‌دار است، پزشکی که
* از مطب دیگری نوبت گرفته بود صفحهٔ نتیجهٔ پرداخت خودش را ۴۰۴ می‌گرفت.
*/
class PayerSeesOwnPaymentTest extends ApiTestCase
{
/**
* پزشکی که محیط کاریِ فعالش هم ذخیره شده است.
*
* بدون این ردیف، `EntityContextResolver` محیط را «انتخاب‌شده» نمی‌داند و
* `TenantFilterSubscriber` فیلتر را روشن نمی‌کند — یعنی تست باگ را نمی‌بیند،
* درحالی‌که کاربر واقعی که وارد پنل شده آن را می‌گیرد.
*/
private function makeDoctor(string $name): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
$this->em->persist(new UserActiveContext($user, $doctor->getUuid(), EntityContext::TYPE_DOCTOR));
$this->em->flush();
return $doctor;
}
/** پرداختی که «پزشکِ پرداخت‌کننده» برای نوبتِ «پزشکِ مقصد» انجام داده است. */
private function makeCrossTenantPayment(Doctor $target, User $payer): Payment
{
$start = strtotime('+20 days') + random_int(0, 500_000) * 7;
$appointment = $this->newAppointment($target, $payer, $start, $start + 900);
$appointment->setVisitPriceRials(1_500_000);
$this->em->persist($appointment);
$this->em->flush();
$payment = new Payment($payer, 1_500_000, 'mellat', Payment::TYPE_APPOINTMENT, '');
$payment->setAppointment($appointment);
$payment->assignTenant(EntityContext::forBooking($target, null));
$this->em->persist($payment);
$this->em->flush();
return $payment;
}
public function testDoctorSeesThePaymentHeMadeAtAnotherPractice(): void
{
$target = $this->makeDoctor('پزشک مقصد');
$payer = $this->makeDoctor('پزشک پرداخت‌کننده');
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
$body = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $payer->getUser());
self::assertSame(200, $this->responseCode());
self::assertSame($payment->getUuid(), $body['data']['uuid']);
}
public function testTheSamePaymentAppearsInHisPaymentsList(): void
{
$target = $this->makeDoctor('پزشک مقصد');
$payer = $this->makeDoctor('پزشک پرداخت‌کننده');
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
$body = $this->authJson('GET', '/api/v1/my/payments?limit=100', $payer->getUser());
self::assertSame(200, $this->responseCode());
self::assertContains(
$payment->getUuid(),
array_column($body['data'], 'uuid'),
'پرداختِ خودِ کاربر باید در فهرست پرداخت‌هایش باشد',
);
}
/** بیمار ساده — بدون محیط — از قبل هم کار می‌کرد و نباید بشکند. */
public function testPlainPatientStillSeesHisPayment(): void
{
$target = $this->makeDoctor('پزشک مقصد');
$patient = $this->createUser();
$payment = $this->makeCrossTenantPayment($target, $patient);
$body = $this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $patient);
self::assertSame(200, $this->responseCode());
self::assertSame($payment->getUuid(), $body['data']['uuid']);
}
/** دورزدنِ فیلتر نباید به معنی بازشدن پرداختِ دیگران باشد. */
public function testAnotherUserIsStillForbidden(): void
{
$target = $this->makeDoctor('پزشک مقصد');
$payer = $this->createUser();
$stranger = $this->createUser();
$payment = $this->makeCrossTenantPayment($target, $payer);
$this->authJson('GET', '/api/v1/payment/' . $payment->getUuid(), $stranger);
self::assertSame(403, $this->responseCode());
}
/**
* همان الگو روی نوبت: نوبتی که کاربر به‌عنوان بیمار گرفته، در محیطِ پزشکِ مقصد
* ثبت می‌شود و برای کاربرِ صاحبِ محیطِ دیگر نامرئی می‌شد.
*/
public function testDoctorSeesTheAppointmentHeBookedAtAnotherPractice(): void
{
$target = $this->makeDoctor('پزشک مقصد');
$payer = $this->makeDoctor('پزشک بیمار');
$payment = $this->makeCrossTenantPayment($target, $payer->getUser());
$body = $this->authJson('GET', '/api/v1/appointments/user', $payer->getUser());
self::assertSame(200, $this->responseCode());
self::assertContains(
$payment->getAppointment()->getUuid(),
array_column($body['data']['data'] ?? $body['data'], 'uuid'),
'نوبتِ خودِ کاربر باید در فهرست نوبت‌هایش باشد',
);
}
public function testUnknownPaymentIsStillNotFound(): void
{
$user = $this->createUser();
$this->authJson('GET', '/api/v1/payment/00000000-0000-4000-8000-000000000000', $user);
self::assertSame(404, $this->responseCode());
}
}