124 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|