201 lines
14 KiB
PHP
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;
|