Aceita fila administrativa de associacoes com checkpoint local

This commit is contained in:
2026-11-02 11:20:13 +02:00
parent fcf0ada227
commit 340f3bb5fa
15 changed files with 635 additions and 67 deletions
@@ -0,0 +1,211 @@
<?php
namespace App\Modules\Identity\Services;
use App\Shared\Context\{CompanyContext, ModuleNavigation, ModuleRecords};
use Illuminate\Support\Facades\{Crypt, DB};
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class ModuleAssociationReview
{
public function __construct(private CompanyContext $context, private Permissions $permissions) {}
private function scope(string $area, bool $edit = false): array
{
$s = $this->context->identifiers();
$unit = str_ends_with($area, '_drafts') ? $this->context->unitId() : null;
abort_if(str_ends_with($area, '_drafts') && !$unit, 403);
foreach (['access.manage', "$area.view", ...($edit ? ["$area.edit"] : [])] as $right) {
$this->permissions->require($this->context->userId(), $s['tenant_id'], $s['company_id'], $right, $right === 'access.manage' ? null : $unit);
}
return $s + ['unit_id' => $unit];
}
private function events(string $area, int $id)
{
return DB::table('identity_audit')->where($this->context->identifiers())
->whereIn('action', ['module.record.associated', 'module.record.disassociated', 'module.record.reviewed'])
->whereRaw("details::jsonb->>'area' = ?", [$area])->whereRaw("details::jsonb->>'record_id' = ?", [(string) $id]);
}
private function links(string $area, int $id): array
{
$events = $this->events($area, $id)->where('action', '<>', 'module.record.reviewed')
->selectRaw("DISTINCT ON (details::jsonb->>'module_id') action,details")
->orderByRaw("details::jsonb->>'module_id'")->orderByDesc('id')->get();
$modules = $events
->filter(fn ($e) => $e->action === 'module.record.associated')
->map(fn ($e) => json_decode($e->details, true)['module_id'])->sort()->values()->all();
return ['modules' => $modules, 'revision' => (int) ($this->events($area, $id)->max('id') ?? 0)];
}
private function row(string $area, int $id, bool $edit = false): object
{
$row = DB::table('a_'.$area)->where($this->scope($area, $edit))->where('id', $id)->first();
abort_unless($row, 403, 'Registo ou dependência fora do âmbito autorizado.');
return $row;
}
// Traverse explicit references, never infer a destination or grant access.
private function graph(string $area, int $id, array &$seen): void
{
$key = "$area:$id";
if (isset($seen[$key])) return;
$row = $this->row($area, $id);
$links = $this->links($area, $id);
$seen[$key] = ['area' => $area, 'id' => $id, 'version' => (int) $row->version,
'checksum' => hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR)), ...$links];
$data = json_decode($row->data, true, 512, JSON_THROW_ON_ERROR);
if (isset($row->client_id)) $this->graph('clients', (int) $row->client_id, $seen);
if (isset($row->source_id)) $this->graph('service_drafts', (int) $row->source_id, $seen);
foreach (str_ends_with($area, '_drafts') ? ($data['lines'] ?? []) : [] as $line) {
if (isset($line['service_id'])) $this->graph('services', (int) $line['service_id'], $seen);
}
}
private function snapshot(string $area, int $id): array
{
$graph = [];
$this->graph($area, $id, $graph);
ksort($graph);
$rights = DB::table('permission_grants')->where($this->context->identifiers())->where('user_id', $this->context->userId())->orderBy('id')->get();
return ['graph' => $graph, 'modules' => app(ModuleNavigation::class)->available($this->context),
'permissions' => hash('sha256', $rights->toJson())];
}
private function digest(array $value): string { return hash('sha256', json_encode($value, JSON_THROW_ON_ERROR)); }
public function listing(Request $r): array
{
abort_unless(hash_equals((string) $r->session()->get('company_context.token'), (string) $r->header('X-SGF-Context')), 409, 'O contexto mudou. Reabra a fila.');
$d = $r->validate(['area' => 'sometimes|in:'.implode(',', ModuleRecords::AREAS), 'state' => 'sometimes|in:all,pending,associated,removed',
'page' => 'sometimes|integer|min:1|max:100000', 'per_page' => 'sometimes|integer|min:1|max:100', 'search' => 'nullable|string|max:100',
'unit_id' => 'sometimes|integer|min:1']);
abort_if(array_diff(array_keys($r->query()), array_keys($d)) !== [], 422);
$area = $d['area'] ?? 'clients';
$scope = $this->scope($area);
if (isset($d['unit_id'])) abort_unless((int) $d['unit_id'] === $this->context->unitId(), 403);
$query = DB::table('a_'.$area)->where($scope);
$active = "EXISTS (SELECT 1 FROM identity_audit ma WHERE ma.tenant_id=a_{$area}.tenant_id AND ma.company_id=a_{$area}.company_id
AND ma.action='module.record.associated' AND ma.details::jsonb->>'area'=? AND ma.details::jsonb->>'record_id'=a_{$area}.id::text
AND NOT EXISTS (SELECT 1 FROM identity_audit mr WHERE mr.tenant_id=ma.tenant_id AND mr.company_id=ma.company_id AND mr.id>ma.id
AND mr.action='module.record.disassociated' AND mr.details::jsonb->>'area'=ma.details::jsonb->>'area'
AND mr.details::jsonb->>'record_id'=ma.details::jsonb->>'record_id' AND mr.details::jsonb->>'module_id'=ma.details::jsonb->>'module_id'))";
$ever = "EXISTS (SELECT 1 FROM identity_audit me WHERE me.tenant_id=a_{$area}.tenant_id AND me.company_id=a_{$area}.company_id
AND me.action IN ('module.record.associated','module.record.disassociated') AND me.details::jsonb->>'area'=? AND me.details::jsonb->>'record_id'=a_{$area}.id::text)";
match ($d['state'] ?? 'all') {
'associated' => $query->whereRaw($active, [$area]),
'pending' => $query->whereRaw("NOT $ever", [$area]),
'removed' => $query->whereRaw("NOT $active AND $ever", [$area, $area]),
default => null,
};
if (($d['search'] ?? '') !== '') $query->where('name', 'ilike', '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $d['search']).'%');
$page = $query->orderBy('id')->paginate($d['per_page'] ?? 25, ['id', 'name', 'version'], 'page', $d['page'] ?? 1);
$rows = [];
foreach ($page->items() as $row) {
$links = $this->links($area, $row->id);
$deps = []; $restricted = false;
try {
$graph = []; $this->graph($area, $row->id, $graph);
unset($graph["$area:$row->id"]);
$deps = array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $graph));
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) { $restricted = true;
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { if ($e->getStatusCode() !== 403) throw $e; $restricted = true; }
$rows[] = [...(array) $row, ...$links, 'dependencies' => $deps, 'dependencies_restricted' => $restricted];
}
return ['records' => [$area => $rows], 'modules' => app(ModuleNavigation::class)->available($this->context),
'pagination' => ['page' => $page->currentPage(), 'per_page' => $page->perPage(), 'total' => $page->total(), 'last_page' => $page->lastPage()],
'notice' => 'Cada decisão exige conferência individual. Dependências não são associadas automaticamente.'];
}
public function preview(Request $r): array
{
$d = $this->input($r);
return DB::transaction(function () use ($d, $r) {
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
$row = $this->row($d['area'], $d['id'], true);
abort_unless((int) $row->version === (int) $d['version'], 409, 'O registo mudou. Faça nova conferência.');
$snapshot = $this->snapshot($d['area'], $d['id']);
abort_unless(in_array($d['module_id'], array_column($snapshot['modules'], 'id'), true), 403);
$before = $snapshot['graph'][$d['area'].':'.$d['id']]['modules'];
$after = $this->after($before, $d);
$dependencies = $snapshot['graph'];
unset($dependencies[$d['area'].':'.$d['id']]);
$missing = array_values(array_filter($dependencies, fn ($g) => !in_array($d['module_id'], $g['modules'], true)));
$payload = ['decision_id' => (string) Str::uuid(), 'actor' => $this->context->userId(), 'scope' => $this->context->identifiers(),
'unit' => $this->context->unitId(), 'context' => hash('sha256', $r->header('X-SGF-Context')), 'expires' => time() + 900,
'input' => $d, 'review_hash' => $this->digest($snapshot)];
return ['review_token' => Crypt::encryptString(json_encode($payload, JSON_THROW_ON_ERROR)), 'record' => ['area' => $d['area'], 'id' => $row->id, 'name' => $row->name, 'version' => $row->version],
'before' => $before, 'after' => $after, 'dependencies' => array_values(array_map(fn ($g) => array_diff_key($g, ['checksum' => true]), $snapshot['graph'])),
'reason' => $d['reason'], 'operation' => $d['operation'], 'module_id' => $d['module_id'],
'warnings' => [$d['operation'] === 'associate'
? count($missing).' dependências sem associação ao destino escolhido. Continuam pendentes; esta decisão não as disponibiliza nesse módulo.'
: 'A retirada pode impedir a consulta deste registo e a sua selecção como origem nesse módulo. Registos que o referenciam e documentos anteriores permanecem preservados.'],
'history' => $this->events($d['area'], $d['id'])->orderByDesc('id')->limit(20)->get(['id', 'action', 'actor_id', 'created_at', 'details']),
'impact' => 'Altera apenas a associação deste registo. Não altera direitos, dependências, origens ou responsabilidade financeira. A visibilidade continua sujeita aos direitos originais.'];
});
}
private function input(Request $r): array
{
$d = $r->validate(['area' => 'required|in:'.implode(',', ModuleRecords::AREAS), 'id' => 'required|integer|min:1', 'version' => 'required|integer|min:1',
'module_id' => 'required|string|max:50', 'reason' => 'required|string|min:3|max:500', 'operation' => 'required|in:associate,remove']);
abort_if(array_diff(array_keys($r->all()), array_keys($d)) !== [], 422);
abort_if(mb_strlen(trim($d['reason'])) < 3, 422);
return $d;
}
private function after(array $before, array $d): array
{
$after = $d['operation'] === 'remove' ? array_values(array_diff($before, [$d['module_id']])) : array_values(array_unique([...$before, $d['module_id']]));
sort($after); return $after;
}
public function confirm(Request $r): array
{
$d = $r->validate(['review_token' => 'required|string|max:30000']);
abort_if(array_keys($r->all()) !== ['review_token'], 422);
try { $p = json_decode(Crypt::decryptString($d['review_token']), true, 512, JSON_THROW_ON_ERROR); }
catch (\Throwable) { abort(422, 'Conferência inválida.'); }
abort_unless(is_array($p) && isset($p['actor'], $p['scope'], $p['context'], $p['expires'], $p['input'], $p['review_hash'], $p['decision_id'])
&& array_key_exists('unit', $p), 422, 'Conferência inválida.');
abort_unless($p['actor'] === $this->context->userId() && $p['scope'] === $this->context->identifiers() && $p['unit'] === $this->context->unitId()
&& hash_equals($p['context'], hash('sha256', $r->header('X-SGF-Context'))), 409, 'O contexto mudou. Faça nova conferência.');
abort_if($p['expires'] < time(), 409, 'Conferência expirada.');
return DB::transaction(function () use ($p) {
$d = $p['input'];
$s = $this->context->identifiers();
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($this->context->userId(), $s['tenant_id'], $s['company_id'], $this->context->unitId());
// Existing identity administration also serializes through this company.
$this->row($d['area'], $d['id'], true);
$initial = $this->snapshot($d['area'], $d['id']);
foreach ($initial['graph'] as $g) {
DB::table('a_'.$g['area'])->where($this->scope($g['area']))->where('id', $g['id'])->lockForUpdate()->firstOrFail();
}
$current = $this->snapshot($d['area'], $d['id']);
abort_unless($this->digest($initial) === $this->digest($current), 409, 'As dependências mudaram. Faça nova conferência.');
abort_unless(in_array($d['module_id'], array_column($current['modules'], 'id'), true), 403);
$receipt = $this->events($d['area'], $d['id'])->whereRaw("details::jsonb->>'decision_id' = ?", [$p['decision_id']])->first();
$key = $d['area'].':'.$d['id'];
if ($receipt) {
$details = json_decode($receipt->details, true);
abort_unless($current['graph'][$key]['revision'] === (int) $receipt->id && $current['graph'][$key]['modules'] === $details['after_modules'], 409, 'A associação mudou. Faça nova conferência.');
$current['graph'][$key]['revision'] = $details['before_revision'];
$current['graph'][$key]['modules'] = $details['before_modules'];
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo ou dependências mudaram. Faça nova conferência.');
return ['ok' => true, 'replayed' => true, 'event_id' => $receipt->id];
}
abort_unless(hash_equals($p['review_hash'], $this->digest($current)), 409, 'O registo, permissões ou dependências mudaram. Faça nova conferência.');
$before = $current['graph'][$key]['modules']; $after = $this->after($before, $d);
$action = $before === $after ? 'module.record.reviewed' : ($d['operation'] === 'remove' ? 'module.record.disassociated' : 'module.record.associated');
Audit::record($action, $this->context->userId(), $p['scope']['tenant_id'], $p['scope']['company_id'], str_ends_with($d['area'], '_drafts') ? $p['unit'] : null,
details: ['area' => $d['area'], 'record_id' => $d['id'], 'version' => $d['version'], 'module_id' => $d['module_id'], 'reason' => $d['reason'],
'decision_id' => $p['decision_id'], 'review_hash' => $p['review_hash'], 'before_modules' => $before, 'after_modules' => $after, 'before_revision' => $current['graph'][$key]['revision']]);
return ['ok' => true, 'replayed' => false, 'event_id' => $this->events($d['area'], $d['id'])->max('id')];
}, 3);
}
}