fix(tenant): check the environment wherever a uuid comes from the request

Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot
reach. Measuring first — as the plan required — moved the target: all 22 children
and their 20 repositories were already sound. Every list query anchors on its
root, and ServiceItemRepository even joins service_sections and filters on the
pair by hand. A repository-level guard would have found nothing.

The real exposure was one layer up. Where a uuid arrives from a request body or
query string, the entity it names is loaded by uuid alone, and the filter is no
help: aggregate children have no tenant column, and a panel user who never chose
an environment is not filtered at all. Three leaks, each proven by removing the
fix and watching the new tests go red:

- GET /api/v1/appointment-service-slots accepted service_item_uuids from any
  environment. Existence, bookable state and duration leaked through the error
  messages and the returned slots. The booking path in the same controller had
  guarded this since it was written; the slot path never did.
- POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid,
  staff_uuid and the service list without any check, and persisted them onto the
  appointment. A write, not just a read.
- PatientService did the same in all three of its loops — pricing, session
  create, session update — so another environment's service price entered the
  invoice and its SessionService row was stored, staff included.

TenantOwnershipChecker is the single place that answers "does this belong to the
current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now
delegates that pair to its section: an aggregate child exposing the tenant it
inherits. An entity that exposes no pair throws rather than returning false —
silence here builds an always-closed guard, which is its own bug.

TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its
place immediately: the first run found more sites than the manual grep had, and
reviewing them turned up the third PatientService loop. StaffController looked
unguarded until read properly — ownsStaff sits two lines below the null check.

One assertion was wrong before it was right: the create-path test read
`$session['services'] ?? []`, which passes vacuously. It now counts the stored
rows through the repository, and fails without the fix.

Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 13:39:59 +03:30
co-authored by Claude Opus 5
parent 0ae9570850
commit d2f4b5c428
10 changed files with 718 additions and 8 deletions
@@ -207,6 +207,13 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids');
}
// پیش از هر بررسی دیگری: سرویس باید مالِ همین محیط باشد. مسیر ثبت نوبت همین
// گارد را دارد و این مسیر نداشت، پس با uuid سرویسِ محیط دیگر می‌شد وجود،
// فعال‌بودن و مدتش را از پیام‌های خطا و اسلات‌های برگشتی استنتاج کرد.
if (($err = $this->assertServicesMatchContext($uuids, $doctor, $clinic)) !== null) {
return $err;
}
// مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes
$overrides = (array) $request->query->all('durations');
@@ -45,6 +45,7 @@ class MyAppointmentsController extends BaseController
private readonly \App\Auth\Repository\UserRepository $userRepo,
private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo,
private readonly VisitPriceRequirementResolver $visitPriceResolver,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
) {}
/**
@@ -187,7 +188,12 @@ class MyAppointmentsController extends BaseController
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId);
// Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422.
// بخش/سرویس/پرسنل با uuid از خودِ درخواست می‌آیند و TenantFilter پوششان
// نمی‌دهد (سرویس فرزند aggregate است، و کاربرِ بدون محیطِ انتخاب‌شده اصلاً
// فیلتر نمی‌خورد). بدون این بررسی، نوبت با بخش/سرویس/پرسنلِ محیط دیگری
// ذخیره می‌شد — نه فقط خوانده، بلکه نوشته.
$bookingContext = EntityContext::forBooking($doctor, $bookingClinic);
foreach ([
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
@@ -198,11 +204,16 @@ class MyAppointmentsController extends BaseController
continue;
}
$entity = $repo->findByUuid($value);
if ($entity === null) {
if (!$this->tenantOwnership->belongsTo($bookingContext, $entity)) {
return $this->error(ErrorCodes::VALIDATION, $label . ' یافت نشد', 422);
}
$appointment->$setter($entity);
}
if (!$this->tenantOwnership->allBelongTo($bookingContext, $serviceItems)) {
return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids');
}
// پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
foreach ($serviceItems as $si) {
$appointment->addServiceItem($si);
+9
View File
@@ -152,6 +152,15 @@ class ServiceItem
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSection(): ServiceSection { return $this->section; }
/**
* محیط را از بخشِ خودش به ارث می‌برد — ستون tenant ندارد و TenantFilter پوششش
* نمی‌دهد. این دو getter همان جفت را در دسترس می‌گذارند تا مالکیتش با بقیهٔ
* موجودیت‌های محیط‌دار یکسان بررسی شود ({@see \App\Shared\Tenant\TenantOwnershipChecker}).
*/
public function getEntityType(): string { return $this->section->getEntityType(); }
public function getEntityId(): int { return $this->section->getEntityId(); }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getName(): string { return $this->name; }
public function getPriceRials(): int { return $this->priceRials; }
+18 -5
View File
@@ -56,6 +56,7 @@ class PatientService
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo,
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
private readonly LoggerInterface $logger,
) {}
@@ -311,11 +312,12 @@ class PatientService
}
}
// جمع‌آوری service items (با احتساب تعداد)
// جمع‌آوری service items (با احتساب تعداد). سرویسِ محیط دیگر نادیده گرفته
// می‌شود، وگرنه قیمتش وارد محاسبهٔ فاکتور این محیط می‌شد.
$serviceItemsData = [];
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item !== null) {
if ($this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) {
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$serviceItemsData[] = ['item_id' => $item->getId(), 'price_rials' => $item->getPriceRials() * $qty];
}
@@ -364,13 +366,18 @@ class PatientService
$session->addConsumable($sc);
}
// ثبت session services
// ثبت session services — سرویس و پرسنلِ محیط دیگر نادیده گرفته می‌شوند،
// همان قاعده‌ای که کالای مصرفی بالاتر رعایت می‌کند.
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item === null) {
if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) {
continue;
}
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) {
$staff = null;
}
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$ss = new SessionService($session, $item, $staff, $qty);
$this->sessionServiceRepo->save($ss);
@@ -411,9 +418,15 @@ class PatientService
$session->getServices()->removeElement($old);
}
foreach (($data['services'] ?? []) as $svc) {
// سرویس و پرسنل با uuid از بدنهٔ درخواست می‌آیند و روی SessionService
// ذخیره می‌شوند؛ بدون این بررسی، دادهٔ محیط دیگری در مراجعه می‌نشست.
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item === null) { continue; }
if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; }
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) {
$staff = null;
}
$qty = max(1, (int) ($svc['quantity'] ?? 1));
$ss = new SessionService($session, $item, $staff, $qty);
$this->sessionServiceRepo->save($ss);
@@ -0,0 +1,68 @@
<?php
namespace App\Shared\Tenant;
use App\Shared\Context\EntityContext;
/**
* «آیا این موجودیت مالِ همین محیط است؟»
*
* برای جاهایی لازم است که یک uuid از خودِ درخواست می‌آید و موجودیت متناظرش را
* TenantFilter پوشش نمی‌دهد — یا چون فرزند aggregate است (ServiceItem) یا چون
* کاربر هنوز محیطی انتخاب نکرده و فیلتر خاموش است.
*
* موجودیت باید جفت محیطش را با getEntityType()/getEntityId() بدهد. فرزندان
* aggregate می‌توانند این دو را به ریشه delegate کنند، همان‌طور که ServiceItem
* به ServiceSection می‌دهد.
*/
final class TenantOwnershipChecker
{
/** null یعنی موجودیتی پیدا نشد — همان‌قدر «مالِ این محیط نیست» که یک موجودیت بیگانه. */
public function belongsTo(EntityContext $context, ?object $entity): bool
{
if (!$context->isResolved()) {
return false;
}
[$type, $id] = $context->toEntityPair();
return $this->belongsToPair($type, $id, $entity);
}
/**
* برای فراخوانی‌هایی که جفت را از قبل به‌صورت اسکالر دارند و موجودیت محیط را در
* دست ندارند. ساختن EntityContext از اسکالر عمداً ممکن نیست: چنین contextی
* isClinic() درست می‌دهد ولی ->clinic تهی دارد و مصرف‌کننده را بی‌صدا می‌شکند.
*/
public function belongsToPair(string $entityType, int $entityId, ?object $entity): bool
{
if ($entity === null) {
return false;
}
if (!method_exists($entity, 'getEntityType') || !method_exists($entity, 'getEntityId')) {
throw new \InvalidArgumentException(sprintf(
'%s does not expose a tenant pair; add getEntityType()/getEntityId() or delegate them to its aggregate root.',
$entity::class,
));
}
return $entity->getEntityType() === $entityType && $entity->getEntityId() === $entityId;
}
/**
* یک بیگانه در فهرست، کل فهرست را رد می‌کند.
*
* @param iterable<object|null> $entities
*/
public function allBelongTo(EntityContext $context, iterable $entities): bool
{
foreach ($entities as $entity) {
if (!$this->belongsTo($context, $entity)) {
return false;
}
}
return true;
}
}