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; } }