Aceita fila administrativa de associacoes com checkpoint local
This commit is contained in:
@@ -1,55 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use App\Shared\Context\{CompanyContext,ModuleNavigation,ModuleRecords};
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
use App\Modules\Identity\Services\ModuleAssociationReview;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ModuleRecordsController
|
||||
{
|
||||
public function index(Request $r,CompanyContext $context,Permissions $permissions)
|
||||
{
|
||||
$s=$context->identifiers();$out=[];
|
||||
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
|
||||
foreach(ModuleRecords::AREAS as $area){
|
||||
$unit=str_ends_with($area,'_drafts')?$context->unitId():null;
|
||||
if(str_ends_with($area,'_drafts')&&!$unit)continue;
|
||||
if(!$permissions->has($context->userId(),$s['tenant_id'],$s['company_id'],"$area.view",$unit))continue;
|
||||
$rows=DB::table('a_'.$area)->where($s)->where('unit_id',$unit)->orderBy('id')->get(['id','name','version']);
|
||||
foreach($rows as $row){
|
||||
$row->modules=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
|
||||
->whereRaw("details::jsonb->>'area' = ?",[$area])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$row->id])
|
||||
->orderByDesc('id')->get(['details','action'])->unique(fn($a)=>json_decode($a->details,true)['module_id'])
|
||||
->filter(fn($a)=>$a->action==='module.record.associated')->map(fn($a)=>json_decode($a->details,true)['module_id'])->values()->all();
|
||||
}
|
||||
$out[$area]=$rows;
|
||||
}
|
||||
return response()->json(['records'=>$out,'modules'=>app(ModuleNavigation::class)->available($context),
|
||||
'notice'=>'Registos sem associação explícita permanecem aqui visíveis pelos direitos originais. Associar não altera o registo, a origem ou as permissões.'])->header('Cache-Control','no-store');
|
||||
}
|
||||
public function associate(Request $r,CompanyContext $context,Permissions $permissions)
|
||||
{
|
||||
$d=$r->validate(['area'=>'required|in:'.implode(',',ModuleRecords::AREAS),'id'=>'required|integer|min:1','version'=>'required|integer|min:1','module_id'=>'required|string','reason'=>'required|string|min:3|max:500','operation'=>'sometimes|in:associate,remove']);
|
||||
abort_if(array_diff(array_keys($r->all()),array_keys($d))!==[],422);
|
||||
$s=$context->identifiers();$unit=str_ends_with($d['area'],'_drafts')?$context->unitId():null;
|
||||
abort_if(str_ends_with($d['area'],'_drafts')&&!$unit,403);
|
||||
DB::transaction(function()use($d,$s,$unit,$context,$permissions){
|
||||
DB::table('companies')->where('tenant_id',$s['tenant_id'])->where('id',$s['company_id'])->lockForUpdate()->firstOrFail();
|
||||
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],'access.manage');
|
||||
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.view',$unit);
|
||||
$permissions->require($context->userId(),$s['tenant_id'],$s['company_id'],$d['area'].'.edit',$unit);
|
||||
abort_unless(in_array($d['module_id'],array_column(app(ModuleNavigation::class)->available($context),'id'),true),403);
|
||||
$row=DB::table('a_'.$d['area'])->where($s)->where('unit_id',$unit)->where('id',$d['id'])->first(['id','version']);
|
||||
abort_unless($row,403);
|
||||
abort_unless((int)$row->version===(int)$d['version'],409,'O registo mudou. Reabra a consulta antes de decidir a associação.');
|
||||
$previous=DB::table('identity_audit')->where($s)->whereIn('action',['module.record.associated','module.record.disassociated'])
|
||||
->whereRaw("details::jsonb->>'area' = ?",[$d['area']])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$d['id']])->whereRaw("details::jsonb->>'module_id' = ?",[$d['module_id']])->orderByDesc('id')->value('action');
|
||||
$remove=($d['operation']??'associate')==='remove';
|
||||
if(($remove&&$previous==='module.record.associated')||(!$remove&&$previous!=='module.record.associated'))
|
||||
ModuleRecords::associate($d['area'],$d['id'],$context->userId(),$s+['unit_id'=>$unit],$d['module_id'],$d['reason'],$remove,(int)$row->version);
|
||||
});
|
||||
return response()->json(['ok'=>true]);
|
||||
}
|
||||
public function index(Request $request, ModuleAssociationReview $review) { return response()->json($review->listing($request))->header('Cache-Control', 'no-store'); }
|
||||
public function preview(Request $request, ModuleAssociationReview $review) { return response()->json($review->preview($request))->header('Cache-Control', 'no-store'); }
|
||||
public function associate(Request $request, ModuleAssociationReview $review) { return response()->json($review->confirm($request))->header('Cache-Control', 'no-store'); }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ final class Audit
|
||||
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
|
||||
{
|
||||
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
|
||||
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id']));
|
||||
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants', 'area', 'record_id', 'version', 'event_id', 'module_id', 'previous_module_id', 'decision_id', 'review_hash', 'before_modules', 'after_modules', 'before_revision']));
|
||||
DB::table('identity_audit')->insert([
|
||||
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
|
||||
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user