ModelScopedTable.php
PHP
Path: src/Database/ModelScopedTable.php
<?php
namespace mini\Database;
use mini\Authorizer\Ability;
use mini\Exceptions\AccessDeniedException;
use mini\Table\AbstractTable;
use mini\Table\Contracts\MutableTableInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Utility\Set;
use mini\Table\Wrappers\AbstractTableWrapper;
use function mini\can;
use function mini\model;
/**
* MutableTableInterface decorator that enforces Model authorization on writes
*
* Wraps the inner table registered in VirtualDatabase. By the time rows reach
* this decorator, VDB has already applied the scope WHERE at the SQL level
* (Layer 1). This decorator adds per-entity can() checks (Layer 2).
*
* Write operations use a snapshot-then-pin pattern (in-memory equivalent of
* SELECT FOR UPDATE): rows are materialized once, authorization is checked on
* the snapshot, and the actual operation is pinned to exactly those row IDs.
* This eliminates the TOCTOU gap between checking and operating.
*
* - insert(): gates on can(Create, $modelClass)
* - update(): snapshots rows, checks can(Update, $entity) per row, pins by PK
* - delete(): snapshots rows, checks can(Delete, $entity) per row, pins by PK
* - reads: pass through (VDB applied readable scope at SQL level)
*/
class ModelScopedTable extends AbstractTableWrapper implements MutableTableInterface
{
private MutableTableInterface $mutableSource;
private string $primaryKey;
public function __construct(
AbstractTable&MutableTableInterface $source,
private ModelTableConfig $config,
) {
parent::__construct($source);
$this->mutableSource = $source;
$this->primaryKey = model($config->modelClass)->primaryKey;
}
/**
* Get the model configuration for this table
*/
public function getModelConfig(): ModelTableConfig
{
return $this->config;
}
// =========================================================================
// MutableTableInterface — intercepted for authorization
// =========================================================================
public function insert(array $row): int|string
{
$allow = $this->config->allowInsert ?? can(Ability::Create, $this->config->modelClass);
if (!$allow) {
throw new AccessDeniedException(
"Not authorized to create " . $this->config->modelClass
);
}
return $this->mutableSource->insert($row);
}
public function update(TableInterface $query, array $changes): int
{
$pk = $this->primaryKey;
// Snapshot: materialize rows once and check authorization.
// This is the in-memory equivalent of SELECT ... FOR UPDATE —
// we pin the operation to exactly the rows we authorized.
$authorizedIds = [];
foreach ($query as $row) {
$entity = Dehydrator::hydrate($row, $this->config->modelClass);
if (!can(Ability::Update, $entity)) {
throw new AccessDeniedException(
"Not authorized to update " . $this->config->modelClass
);
}
$authorizedIds[] = $row->$pk;
}
if (empty($authorizedIds)) {
return 0;
}
// Pin to exactly the authorized rows by primary key
$pinned = $this->mutableSource->in($pk, new Set($pk, $authorizedIds));
return $this->mutableSource->update($pinned, $changes);
}
public function delete(TableInterface $query): int
{
$pk = $this->primaryKey;
// Snapshot and authorize (same pattern as update)
$authorizedIds = [];
foreach ($query as $row) {
$entity = Dehydrator::hydrate($row, $this->config->modelClass);
if (!can(Ability::Delete, $entity)) {
throw new AccessDeniedException(
"Not authorized to delete " . $this->config->modelClass
);
}
$authorizedIds[] = $row->$pk;
}
if (empty($authorizedIds)) {
return 0;
}
// Pin to exactly the authorized rows by primary key
$pinned = $this->mutableSource->in($pk, new Set($pk, $authorizedIds));
return $this->mutableSource->delete($pinned);
}
}