feat(tenant): implement tenant filter scope to manage cross-tenant data visibility
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user