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>
95 lines
4.0 KiB
PHP
95 lines
4.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
/**
|
|
* Phase 8: give a tenant pair to the aggregate children that are loaded straight
|
|
* from a request uuid.
|
|
*
|
|
* The phase-7 audit 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 missed the case where the anchor never happens:
|
|
*
|
|
* $item = $this->serviceItemRepo->findByUuid($request['service_item_uuid']);
|
|
*
|
|
* A lookup by uuid *is* an unanchored query, and TenantFilter cannot help when
|
|
* the table has no column to filter on. All three leaks found in phase 7 had
|
|
* exactly this shape. With these columns the filter covers them, so a forgotten
|
|
* guard returns nothing instead of another environment's row.
|
|
*
|
|
* Only the eight children actually reachable that way are changed — measured,
|
|
* not guessed. The rest stay aggregate children and are still reached from their
|
|
* root.
|
|
*
|
|
* The pair is derived from the root, and a root never changes environment, so
|
|
* the copy is written once and can never drift.
|
|
*/
|
|
final class Version20260728170000 extends AbstractMigration
|
|
{
|
|
/** table => [join clause, root alias] */
|
|
private const BACKFILL = [
|
|
'patient_attachments' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'patient_calls' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'patient_medical_records' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'patient_messages' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'patient_notes' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'patient_sessions' => ['JOIN patient_records r ON r.id = t.record_id', 'r'],
|
|
'session_payments' => ['JOIN patient_sessions s ON s.id = t.session_id JOIN patient_records r ON r.id = s.record_id', 'r'],
|
|
'service_items' => ['JOIN service_sections sec ON sec.id = t.section_id', 'sec'],
|
|
];
|
|
|
|
public function getDescription(): string
|
|
{
|
|
return 'Give the request-reachable aggregate children their own tenant pair';
|
|
}
|
|
|
|
public function up(Schema $schema): void
|
|
{
|
|
foreach (self::BACKFILL as $table => [$join, $alias]) {
|
|
$this->connection->executeStatement(
|
|
"ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL"
|
|
);
|
|
$this->connection->executeStatement(
|
|
"UPDATE {$table} t {$join}
|
|
SET t.entity_type = {$alias}.entity_type, t.entity_id = {$alias}.entity_id"
|
|
);
|
|
|
|
// A row whose root cannot be reached is already broken data; inventing
|
|
// an environment for it would hide the breakage behind a wrong owner.
|
|
$remaining = (int) $this->connection->fetchOne(
|
|
"SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL"
|
|
);
|
|
$this->abortIf(
|
|
$remaining > 0,
|
|
"{$remaining} row(s) in {$table} have no reachable root; fix them before rerunning."
|
|
);
|
|
|
|
$this->connection->executeStatement(
|
|
"ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL"
|
|
);
|
|
$this->connection->executeStatement(
|
|
"CREATE INDEX idx_{$table}_entity ON {$table} (entity_type, entity_id)"
|
|
);
|
|
}
|
|
}
|
|
|
|
public function down(Schema $schema): void
|
|
{
|
|
foreach (array_keys(self::BACKFILL) as $table) {
|
|
$this->addSql("DROP INDEX idx_{$table}_entity ON {$table}");
|
|
$this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id");
|
|
}
|
|
}
|
|
|
|
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
|
|
public function isTransactional(): bool
|
|
{
|
|
return false;
|
|
}
|
|
}
|