diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 25b1c6b8..40e08616 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -547,6 +547,12 @@ Get all appointments for the authenticated user. **Permission:** `AUTH` — عمداً بدون مجوزِ رجیستری. +> **فیلتر محیط اینجا اعمال نمی‌شود.** رکورد در محیطِ پزشکِ مقصد ثبت می‌شود، ولی +> مالکش از راه `user_id` تعیین می‌شود. کاربری که خودش صاحب محیط دیگری است — پزشک، +> منشی، مالک کلینیک — با فیلترِ محیطِ خودش رکورد خودش را نمی‌دید و ۴۰۴ می‌گرفت. +> دورزدن فیلتر فقط از راه `App\Shared\Tenant\TenantFilterScope` انجام می‌شود و +> مجوز دست‌نخورده باقی می‌ماند. + > این اندپوینت `a.user = خودِ کاربر` را می‌دهد، یعنی نوبت‌های خودِ فرد **به‌عنوان > بیمار**، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است. > آدیت ۲۰۲۶-۰۸-۰۷ آن را در فهرست گَپ‌ها آورده بود؛ در ۲۰۲۶-۰۸-۰۸ مثبت کاذب تشخیص diff --git a/docs/api/payment.md b/docs/api/payment.md index a195f7a6..d1152d43 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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 | |-------|------|-------------| diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index 529010c2..c5bf546d 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -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` سر جایشان می‌مانند: آن‌ها «چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها». ### تغییر رفتار: ۴۰۴ به‌جای ۴۰۳ diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index b518a33f..6e436162 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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, ) {} @@ -884,10 +885,19 @@ class AppointmentController extends BaseController #[Route('/api/v1/appointments/user', methods: ['GET'])] public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse { - $status = $request->query->get('status'); - $appointments = $this->appointmentRepo->findByUser($user, $status); + $status = $request->query->get('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 diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 39e68a05..f69bd13f 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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( - fn(Payment $p) => $p->toArray(), - $this->paymentRepo->findByUser($user, $status, $page, $limit) - ); - $total = $this->paymentRepo->countByUser($user, $status); + // پرداختِ بیمار به محیطِ پزشکِ مقصد تعلق دارد؛ اگر پرداخت‌کننده خودش پزشک یا + // منشی باشد، فیلترِ محیطِ او همان رکورد را پنهان می‌کند. مجوز اینجا با + // `user_id` بررسی می‌شود، پس فیلتر محیط چیزی به امنیت اضافه نمی‌کند. + [$items, $total] = $this->tenantScope->withoutFilter(fn () => [ + array_map( + fn(Payment $p) => $p->toArray(), + $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 { - $payment = $this->paymentRepo->findByUuid($uuid); - if ($payment === null) { - return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404); + // مثل myPayments: صاحبِ پرداخت با `user_id` تعیین می‌شود، نه با محیط. + // + // سریال‌سازی هم داخل همین محدوده است، نه فقط کوئری: `toArray()` proxyهای + // نوبت و پزشک را باز می‌کند و اگر فیلتر تا آن لحظه برگشته باشد، Doctrine + // `EntityNotFoundException` می‌دهد که همان ۴۰۴ را برمی‌گرداند. + $result = $this->tenantScope->withoutFilter(function () use ($uuid, $user) { + $payment = $this->paymentRepo->findByUuid($uuid); + if ($payment === null) { + return ['error' => [ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404]]; + } + + if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return ['error' => [ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403]]; + } + + return ['data' => $payment->toArray()]; + }); + + if (isset($result['error'])) { + [$code, $message, $status] = $result['error']; + + return $this->error($code, $message, $status); } - if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); - } - - return $this->success($payment->toArray()); + return $this->success($result['data']); } // ── Private helpers ─────────────────────────────────────────────────────── diff --git a/src/Shared/Tenant/TenantFilterScope.php b/src/Shared/Tenant/TenantFilterScope.php new file mode 100644 index 00000000..10566e7a --- /dev/null +++ b/src/Shared/Tenant/TenantFilterScope.php @@ -0,0 +1,63 @@ +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]); + } + } + } +} diff --git a/src/Shared/Tenant/TenantFilterSubscriber.php b/src/Shared/Tenant/TenantFilterSubscriber.php index 051bf3db..3d0dc938 100644 --- a/src/Shared/Tenant/TenantFilterSubscriber.php +++ b/src/Shared/Tenant/TenantFilterSubscriber.php @@ -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); } } diff --git a/tests/Payment/PayerSeesOwnPaymentTest.php b/tests/Payment/PayerSeesOwnPaymentTest.php new file mode 100644 index 00000000..358d7479 --- /dev/null +++ b/tests/Payment/PayerSeesOwnPaymentTest.php @@ -0,0 +1,148 @@ +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()); + } +}