Files
compras/scripts/prepare-audit-access-pilot.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

131 lines
8.1 KiB
PHP

<?php
use App\Models\User;
use App\Services\OrganizationAccess;
use App\Services\OrganizationConfiguration;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require dirname(__DIR__).'/vendor/autoload.php';
$root = dirname(__DIR__);
if (! in_array('--prepare-audit-access', $argv, true)) {
throw new LogicException('Explicit preparation flag required.');
}
$s = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-compras.json'), true, 32, JSON_THROW_ON_ERROR);
if (($s['APP_ENV'] ?? '') !== 'payable-testing' || ($s['DB_HOST'] ?? '') !== '127.0.0.1' || realpath($s['HOST_ROOT'] ?? '') !== realpath($root) || ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $s['DB_DATABASE'] ?? '', $m) || $s['DB_USERNAME'] !== 'payable_qa_'.$m[1]) {
throw new LogicException('Wrong pilot destination.');
}
$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('Local server required.');
}
$qa = [...$owner, 'url' => null, 'database' => $s['DB_DATABASE'], 'username' => $s['DB_USERNAME'], 'password' => $s['DB_PASSWORD']];
config(['database.connections.receipt_install' => $qa, 'database.connections.receipt_owner' => [...$owner, 'url' => null, 'database' => 'postgres']]);
DB::setDefaultConnection('receipt_install');
$db = DB::connection();
$admin = DB::connection('receipt_owner');
$identity = $db->selectOne('select current_database() as db, current_user as role');
if ($identity->db !== $s['DB_DATABASE'] || $identity->role !== $s['DB_USERNAME']) {
throw new LogicException('Identity mismatch.');
}
$fingerprint = function ($connection, $tables) {
$out = [];
foreach ($tables as $table) {
$rows = $connection->select('select row_to_json(t)::text as row from "'.str_replace('"', '""', $table).'" t order by row_to_json(t)::text');
$out[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR));
}
return $out;
};
$pg = function ($binary, $args, $connection) {
$p = new Process(['C:/Program Files/PostgreSQL/17/bin/'.$binary.'.exe', ...$args], null, ['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], timeout: 120);
$p->run();
if (! $p->isSuccessful()) {
throw new RuntimeException('Backup/restore failed.');
}
};
$tables = array_column($db->select("select tablename from pg_tables where schemaname='public' order by tablename"), 'tablename');
$restore = 'compras_pilot_restore_'.bin2hex(random_bytes(6));
$created = false;
try {
$db->beginTransaction();
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$baseline = $fingerprint($db, $tables);
$directory = 'C:/laragon/data/sgf-tempo/backups/compras-audit-access-preparation-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Backup unavailable.');
}
$dump = $directory.'/before.dump';
$snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot;
$pg('pg_dump', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa);
$db->commit();
$admin->statement('CREATE DATABASE "'.$restore.'"');
$created = true;
$pg('pg_restore', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner);
config(['database.connections.receipt_restore' => [...$owner, 'url' => null, 'database' => $restore], 'work_context.composition' => 'sgc']);
$restored = DB::connection('receipt_restore');
if ($fingerprint($restored, $tables) !== $baseline) {
throw new LogicException('Restored backup differs.');
}
DB::setDefaultConnection('receipt_restore');
$app->detectEnvironment(fn () => 'payable-testing');
$request = Request::create('http://127.0.0.1:8005/administracao/parametrizacao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']);
$request->setLaravelSession($app['session']->driver('array'));
$app->instance('request', $request);
$actor = User::query()->findOrFail(6);
auth()->guard('web')->setUser($actor);
$request->setUserResolver(fn () => $actor);
$request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $actor->id, 'tenant_id' => $actor->tenant_id]);
$tenant = $actor->tenant_id;
$version = (int) $restored->table('organization_access')->where('tenant_id', $tenant)->value('version');
$beforePermissions = [];
$profiles = [];
foreach ([6, 8] as $id) {
$user = User::query()->where('tenant_id', $tenant)->findOrFail($id);
$beforePermissions[$id] = app(OrganizationAccess::class)->permissions($user, 'procurement.internal');
if (! in_array('admin', $beforePermissions[$id], true) || in_array('audit_metadata_read', $beforePermissions[$id], true)) {
throw new LogicException('Proposed administrators changed.');
}
$members = $restored->table('organization_memberships')->where('tenant_id', $tenant)->where('user_id', $id)->get();
foreach ($members as $m) {
if ($m->maintenance || $m->expires_at !== null) {
throw new LogicException('Membership conditions require a separate proposal.');
}
}$profiles[$id] = $members->pluck('profile_id')->all();
}
$flow = app(OrganizationConfiguration::class);
$flow->execute($actor, 'profile', ['version' => $version, 'name' => 'Auditoria de metadados — piloto sintético', 'permissions' => ['audit_metadata_read'], 'work_contexts' => ['procurement.internal'], 'reason' => 'Ensaio descartável da proposta de consulta de metadados.']);
$profile = (int) $restored->table('organization_profiles')->where('tenant_id', $tenant)->max('id');
foreach ([6, 8] as $index => $id) {
$flow->execute($actor, 'assign', ['version' => $version + 1 + $index, 'user_id' => $id, 'profile_ids' => [...$profiles[$id], $profile], 'maintenance' => false, 'expires_at' => null, 'reason' => 'Ensaio descartável: acrescentar só consulta de metadados, preservando competências.']);
$user = User::query()->findOrFail($id);
$after = app(OrganizationAccess::class)->permissions($user, 'procurement.internal');
$expected = [...$beforePermissions[$id], 'audit_metadata_read'];
sort($after);
sort($expected);
if ($after !== $expected) {
throw new LogicException('Unexpected permission change.');
}
}
$protected = array_values(array_diff($tables, ['organization_profiles', 'organization_memberships', 'organization_access', 'audit_logs']));
if ($fingerprint($restored, $protected) !== array_intersect_key($baseline, array_flip($protected))) {
throw new LogicException('Unexpected business changes.');
}
$result = ['checked_at' => gmdate('c'), 'result' => 'passed', 'environment' => 'http://127.0.0.1:8005', 'tenant_id' => $tenant, 'expected_access_version' => $version, 'proposed_access_version' => $version + 3, 'actors' => [6, 8], 'new_profile' => ['name' => 'Auditoria de metadados — piloto sintético', 'permissions' => ['audit_metadata_read'], 'work_contexts' => ['procurement.internal']], 'existing_profile_ids' => $profiles, 'existing_permissions_preserved' => true, 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), 'pilot_grants_changed' => false, 'pilot_mfa_enabled' => false, 'restore_database_removed' => false];
} finally {
if ($db->transactionLevel() > 0) {
$db->rollBack();
}DB::purge('receipt_restore');
if ($created) {
$admin->statement('DROP DATABASE "'.$restore.'" WITH (FORCE)');
}
}
$result['restore_database_removed'] = true;
file_put_contents($root.'/docs/proofs/audit-access-preparation.json',json_encode($result,JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
echo 'Audit access proposal rehearsed only in restored disposable backup; pilot grants unchanged.'.PHP_EOL;