Files
SGF/app/Console/Commands/ProvisionIdentityUser.php
T

124 lines
10 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class ProvisionIdentityUser extends Command
{
protected $signature = 'identity:provision {--email=} {--name=} {--company=} {--tenant-name=} {--company-name=} {--company-permission=*} {--unit-permission=*} {--operator=} {--reason=} {--existing-user} {--protected-stdin} {--apply}';
protected $description = 'Pré-visualizar e, com confirmação local, criar utilizador e primeiros acessos delimitados.';
public function handle(): int
{
if (! $this->safeLocalDatabase()) { return self::FAILURE; }
$email = mb_strtolower(trim((string) $this->option('email')));
$name = trim((string) $this->option('name'));
if (! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '' || mb_strlen($name) > 255 || mb_strlen($email) > 255 || ! $this->option('operator') || ! $this->option('reason')) {
$this->error('Indique email, nome, operador local e motivo. Não forneça palavras-passe por argumentos.'); return self::FAILURE;
}
$existing = User::where('email', $email)->first();
if ($existing && ! $this->option('existing-user')) { $this->error('O utilizador já existe. Use recuperação local ou gestão de acessos.'); return self::FAILURE; }
if ($this->option('existing-user') && (! $existing || $existing->name !== $name || ! $this->option('company'))) {
$this->error('A recuperação de concessões exige utilizador/nome existentes e --company explícito.'); return self::FAILURE;
}
$company = $this->option('company') ? DB::table('companies')->where('id', $this->option('company'))->first() : null;
if ($this->option('company') && ! $company) { $this->error('Empresa inexistente; não será criada automaticamente.'); return self::FAILURE; }
$newScope = $this->option('tenant-name') || $this->option('company-name');
if ($newScope) {
if ($existing) { $this->error('A recuperação não cria um novo âmbito.'); return self::FAILURE; }
$tenantName = trim((string) $this->option('tenant-name'));
$companyName = trim((string) $this->option('company-name'));
if ($company || $tenantName === '' || $companyName === '' || mb_strlen($tenantName) > 255 || mb_strlen($companyName) > 255
|| DB::table('tenants')->where('name', $tenantName)->exists()) {
$this->error('Para novo âmbito, indique nomes explícitos, distintos de um tenant existente, sem --company. Nada será reutilizado por inferência.'); return self::FAILURE;
}
$company = (object) ['id' => 0, 'tenant_id' => 0, 'name' => $companyName];
}
$grants = [];
foreach ($this->option('company-permission') as $permission) {
if (! $company || ! in_array($permission, Permissions::COMPANY, true)) { $this->error('Concessão de empresa inválida.'); return self::FAILURE; }
$grants[] = ['permission' => $permission, 'unit_id' => null];
}
foreach ($this->option('unit-permission') as $raw) {
[$unit, $permission] = array_pad(explode(':', $raw, 2), 2, null);
if (! $company || ! ctype_digit((string) $unit) || ! in_array($permission, Permissions::UNIT, true)
|| ! DB::table('units')->where('id', $unit)->where('company_id', $company->id)->where('tenant_id', $company->tenant_id)->exists()) {
$this->error('Concessão de unidade inválida.'); return self::FAILURE;
}
$grants[] = ['permission' => $permission, 'unit_id' => (int) $unit];
}
if ($grants && ! in_array('company.view', array_column($grants, 'permission'), true)) { $this->error('Os primeiros acessos exigem company.view explícito.'); return self::FAILURE; }
if ($newScope && ! $grants) { $this->error('O novo âmbito exige concessões iniciais explícitas.'); return self::FAILURE; }
foreach ($grants as $grant) {
if ($grant['permission'] === 'unit.edit' && ! in_array(['permission' => 'unit.view', 'unit_id' => $grant['unit_id']], $grants)) {
$this->error('unit.edit exige unit.view explícito para a mesma unidade.'); return self::FAILURE;
}
}
if ($grants && DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) {
$this->error('Esta empresa já tem gestor. Crie apenas o utilizador sem concessões e use a gestão de acessos.'); return self::FAILURE;
}
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$name <$email>"],
['Tenant', $newScope ? 'CRIAR: '.$tenantName : ($company?->tenant_id ?? 'sem acesso')], ['Empresa', $newScope ? 'CRIAR: '.$companyName : ($company ? "$company->id — $company->name" : 'sem acesso')],
['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
$this->table(['Permissão', 'Unidade'], array_map(fn ($g) => [$g['permission'], $g['unit_id'] ?? 'âmbito da empresa'], $grants));
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Obtenha confirmação desta operação antes de repetir com --apply.'); return self::SUCCESS; }
if ($existing && ! $grants) { $this->error('Indique as concessões a recuperar.'); return self::FAILURE; }
$protected = null;
if ($this->option('protected-stdin')) {
if ($existing) { $this->error('Esta entrada protegida destina-se apenas à criação de uma nova conta.'); return self::FAILURE; }
try { $protected = \App\Modules\Identity\Services\ProtectedProvisioningInput::read(STDIN, $email); }
catch (\RuntimeException $exception) { $this->error($exception->getMessage()); return self::FAILURE; }
} elseif (! $this->input->isInteractive() || ! $this->confirm($existing ? 'Confirma recuperar apenas as concessões acima, mantendo a palavra-passe?' : 'Confirma a criação exacta acima, sem outros acessos?', false)) { return self::FAILURE; }
$password = null;
if (! $existing) {
$password = $protected ? $protected[0] : $this->secret('Nova palavra-passe (mínimo 12 caracteres; não será apresentada)', false);
$repeat = $protected ? $protected[1] : $this->secret('Repita a palavra-passe', false);
if (! is_string($password) || $password === '') { $this->error('A entrada oculta não recebeu uma palavra-passe. Nada alterado.'); return self::FAILURE; }
if (mb_strlen($password) < 12) { $this->error('A palavra-passe precisa de pelo menos 12 caracteres. Nada alterado.'); return self::FAILURE; }
if (strlen($password) > 72) { $this->error('A palavra-passe excede o limite de 72 bytes do algoritmo actual. Nada alterado.'); return self::FAILURE; }
if ($password !== $repeat) { $this->error('As duas entradas ocultas não coincidem. Nada alterado.'); return self::FAILURE; }
}
try {
DB::transaction(function () use ($email, $name, $password, $company, $grants, $newScope, $existing): void {
if ($newScope) {
// Serialize local bootstrap commands; never silently reuse or duplicate the named scope.
DB::statement('SELECT pg_advisory_xact_lock(72644101)');
if (DB::table('tenants')->where('name', trim($this->option('tenant-name')))->exists()) { throw new \RuntimeException('Scope changed'); }
$tenantId = DB::table('tenants')->insertGetId(['name' => trim($this->option('tenant-name')), 'created_at' => now(), 'updated_at' => now()]);
$companyId = DB::table('companies')->insertGetId(['tenant_id' => $tenantId, 'name' => trim($this->option('company-name')), 'created_at' => now(), 'updated_at' => now()]);
$company = (object) ['id' => $companyId, 'tenant_id' => $tenantId];
Audit::record('local.scope_created', null, $tenantId, $companyId,
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
}
if ($company && $grants) {
DB::table('companies')->where('id', $company->id)->lockForUpdate()->first();
if (DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) { throw new \RuntimeException('Scope changed'); }
}
$user = $existing ?? User::create(['name' => $name, 'email' => $email, 'password' => $password]);
if ($company && $grants) {
DB::table('company_memberships')->insertOrIgnore(['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id]);
foreach ($grants as $grant) {
DB::table('permission_grants')->insertOrIgnore($grant + ['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id, 'created_at' => now(), 'updated_at' => now()]);
}
}
Audit::record($existing ? 'local.access_restored' : 'local.user_provisioned', null, $company?->tenant_id, $company?->id, subject: $user->id,
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason'), 'grants' => $grants]);
});
} catch (\Throwable) { $this->error('Operação não concluída. Nenhum detalhe sensível foi registado. Verifique o estado e repita a pré-visualização.'); return self::FAILURE; }
$this->info($existing ? 'Concessões recuperadas no âmbito apresentado; credencial preservada.' : 'Utilizador criado com os acessos apresentados. Nenhuma palavra-passe foi registada.'); return self::SUCCESS;
}
private function safeLocalDatabase(): bool
{
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) {
$this->error('Procedimento permitido apenas no desenvolvimento local próprio, com sgf_dev_app.'); return false;
}
return true;
}
}