This commit is contained in:
2026-11-02 08:08:05 +02:00
parent 13a6cb9922
commit 504b01b244
246 changed files with 24499 additions and 185 deletions
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Tempo\Purchasing\Delivery;
/** Builds a review overlay; never writes to the source host. */
final class AccessOverlay
{
public const LABELS = [
'purchasing_requisitions.view' => 'Consultar requisições', 'purchasing_requisitions.prepare' => 'Preparar e submeter requisições',
'purchasing_requisitions.configure' => 'Configurar Compras internas', 'purchasing_requisitions.approve.department_head' => 'Aprovar como responsável de departamento',
'purchasing_requisitions.approve.procurement' => 'Aprovar como responsável de Compras', 'purchasing_requisitions.approve.finance' => 'Aprovar como responsável financeiro',
'purchasing_budget.create' => 'Preparar dotações', 'purchasing_budget.approve' => 'Aprovar dotações', 'purchasing_budget.record' => 'Registar e libertar reservas orçamentais',
'purchasing_budget.consume' => 'Registar consumo orçamental', 'purchasing_budget.view' => 'Consultar orçamento',
'purchasing_budget.reinforcement.propose' => 'Propor reforços orçamentais', 'purchasing_budget.reinforcement.review' => 'Rever reforços orçamentais',
];
public const RIGHTS = [
'purchasing_requisitions.view', 'purchasing_requisitions.prepare', 'purchasing_requisitions.configure',
'purchasing_requisitions.approve.department_head', 'purchasing_requisitions.approve.procurement', 'purchasing_requisitions.approve.finance',
'purchasing_budget.create', 'purchasing_budget.approve', 'purchasing_budget.record', 'purchasing_budget.consume', 'purchasing_budget.view',
'purchasing_budget.reinforcement.propose', 'purchasing_budget.reinforcement.review',
];
public static function prepare(string $host): array
{
$paths = ['app/Modules/Identity/Services/Permissions.php', 'app/Modules/Identity/Services/IdentityWorkflow.php'];
$result = [];
foreach ($paths as $path) {
$source = file_get_contents($host.'/'.$path);
$before = hash('sha256', $source);
if (str_ends_with($path, '/Permissions.php')) {
$source = self::replace($source, "public const UNIT = ['unit.view', 'unit.edit'];", 'public const UNIT = '.var_export(['unit.view', 'unit.edit', ...self::RIGHTS], true).';');
} else {
// Expanding a catalogue must never expand creation-time grants.
$source = self::replace($source, 'foreach (Permissions::UNIT as $permission)', "foreach (['unit.view', 'unit.edit'] as \$permission)");
$source = self::replace($source,
"if (\$unit && \$data['permission'] === 'unit.edit')",
"if (\$unit && \$data['permission'] !== 'unit.view')");
$source = self::replace($source,
"if (\$this->permissions->has(\$target->id, \$tenant, \$company, 'unit.edit', \$unit)) { \$this->permissions->require(\$actor, \$tenant, \$company, 'unit.edit', \$unit); }",
"foreach ((clone \$query)->where('unit_id', \$unit)->whereIn('permission', Permissions::UNIT)->get() as \$existing) { \$this->permissions->require(\$actor, \$tenant, \$company, \$existing->permission, \$unit); }");
}
$result[$path] = ['before_sha256' => $before, 'after_sha256' => hash('sha256', $source), 'contents' => $source];
}
return $result;
}
private static function replace(string $source, string $old, string $new): string
{
if (substr_count($source, $old) !== 1) {
throw new \RuntimeException('Host divergiu: reconciliar a alteração de identidade antes de preparar a entrega.');
}
return str_replace($old, $new, $source);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Tempo\Purchasing\Delivery;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/** Explicit initial delivery to nominated administrators, never automatic profile inheritance. */
final class InitialAuthority
{
public function options(Request $request): array
{
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
$actor = $request->user()->id;
abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403);
abort_unless(in_array(implode(':', $scope), config('purchasing-access.initialisation_scopes', []), true), 403);
$permissions = app(Permissions::class);
$context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage');
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
->where('m.tenant_id', $scope['tenant_id'])->where('m.company_id', $scope['company_id'])->get(['u.id', 'u.name'])->filter(function ($member) use ($permissions, $scope) {
return $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'company.view')
&& $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'access.manage')
&& $permissions->has($member->id, $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']);
})->values()->all();
return ['scope' => $scope, 'administrators' => $members, 'rights' => AccessOverlay::RIGHTS, 'labels' => AccessOverlay::LABELS,
'initialised' => DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->exists()];
}
public function initialise(Request $request): array
{
$data = $request->validate([
'operation_key' => 'required|uuid', 'reason' => 'required|string|min:10|max:500',
'administrators' => 'required|array|min:1|max:20', 'administrators.*' => 'array:user_id,rights',
'administrators.*.user_id' => 'required|integer|distinct|min:1',
'administrators.*.rights' => 'required|array|min:1', 'administrators.*.rights.*' => 'required|string|distinct',
]);
abort_unless(array_diff(array_keys($request->all()), array_keys($data)) === [], 422);
$context = app(CompanyContext::class);
$scope = $context->identifiers() + ['unit_id' => $context->unitId()];
$actor = $request->user()->id;
abort_unless($scope['unit_id'] !== null && $context->userId() === $actor, 403);
// Installed configuration explicitly permits initialisation for this exact scope.
$scopeKey = implode(':', $scope);
abort_unless(in_array($scopeKey, config('purchasing-access.initialisation_scopes', []), true), 403);
foreach ($data['administrators'] as &$admin) {
abort_unless(array_diff($admin['rights'], AccessOverlay::RIGHTS) === [], 422);
sort($admin['rights']);
}
unset($admin);
usort($data['administrators'], fn ($a, $b) => $a['user_id'] <=> $b['user_id']);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($request, $scope, $actor, $context, $data, $hash) {
DB::table('companies')->where('id', $scope['company_id'])->where('tenant_id', $scope['tenant_id'])->lockForUpdate()->firstOrFail();
$context->selectFor($actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$permissions = app(Permissions::class);
$permissions->require($actor, $scope['tenant_id'], $scope['company_id'], 'access.manage');
if ($request->hasSession()) {
$token = (string) $request->session()->get('company_context.token', '');
abort_unless($token !== '' && hash_equals($token, (string) $request->header('X-SGF-Context')), 409);
}
$old = DB::table('identity_audit')->where($scope)->where('action', 'purchasing.access_initialised')->first();
if ($old) {
$details = json_decode($old->details, true, 32, JSON_THROW_ON_ERROR);
abort_unless($old->actor_id === $actor && ($details['event_id'] ?? null) === $hash, 409, 'Autoridade inicial já entregue; use a gestão normal de acessos.');
return ['initialised' => true, 'replayed' => true];
}
$grants = [];
foreach ($data['administrators'] as $admin) {
foreach (['company.view', 'access.manage'] as $right) {
$permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], $right);
}
$permissions->require($admin['user_id'], $scope['tenant_id'], $scope['company_id'], 'unit.view', $scope['unit_id']);
abort_unless(DB::table('company_memberships')->where(['user_id' => $admin['user_id'], 'tenant_id' => $scope['tenant_id'], 'company_id' => $scope['company_id']])->exists(), 403);
foreach ($admin['rights'] as $right) {
abort_unless(in_array($right, Permissions::UNIT, true), 503, 'Catálogo operacional não preparado.');
DB::table('permission_grants')->insertOrIgnore($scope + ['user_id' => $admin['user_id'], 'permission' => $right, 'created_at' => now(), 'updated_at' => now()]);
$grants[] = ['user_id' => $admin['user_id'], 'permission' => $right];
}
}
Audit::record('purchasing.access_initialised', $actor, $scope['tenant_id'], $scope['company_id'], $scope['unit_id'], details: ['reason' => $data['reason'], 'event_id' => $hash, 'grants' => $grants]);
return ['initialised' => true, 'replayed' => false];
});
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
use App\Modules\Identity\Http\RequireCompanyContext;
use App\Modules\Identity\Http\RequireValidSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tempo\Purchasing\Delivery\InitialAuthority;
// Deployment composition must explicitly register this file. Never loaded by the installed host.
require_once __DIR__.'/AccessOverlay.php';
require_once __DIR__.'/InitialAuthority.php';
Route::middleware(['web', 'auth', RequireValidSession::class, RequireCompanyContext::class])->group(function () {
Route::get('/purchasing/internal-requisitions/access-initialisation', function (Request $request) {
$options = (new InitialAuthority)->options($request);
ob_start();
require __DIR__.'/screen.php';
return response(ob_get_clean())->header('Cache-Control', 'private, no-store');
});
Route::post('/api/internal-requisitions/access-initialisation', function (Request $request) {
return response()->json((new InitialAuthority)->initialise($request))->header('Cache-Control', 'private, no-store');
});
});
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Configuração inicial de acessos a Compras internas</title></head>
<body>
<main><h1>Configuração inicial de acessos a Compras internas</h1>
<p>Empresa <?= (int) $options['scope']['company_id'] ?> · Unidade <?= (int) $options['scope']['unit_id'] ?></p>
<p>Escolha explicitamente o administrador e os direitos iniciais. As restantes atribuições e revogações usam a administração de acessos existente.</p>
<?php if ($options['initialised']) { ?>
<p>A autoridade inicial já foi entregue. Utilize a administração de acessos para alterações.</p>
<?php } else { ?>
<form id="initial-access" method="post" action="/api/internal-requisitions/access-initialisation">
<input type="hidden" name="_token" value="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
<label>Administrador nominativo <select name="administrator" required>
<option value="">Seleccionar</option>
<?php foreach ($options['administrators'] as $member) { ?>
<option value="<?= (int) $member->id ?>"><?= htmlspecialchars($member->name, ENT_QUOTES, 'UTF-8') ?> · <?= (int) $member->id ?></option>
<?php } ?>
</select></label>
<fieldset><legend>Direitos iniciais escolhidos para esta unidade</legend>
<?php foreach ($options['rights'] as $right) { ?>
<label><input type="checkbox" name="rights" value="<?= htmlspecialchars($right, ENT_QUOTES, 'UTF-8') ?>"> <?= htmlspecialchars($options['labels'][$right], ENT_QUOTES, 'UTF-8') ?></label><br>
<?php } ?>
</fieldset>
<label>Fundamento <textarea name="reason" minlength="10" maxlength="500" required></textarea></label>
<button type="submit">Registar atribuição inicial</button>
</form>
<p id="result" role="status" aria-live="polite"></p>
<script>
const form = document.getElementById('initial-access');
const contextToken = <?= json_encode($request->session()->get('company_context.token'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
let pending = null;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const fields = new FormData(form);
const payload = {reason: fields.get('reason'), administrators: [{user_id: Number(fields.get('administrator')), rights: fields.getAll('rights')}]};
const content = JSON.stringify(payload);
if (pending?.content !== content) pending = {content, operation_key: crypto.randomUUID()};
const button = form.querySelector('button');
button.disabled = true;
try {
const response = await fetch(form.action, {method: 'POST', headers: {'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': fields.get('_token'), 'X-SGF-Context': contextToken}, body: JSON.stringify({...payload, operation_key: pending.operation_key})});
const result = await response.json();
if (!response.ok) throw new Error(result.message || 'Não foi possível registar.');
form.hidden = true;
document.getElementById('result').textContent = 'Atribuição inicial registada. Utilize a administração de acessos para alterações.';
} catch (error) {
document.getElementById('result').textContent = error.message;
} finally { button.disabled = false; }
});
</script>
<?php } ?>
</main></body></html>