feat(tenant): give a tenant pair to the children reachable by a request uuid

Phase 7 concluded that aggregate children needed no column of their own,
because every repository query anchors to its root. That was true of the
repositories, and it missed the case where the anchor never happens:

    $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);

A lookup by uuid is itself an unanchored query, and TenantFilter cannot help
when the table has no column to filter on. All three leaks phase 7 found had
exactly this shape, including the one that put another environment's service
price on a patient's invoice.

Measuring which children are actually loaded that way gives eight of the
twenty-five — service_items (15 call sites), patient_sessions (7),
session_payments, patient_notes, patient_calls, patient_messages,
patient_attachments, patient_medical_records. They now carry their own pair
and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed
from their root and stay as they were.

The pair is derived from the root inside the constructor rather than passed
in, so no creation site can forget it and the value has one source. A root
never changes environment, so the copy is written once and cannot drift.

This is defence at the data layer rather than at the entry point: a forgotten
guard now returns nothing instead of another environment's row. The existing
TenantOwnershipChecker guards stay as the outer layer.

Verified against an imported production database: 8 tables backfilled, zero
rows unmatched, zero rows inconsistent with their root. Dropping the column
again turns the leak test red.

Tests: 911 backend (+5). PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 16:50:51 +03:30
co-authored by Claude Opus 5
parent 74d2034158
commit a95ee9a618
12 changed files with 323 additions and 18 deletions
+5 -8
View File
@@ -7,13 +7,17 @@ use App\Insurance\Enum\ServiceCategory;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_items_entity')]
class ServiceItem
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -97,6 +101,7 @@ class ServiceItem
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->section = $section;
$this->assignTenantPair($section->getEntityType(), $section->getEntityId());
$this->name = $name;
$this->priceRials = $priceRials;
$this->createdAt = time();
@@ -153,14 +158,6 @@ class ServiceItem
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; }
+5
View File
@@ -3,15 +3,19 @@
namespace App\Patient\Entity;
use App\Patient\Repository\PatientAttachmentRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A file attached to a patient record (the «ضمیمه» tab). */
#[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)]
#[ORM\Table(name: 'patient_attachments')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_attachments_entity')]
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')]
class PatientAttachment
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -43,6 +47,7 @@ class PatientAttachment
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->name = $name;
$this->url = $url;
$this->mime = $mime;
+5
View File
@@ -3,15 +3,19 @@
namespace App\Patient\Entity;
use App\Patient\Repository\PatientCallRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A logged phone call with a patient (the «کال سنتر» tab). */
#[ORM\Entity(repositoryClass: PatientCallRepository::class)]
#[ORM\Table(name: 'patient_calls')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_calls_entity')]
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_calls_record')]
class PatientCall
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -51,6 +55,7 @@ class PatientCall
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->subject = $subject;
$this->outcome = $outcome;
$this->calledAt = $calledAt ?? time();
@@ -3,15 +3,19 @@
namespace App\Patient\Entity;
use App\Patient\Repository\PatientMedicalRecordRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */
#[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)]
#[ORM\Table(name: 'patient_medical_records')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_medical_records_entity')]
#[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')]
class PatientMedicalRecord
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -41,6 +45,7 @@ class PatientMedicalRecord
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->title = $title;
$this->body = $body;
$this->createdAt = time();
+5
View File
@@ -3,15 +3,19 @@
namespace App\Patient\Entity;
use App\Patient\Repository\PatientMessageRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A logged message/communication with a patient (the «پیام‌ها» tab). */
#[ORM\Entity(repositoryClass: PatientMessageRepository::class)]
#[ORM\Table(name: 'patient_messages')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_messages_entity')]
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_messages_record')]
class PatientMessage
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -38,6 +42,7 @@ class PatientMessage
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->body = $body;
$this->channel = $channel;
$this->createdAt = time();
+5
View File
@@ -4,6 +4,7 @@ namespace App\Patient\Entity;
use App\Auth\Entity\User;
use App\Patient\Repository\PatientNoteRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@@ -14,9 +15,12 @@ use Symfony\Component\Uid\Uuid;
*/
#[ORM\Entity(repositoryClass: PatientNoteRepository::class)]
#[ORM\Table(name: 'patient_notes')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_notes_entity')]
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_notes_record')]
class PatientNote
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -56,6 +60,7 @@ class PatientNote
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->body = $body;
$this->pinned = $pinned;
$this->createdAt = time();
+5
View File
@@ -8,14 +8,18 @@ use App\Inventory\Entity\InventoryPackage;
use App\Patient\Repository\PatientSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: PatientSessionRepository::class)]
#[ORM\Table(name: 'patient_sessions')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_sessions_entity')]
#[ORM\Index(columns: ['created_at'], name: 'idx_patient_sessions_created_at')]
class PatientSession
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -143,6 +147,7 @@ class PatientSession
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->assignTenantPair($record->getEntityType(), $record->getEntityId());
$this->appointment = $appointment;
$this->createdAt = time();
$this->updatedAt = time();
+5
View File
@@ -4,6 +4,7 @@ namespace App\Patient\Entity;
use App\Auth\Entity\User;
use App\Patient\Repository\SessionPaymentRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@@ -13,8 +14,11 @@ use Symfony\Component\Uid\Uuid;
*/
#[ORM\Entity(repositoryClass: SessionPaymentRepository::class)]
#[ORM\Table(name: 'session_payments')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_session_payments_entity')]
class SessionPayment
{
use TenantOwnedTrait;
public const METHODS = ['wallet', 'pos', 'cash', 'card'];
#[ORM\Id]
@@ -60,6 +64,7 @@ class SessionPayment
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->session = $session;
$this->assignTenantPair($session->getEntityType(), $session->getEntityId());
$this->method = $method;
$this->amountRials = $amountRials;
$this->paidAt = $paidAt ?? time();
+4 -8
View File
@@ -80,23 +80,19 @@ final class GlobalTables
* ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
*
* فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی
* ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را
* گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند.
*
* @var array<class-string, class-string> فرزند => ریشه
*/
public const AGGREGATE_CHILDREN = [
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
\App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class,
\App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class,
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,