Files
compras/scripts/apply-pilot-access-proposal.php
T
2026-11-02 20:27:11 +02:00

201 lines
14 KiB
PHP

<?php
use App\Models\Tenant;
use App\Models\User;
use App\Services\ConflictGuard;
use App\Services\OrganizationAccess;
use App\Services\OrganizationActivationPreview;
use App\Services\OrganizationConfiguration;
use App\Services\WorkContext;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
// Explicit, guarded application of the owner's approved local synthetic proposal.
require dirname(__DIR__).'/vendor/autoload.php';
$root = dirname(__DIR__);
$apply = in_array('--apply-authorized', $argv, true);
$proposalFile = $root.'/docs/proofs/pilot-access-proposal.json';
$proposal = json_decode(file_get_contents($proposalFile), true, 64, JSON_THROW_ON_ERROR);
$tested = json_decode(file_get_contents($root.'/docs/proofs/pilot-access-proposal-dry-run.json'), true, 64, JSON_THROW_ON_ERROR);
$proposalHash = hash_file('sha256', $proposalFile);
if (strtolower($tested['proposal_sha256']) !== $proposalHash || $tested['sqlite']['result'] !== 'passed' || $tested['postgres']['result'] !== 'passed'
|| $proposal['environment'] !== 'http://127.0.0.1:8005' || $proposal['scope'] !== 'synthetic_local_pilot_only' || $proposal['status'] !== 'pending_owner_authorization') {
throw new LogicException('A proposta não coincide com a simulação conferida.');
}
$authorization = json_decode(file_get_contents($root.'/docs/proofs/pilot-access-authorization.json'), true, 32, JSON_THROW_ON_ERROR);
if ($authorization['proposal_sha256'] !== $proposalHash || $authorization['owner_reply'] !== 'autorizo' || $authorization['environment'] !== $proposal['environment']) {
throw new LogicException('Autorização específica da proposta obrigatória.');
}
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-compras.json'), true, 32, JSON_THROW_ON_ERROR);
if (($settings['APP_ENV'] ?? '') !== 'payable-testing' || ($settings['DB_HOST'] ?? '') !== '127.0.0.1'
|| ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $settings['DB_DATABASE'] ?? '', $match)
|| ($settings['DB_USERNAME'] ?? '') !== 'payable_qa_'.$match[1] || realpath($settings['HOST_ROOT'] ?? '') !== realpath($root)) {
throw new LogicException('Destino limitado à avaliação local SGC existente.');
}
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$owner = DB::connection('pgsql')->getConfig();
unset($owner['name']);
if ($owner['host'] !== '127.0.0.1') {
throw new LogicException('Controlo de restauro exclusivamente local.');
}
$qa = [...$owner, 'url' => null, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']];
config(['database.connections.pilot_apply' => $qa, 'database.connections.pilot_owner' => [...$owner, 'database' => 'postgres', 'url' => null], 'app.env' => 'payable-testing', 'work_context.composition' => 'sgc']);
$app->detectEnvironment(fn () => 'payable-testing');
DB::setDefaultConnection('pilot_apply');
$db = DB::connection();
$identity = $db->selectOne('select current_database() as db, current_user as role');
if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== $settings['DB_USERNAME']) {
throw new LogicException('Identidade do destino inesperada.');
}
$request = Request::create($proposal['environment'].'/administracao/parametrizacao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']);
$app->instance('request', $request);
$request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user());
function pilotFingerprint($db, array $tables, bool $tenantProjection = false): array
{
$hashes = [];
foreach ($tables as $table) {
$quoted = '"'.str_replace('"', '""', $table).'"';
$projection = $tenantProjection && $table === 'tenants' ? "(to_jsonb(t) - ARRAY['rules_provisional','work_context_policy','updated_at'])::text" : 'row_to_json(t)::text';
$rows = $db->select('select '.$projection.' as row from '.$quoted.' t order by '.$projection);
$hashes[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR));
}
return $hashes;
}
function pilotPg(string $binary, array $arguments, array $connection): void
{
$process = new Process([$binary, ...$arguments], null, ['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], timeout: 120);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException('Backup/restauro de controlo falhou. Nenhuma activação confirmada.');
}
}
$restore = null;
$admin = DB::connection('pilot_owner');
$failure = null;
$result = null;
try {
$result = $db->transaction(function () use ($db, $proposal, $proposalHash, $apply, $qa, $owner, $admin, &$restore) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$actor = User::query()->findOrFail(8);
auth()->guard('web')->setUser($actor);
app(ConflictGuard::class)->lockCompany($actor);
$configuration = app(OrganizationConfiguration::class);
$configuration->administrator($actor);
$access = app(OrganizationAccess::class);
$contexts = app(WorkContext::class);
$state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first();
if (($state->active ?? false) || (int) ($state->version ?? 1) !== $proposal['source_version']
|| DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->exists()
|| DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->exists()) {
throw new LogicException('A origem mudou; não substituir perfis ou atribuições existentes.');
}
$company = Tenant::query()->findOrFail($actor->tenant_id);
$expectedRules = OrganizationConfiguration::rules($proposal['current_rules']);
foreach (['first_limit_minor', 'second_limit_minor', 'approval_includes_tax'] as $field) {
if ($expectedRules[$field] !== $company->$field) {
throw new LogicException('As alçadas mudaram desde a proposta.');
}
}
if ($company->rules_provisional !== $proposal['current_rules']['provisional'] || (string) $company->default_tax_rate !== $proposal['current_rules']['default_tax_rate']
|| $contexts->policy($actor)['enabled'] !== $proposal['enabled_contexts'] || User::query()->where('tenant_id', $actor->tenant_id)->count() !== count($proposal['users'])) {
throw new LogicException('A configuração ou a lista de avaliação mudou.');
}
$before = [];
foreach ($proposal['users'] as $person) {
$user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($person['id']);
if ($user->name !== $person['name'] || $user->department_id !== $person['department_id'] || $access->permissions($user) !== $person['current_permissions']
|| $contexts->available($user) !== $person['current_contexts'] || $access->canRequest($user) !== $person['current_can_request']) {
throw new LogicException('Uma identidade ou competência mudou desde a conferência.');
}
$before[$user->id] = ['permissions' => $access->permissions($user), 'contexts' => $contexts->available($user), 'requester' => $access->canRequest($user), 'department_scoped' => $access->departmentScoped($user)];
}
if (! $apply) {
return ['result' => 'preflight_passed_no_changes', 'users' => count($before), 'source_version' => $proposal['source_version'], 'proposal_sha256' => $proposalHash];
}
$tables = array_map(fn ($row) => $row->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename"));
$baseline = pilotFingerprint($db, $tables);
$protected = array_values(array_diff($tables, ['organization_access', 'organization_profiles', 'organization_memberships', 'audit_logs', 'tenants']));
$tenantBaseline = pilotFingerprint($db, ['tenants'], true);
$directory = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-access-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível guardar a cópia de recuperação.');
}
$dump = $directory.'/before.dump';
$snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot;
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
pilotPg($bin.'pg_dump.exe', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa);
$restoreName = 'compras_pilot_restore_'.bin2hex(random_bytes(6));
$admin->statement('CREATE DATABASE "'.$restoreName.'"');
$restore = $restoreName;
pilotPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner);
config(['database.connections.pilot_restore' => [...$owner, 'database' => $restore, 'url' => null]]);
if (pilotFingerprint(DB::connection('pilot_restore'), $tables) !== $baseline) {
throw new RuntimeException('O restauro não coincide com a origem. Nenhuma atribuição aplicada.');
}
$reason = 'Piloto sintético SGC8005 autorizado pelo proprietário neste chat: resposta «autorizo» à proposta concreta de 02/11/2026.';
$version = $proposal['source_version'];
$profiles = [];
foreach ($proposal['profiles'] as $profile) {
$configuration->execute($actor, 'profile', ['version' => $version++, 'name' => $profile['name'], 'permissions' => $profile['permissions'], 'work_contexts' => $profile['work_contexts']]);
$profiles[$profile['key']] = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('name', $profile['name'])->sole()->id;
}
foreach ($proposal['users'] as $person) {
$configuration->execute($actor, 'assign', ['version' => $version++, 'user_id' => $person['id'], 'profile_ids' => array_map(fn ($key) => $profiles[$key], $person['profile_keys']), 'maintenance' => false, 'expires_at' => null, 'reason' => $reason]);
}
$rules = $proposal['proposed_rules'];
$configuration->execute($actor, 'rules', ['version' => $version++, 'first_limit' => $rules['first_limit'], 'second_limit' => $rules['second_limit'], 'includes_tax' => $rules['includes_tax'], 'reason' => $reason]);
$configuration->execute($actor, 'work_contexts', ['version' => $version++, 'enabled' => $proposal['enabled_contexts'], 'reason' => $reason]);
$preview = app(OrganizationActivationPreview::class)->build($actor);
$administrators = $configuration->permanentAdministrators($actor->tenant_id);
sort($administrators);
if (! $preview['can_activate'] || $preview['losing_access'] !== 0 || $administrators !== $proposal['permanent_administrator_source_ids']) {
throw new RuntimeException('A conferência anterior à activação não coincide com a proposta.');
}
$configuration->execute($actor, 'activate', ['version' => $version, 'reason' => $reason]);
foreach ($proposal['users'] as $person) {
$user = User::query()->findOrFail($person['id']);
$permissions = $access->permissions($user);
$expected = $person['planned_permissions'];
sort($permissions);
sort($expected);
if ($permissions !== $expected || $contexts->available($user) !== $before[$user->id]['contexts']
|| $access->canRequest($user) !== $before[$user->id]['requester'] || $access->departmentScoped($user) !== $before[$user->id]['department_scoped']) {
throw new RuntimeException('O acesso efectivo diverge da distribuição autorizada.');
}
}
if (pilotFingerprint($db, $protected) !== array_intersect_key($baseline, array_flip($protected)) || pilotFingerprint($db, ['tenants'], true) !== $tenantBaseline) {
throw new RuntimeException('Foi detectada alteração fora do âmbito autorizado; transacção cancelada.');
}
return ['result' => 'applied', 'checked_at' => gmdate('c'), 'environment' => $proposal['environment'], 'proposal_sha256' => $proposalHash,
'source_version' => $proposal['source_version'], 'active_version' => $version + 1, 'users_preserved' => count($before), 'profiles_created' => count($profiles),
'memberships_created' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->count(), 'administrators_preserved' => $proposal['permanent_administrator_source_ids'],
'backup_file' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restore_verified_tables' => count($tables), 'protected_tables_preserved' => count($protected),
'tenant_other_fields_preserved' => true, 'business_data_preserved' => true, 'payables_specific_grants_preserved' => true, 'new_accounts' => false,
'new_stock_publications_or_policies' => false, 'business_decisions_executed' => false, 'sgf_installation_changed' => false, 'atomic_transaction' => true];
});
} catch (Throwable $error) {
$failure = $error;
fwrite(STDERR, 'Operação não concluída ('.get_class($error).'). '.$error->getMessage().PHP_EOL);
} finally {
if ($restore !== null && preg_match('/^compras_pilot_restore_[a-f0-9]{12}$/D', $restore) && $restore !== $qa['database']) {
DB::purge('pilot_restore');
$admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$restore]);
$admin->statement('DROP DATABASE "'.$restore.'"');
}
}
if ($failure !== null) {
exit(1);
}
if ($apply) {
file_put_contents($root.'/docs/proofs/pilot-access-activation.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;