Preserva base local aceite de identidade e acessos
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class RecoverIdentityUser extends Command
|
||||
{
|
||||
protected $signature = 'identity:recover {--email=} {--operator=} {--reason=} {--apply}';
|
||||
protected $description = 'Recuperação local assistida, com confirmação e invalidação das sessões existentes.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { $this->error('Destino local inválido.'); return self::FAILURE; }
|
||||
$user = User::where('email', mb_strtolower(trim((string) $this->option('email'))))->first();
|
||||
if (! $user || ! $this->option('operator') || ! $this->option('reason')) { $this->error('Indique utilizador existente, operador e motivo.'); return self::FAILURE; }
|
||||
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$user->id — $user->name <$user->email>"],
|
||||
['Efeito', 'Alterar palavra-passe e invalidar todas as sessões; manter concessões existentes'], ['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
|
||||
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Confirme a identidade e autorização da pessoa antes de --apply.'); return self::SUCCESS; }
|
||||
if (! $this->input->isInteractive() || ! $this->confirm('Confirma a recuperação exacta apresentada?', false)) { return self::FAILURE; }
|
||||
$password = $this->secret('Nova palavra-passe (mínimo 12 caracteres)', false);
|
||||
$repeat = $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 ($user, $password): void {
|
||||
$current = User::whereKey($user->id)->lockForUpdate()->firstOrFail();
|
||||
$current->password = $password;
|
||||
$current->auth_version++;
|
||||
$current->remember_token = bin2hex(random_bytes(30));
|
||||
$current->save();
|
||||
Audit::record('local.password_recovered', null, subject: $current->id,
|
||||
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
|
||||
});
|
||||
} catch (\Throwable) { $this->error('Recuperação não concluída; nenhum detalhe sensível foi apresentado.'); return self::FAILURE; }
|
||||
$this->info('Recuperação concluída. As sessões anteriores serão recusadas no pedido seguinte.'); return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
protected $attributes = ['auth_version' => 1];
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'auth_version' => 'integer',
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Identity\Services\Audit;
|
||||
use App\Modules\Identity\Services\IdentityWorkflow;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class IdentityController extends Controller
|
||||
{
|
||||
public function __construct(private IdentityWorkflow $workflow) {}
|
||||
|
||||
public function session(Request $request)
|
||||
{
|
||||
if ($request->user() && (int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
|
||||
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
|
||||
}
|
||||
return response()->json($this->workflow->snapshot($request))->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email|max:255', 'password' => 'required|string|max:1024']);
|
||||
$email = mb_strtolower(trim($data['email']));
|
||||
if (strlen($data['password']) > 72) { throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']); }
|
||||
$key = 'sgf-login:'.hash('sha256', $email.'|'.$request->ip());
|
||||
$ipKey = 'sgf-login-ip:'.hash('sha256', (string) $request->ip());
|
||||
abort_if(RateLimiter::tooManyAttempts($key, config('identity.login_attempts')) || RateLimiter::tooManyAttempts($ipKey, config('identity.login_ip_attempts')), 429, 'Demasiadas tentativas. Aguarde antes de tentar novamente.');
|
||||
if (! Auth::attempt(['email' => $email, 'password' => $data['password']], false)) {
|
||||
RateLimiter::hit($key, config('identity.login_decay_seconds'));
|
||||
RateLimiter::hit($ipKey, config('identity.login_decay_seconds'));
|
||||
Audit::record('auth.failed', null);
|
||||
throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']);
|
||||
}
|
||||
RateLimiter::clear($key);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('company_context');
|
||||
$request->session()->put('auth_version', (int) $request->user()->auth_version);
|
||||
Audit::record('auth.login', $request->user()->id);
|
||||
return $this->session($request);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Audit::record('auth.logout', $request->user()->id);
|
||||
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
|
||||
return response()->json(['csrf' => csrf_token()])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
public function select(Request $request) { return response()->json($this->workflow->select($request)); }
|
||||
public function company(Request $request, int $company)
|
||||
{
|
||||
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
|
||||
abort_unless($company === $ids['company_id'], 403);
|
||||
return response()->json(\Illuminate\Support\Facades\DB::table('companies')->where('id', $company)->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']));
|
||||
}
|
||||
public function unit(Request $request, int $unit)
|
||||
{
|
||||
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
|
||||
app(\App\Modules\Identity\Services\Permissions::class)->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'unit.view', $unit);
|
||||
$record = \Illuminate\Support\Facades\DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']);
|
||||
abort_unless($record, 403);
|
||||
return response()->json($record);
|
||||
}
|
||||
public function renameCompany(Request $request, int $company) { $this->workflow->renameCompany($request, $company); return response()->json(['ok' => true]); }
|
||||
public function renameUnit(Request $request, int $unit) { $this->workflow->renameUnit($request, $unit); return response()->json(['ok' => true]); }
|
||||
public function createUnit(Request $request) { return response()->json(['id' => $this->workflow->createUnit($request)], 201); }
|
||||
public function accesses(Request $request) { return response()->json($this->workflow->accessList($request)); }
|
||||
public function grant(Request $request) { $this->workflow->changeAccess($request, true); return response()->json(['ok' => true]); }
|
||||
public function revoke(Request $request) { $this->workflow->changeAccess($request, false); return response()->json(['ok' => true]); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class RequireCompanyContext
|
||||
{
|
||||
public function __construct(private CompanyContext $context) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$this->context->clear();
|
||||
try {
|
||||
$selection = $request->session()->get('company_context', []);
|
||||
$tenant = $selection['tenant_id'] ?? null;
|
||||
$company = $selection['company_id'] ?? null;
|
||||
abort_unless($request->user() && $tenant && $company, 403, 'Contexto de empresa obrigatório.');
|
||||
$this->context->selectFor((int) $request->user()->getKey(), $tenant, $company, $selection['unit_id'] ?? null);
|
||||
if (! $request->isMethodSafe()) {
|
||||
$token = $request->header('X-SGF-Context');
|
||||
abort_unless(is_string($token) && hash_equals($selection['token'] ?? '', $token), 409, 'O contexto mudou. Reabra o formulário antes de guardar.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Http;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class RequireValidSession
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
abort_unless($request->user(), 401);
|
||||
if ((int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
abort(401, 'Sessão terminada. Entre novamente.');
|
||||
}
|
||||
$response = $next($request);
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
// Identity catalogue; company selection is authorized by CompanyContext.
|
||||
final class Company extends Model
|
||||
{
|
||||
protected $fillable = ['tenant_id', 'name'];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Tenant extends Model
|
||||
{
|
||||
protected $fillable = ['name'];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Models;
|
||||
|
||||
use App\Shared\Context\BelongsToCompany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class Unit extends Model
|
||||
{
|
||||
use BelongsToCompany;
|
||||
|
||||
protected $fillable = ['name'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::addGlobalScope('unit-access', function (\Illuminate\Database\Eloquent\Builder $query): void {
|
||||
$query->whereIn('units.id', app(\App\Shared\Context\CompanyContext::class)->permittedUnitIds());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class Audit
|
||||
{
|
||||
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
|
||||
{
|
||||
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
|
||||
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants']));
|
||||
DB::table('identity_audit')->insert([
|
||||
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
|
||||
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
|
||||
'details' => json_encode($safe, JSON_THROW_ON_ERROR), 'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class IdentityWorkflow
|
||||
{
|
||||
public function __construct(private Permissions $permissions, private CompanyContext $context) {}
|
||||
|
||||
public function companies(int $user): array
|
||||
{
|
||||
return DB::table('companies as c')->join('permission_grants as g', 'g.company_id', '=', 'c.id')
|
||||
->where('g.user_id', $user)->where('g.permission', 'company.view')->whereNull('g.unit_id')
|
||||
->select('c.id', 'c.tenant_id', 'c.name')->orderBy('c.name')->get()->all();
|
||||
}
|
||||
|
||||
public function units(int $user, int $tenant, int $company): array
|
||||
{
|
||||
return DB::table('units')->where('tenant_id', $tenant)->where('company_id', $company)
|
||||
->whereIn('id', $this->permissions->unitIds($user, $tenant, $company))
|
||||
->select('id', 'name')->orderBy('name')->get()->all();
|
||||
}
|
||||
|
||||
public function snapshot(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
$result = ['user' => $user ? ['id' => $user->id, 'name' => $user->name, 'email' => $user->email] : null,
|
||||
'csrf' => csrf_token(), 'companies' => [], 'context' => null,
|
||||
'setup_pending' => app()->environment('local') && ! DB::table('users')->exists()];
|
||||
if (! $user) { return $result; }
|
||||
$result['companies'] = $this->companies($user->id);
|
||||
$selected = $request->session()->get('company_context');
|
||||
if (! $selected) { return $result; }
|
||||
try {
|
||||
$this->context->selectFor($user->id, $selected['tenant_id'], $selected['company_id'], $selected['unit_id']);
|
||||
$company = DB::table('companies')->where('id', $selected['company_id'])->first(['id', 'name', 'tenant_id']);
|
||||
$unit = $selected['unit_id'] ? DB::table('units')->where('id', $selected['unit_id'])->first(['id', 'name']) : null;
|
||||
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id)
|
||||
->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all();
|
||||
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
|
||||
'token' => $selected['token'], 'grants' => $grants];
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
|
||||
if ($exception->getStatusCode() !== 403) { throw $exception; }
|
||||
$request->session()->forget('company_context');
|
||||
$result['notice'] = 'O acesso ao contexto anterior mudou. Seleccione um contexto autorizado.';
|
||||
} finally { $this->context->clear(); }
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function select(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1']);
|
||||
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
|
||||
// Selection is explicit, never silently carries the old unit into another company.
|
||||
$this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
|
||||
$request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
|
||||
'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]);
|
||||
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
|
||||
return $this->snapshot($request);
|
||||
}
|
||||
|
||||
public function renameCompany(Request $request, int $company): void
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $user) use ($company, $data): void {
|
||||
abort_unless($company === $ids['company_id'], 403);
|
||||
$this->permissions->require($user, $ids['tenant_id'], $company, 'company.edit');
|
||||
$before = DB::table('companies')->where('id', $company)->value('name');
|
||||
DB::table('companies')->where('id', $company)->update(['name' => trim($data['name']), 'updated_at' => now()]);
|
||||
Audit::record('company.renamed', $user, $ids['tenant_id'], $company, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
|
||||
});
|
||||
}
|
||||
|
||||
public function renameUnit(Request $request, int $unit): void
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $user) use ($unit, $data): void {
|
||||
abort_unless($this->context->unitId() === $unit, 409, 'Seleccione esta unidade antes de editar.');
|
||||
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.edit', $unit);
|
||||
$before = DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->value('name');
|
||||
abort_if($before === null, 403);
|
||||
DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->update(['name' => trim($data['name']), 'updated_at' => now()]);
|
||||
Audit::record('unit.renamed', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
|
||||
});
|
||||
}
|
||||
|
||||
public function createUnit(Request $request): int
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
|
||||
return $this->mutate($request, function ($ids, $user) use ($data): int {
|
||||
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.create');
|
||||
$unit = DB::table('units')->insertGetId($ids + ['name' => trim($data['name']), 'created_at' => now(), 'updated_at' => now()]);
|
||||
Audit::record('unit.created', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['after_name' => trim($data['name'])]);
|
||||
foreach (Permissions::UNIT as $permission) {
|
||||
$this->insertGrant($user, $ids, $unit, $permission);
|
||||
Audit::record('access.granted_on_creation', $user, $ids['tenant_id'], $ids['company_id'], $unit, $user, ['permission' => $permission]);
|
||||
}
|
||||
return $unit;
|
||||
});
|
||||
}
|
||||
|
||||
public function accessList(Request $request): array
|
||||
{
|
||||
$ids = $this->context->identifiers();
|
||||
$this->permissions->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'access.manage');
|
||||
$allowedUnits = $this->context->permittedUnitIds();
|
||||
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
|
||||
->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get();
|
||||
$grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id'])
|
||||
->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']);
|
||||
return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];
|
||||
}
|
||||
|
||||
public function changeAccess(Request $request, bool $grant): void
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email|max:255', 'permission' => 'required|string', 'unit_id' => 'nullable|integer|min:1',
|
||||
'reason' => 'required|string|max:500', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'user_id' => 'prohibited']);
|
||||
$this->mutate($request, function ($ids, $actor) use ($data, $grant): void {
|
||||
$company = $ids['company_id']; $tenant = $ids['tenant_id'];
|
||||
$this->permissions->require($actor, $tenant, $company, 'access.manage');
|
||||
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
|
||||
$catalogue = $unit === null ? Permissions::COMPANY : Permissions::UNIT;
|
||||
abort_unless(in_array($data['permission'], $catalogue, true), 422, 'Permissão ou âmbito inválido.');
|
||||
$this->permissions->require($actor, $tenant, $company, $data['permission'], $unit);
|
||||
if ($unit !== null) {
|
||||
$this->permissions->require($actor, $tenant, $company, 'unit.view', $unit);
|
||||
abort_unless(DB::table('units')->where('id', $unit)->where('tenant_id', $tenant)->where('company_id', $company)->exists(), 403);
|
||||
}
|
||||
$target = User::query()->where('email', mb_strtolower($data['email']))->first();
|
||||
if (! $target) { throw ValidationException::withMessages(['email' => 'Não foi possível atribuir este acesso. Confirme o utilizador pelo procedimento local.']); }
|
||||
$membership = DB::table('company_memberships')->where($ids)->where('user_id', $target->id);
|
||||
if (! $membership->exists()) {
|
||||
abort_unless($grant && $data['permission'] === 'company.view' && $unit === null, 403, 'Conceda primeiro consulta da empresa.');
|
||||
DB::table('company_memberships')->insert($ids + ['user_id' => $target->id]);
|
||||
}
|
||||
if ($grant && $data['permission'] !== 'company.view') {
|
||||
$this->permissions->require($target->id, $tenant, $company, 'company.view');
|
||||
if ($unit && $data['permission'] === 'unit.edit') { $this->permissions->require($target->id, $tenant, $company, 'unit.view', $unit); }
|
||||
}
|
||||
$removed = [];
|
||||
if ($grant) { $this->insertGrant($target->id, $ids, $unit, $data['permission']); }
|
||||
else {
|
||||
$query = DB::table('permission_grants')->where($ids)->where('user_id', $target->id);
|
||||
if ($data['permission'] === 'company.view') {
|
||||
// Revocation deactivates all derived grants; reject partial authority rather than silently exceeding it.
|
||||
foreach ($query->get() as $existing) { $this->permissions->require($actor, $tenant, $company, $existing->permission, $existing->unit_id ? (int) $existing->unit_id : null); }
|
||||
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
|
||||
$query->delete();
|
||||
} else {
|
||||
if ($data['permission'] === 'unit.view') {
|
||||
if ($this->permissions->has($target->id, $tenant, $company, 'unit.edit', $unit)) { $this->permissions->require($actor, $tenant, $company, 'unit.edit', $unit); }
|
||||
$query->where('unit_id', $unit)->whereIn('permission', Permissions::UNIT);
|
||||
} else { $query->where('unit_id', $unit)->where('permission', $data['permission']); }
|
||||
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
|
||||
$query->delete();
|
||||
}
|
||||
}
|
||||
Audit::record($grant ? 'access.granted' : 'access.revoked', $actor, $tenant, $company, $unit, $target->id,
|
||||
['permission' => $data['permission'], 'reason' => $data['reason'], 'grants' => $grant ? [['permission' => $data['permission'], 'unit_id' => $unit]] : $removed]);
|
||||
});
|
||||
}
|
||||
|
||||
private function insertGrant(int $user, array $ids, ?int $unit, string $permission): void
|
||||
{
|
||||
DB::table('permission_grants')->insertOrIgnore($ids + ['user_id' => $user, 'unit_id' => $unit, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
private function mutate(Request $request, callable $operation): mixed
|
||||
{
|
||||
$selected = $this->context->identifiers();
|
||||
return DB::transaction(function () use ($request, $operation, $selected) {
|
||||
// All access changes and business mutations serialize on the same company row.
|
||||
DB::table('companies')->where('id', $selected['company_id'])->where('tenant_id', $selected['tenant_id'])->lockForUpdate()->firstOrFail();
|
||||
$this->context->selectFor($request->user()->id, $selected['tenant_id'], $selected['company_id'], $this->context->unitId());
|
||||
return $operation($selected, $request->user()->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class LocalDatabaseGuard
|
||||
{
|
||||
public static function allows(): bool
|
||||
{
|
||||
if (! app()->environment('local')) { return false; }
|
||||
$connection = config('database.default');
|
||||
$config = config("database.connections.$connection", []);
|
||||
foreach (['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432', 'database' => 'sgf_tempo_dev', 'username' => 'sgf_dev_app'] as $key => $expected) {
|
||||
if ((string) ($config[$key] ?? '') !== $expected) { return false; }
|
||||
}
|
||||
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])) { return false; }
|
||||
try {
|
||||
$identity = DB::selectOne('SELECT current_database() AS db, current_user AS role, rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user');
|
||||
return $identity->db === 'sgf_tempo_dev' && $identity->role === 'sgf_dev_app'
|
||||
&& ! $identity->rolsuper && ! $identity->rolcreatedb && ! $identity->rolcreaterole && ! $identity->rolbypassrls;
|
||||
} catch (\Throwable) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class Permissions
|
||||
{
|
||||
public const COMPANY = ['company.view', 'company.edit', 'unit.create', 'access.manage'];
|
||||
public const UNIT = ['unit.view', 'unit.edit'];
|
||||
|
||||
public function has(int $user, int $tenant, int $company, string $permission, ?int $unit = null): bool
|
||||
{
|
||||
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
|
||||
->where('company_id', $company)->where('permission', $permission)->where('unit_id', $unit)->exists();
|
||||
}
|
||||
|
||||
public function require(int $user, int $tenant, int $company, string $permission, ?int $unit = null): void
|
||||
{
|
||||
abort_unless($this->has($user, $tenant, $company, $permission, $unit), 403, 'Acção não autorizada.');
|
||||
}
|
||||
|
||||
public function unitIds(int $user, int $tenant, int $company): array
|
||||
{
|
||||
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
|
||||
->where('company_id', $company)->where('permission', 'unit.view')->pluck('unit_id')->map(fn ($id) => (int) $id)->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Identity\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ProtectedProvisioningInput
|
||||
{
|
||||
public static function read($stream, string $email): array
|
||||
{
|
||||
try {
|
||||
$raw = stream_get_contents($stream, 16385);
|
||||
if (! is_string($raw) || strlen($raw) > 16384) { throw new RuntimeException(); }
|
||||
$data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
|
||||
if (! is_array($data) || ($data['confirmed'] ?? null) !== true || ($data['email'] ?? null) !== $email
|
||||
|| ! is_string($data['password'] ?? null) || ! is_string($data['confirmation'] ?? null)) {
|
||||
throw new RuntimeException();
|
||||
}
|
||||
return [$data['password'], $data['confirmation']];
|
||||
} catch (\Throwable) {
|
||||
// Never include payload, parser errors, credentials or tokens in diagnostics.
|
||||
throw new RuntimeException('Entrada protegida inválida ou não confirmada. Nada alterado.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Shared\Context\CompanyContext;
|
||||
use App\Shared\Testing\TestingDatabaseGuard;
|
||||
use Illuminate\Console\Events\CommandStarting;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->environment(['testing', 'browser-testing']) || defined('PHPUNIT_COMPOSER_INSTALL')) {
|
||||
TestingDatabaseGuard::assertSafe();
|
||||
}
|
||||
|
||||
Event::listen(CommandStarting::class, function (CommandStarting $event): void {
|
||||
if (in_array($event->command, ['migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'db:wipe'], true)) {
|
||||
$connection = $event->input->hasOption('database') ? $event->input->getOption('database') : null;
|
||||
TestingDatabaseGuard::assertSafe($connection);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
trait BelongsToCompany
|
||||
{
|
||||
protected static function bootBelongsToCompany(): void
|
||||
{
|
||||
static::addGlobalScope('company', function (Builder $query): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
$query->where($query->getModel()->qualifyColumn($column), $value);
|
||||
}
|
||||
});
|
||||
|
||||
static::saving(function (Model $model): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
if ($model->getAttribute($column) !== null && (int) $model->getAttribute($column) !== $value) {
|
||||
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
|
||||
}
|
||||
$model->setAttribute($column, $value);
|
||||
}
|
||||
});
|
||||
|
||||
static::deleting(function (Model $model): void {
|
||||
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
|
||||
if ((int) $model->getAttribute($column) !== $value) {
|
||||
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use App\Modules\Identity\Models\Company;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use App\Modules\Identity\Services\Permissions;
|
||||
|
||||
final class CompanyContext
|
||||
{
|
||||
private ?array $identifiers = null;
|
||||
private ?int $userId = null;
|
||||
private ?int $unitId = null;
|
||||
|
||||
public function selectFor(int $userId, int $tenantId, int $companyId, ?int $unitId = null): void
|
||||
{
|
||||
$this->clear();
|
||||
$allowed = DB::table('company_memberships')
|
||||
->where('user_id', $userId)->where('tenant_id', $tenantId)
|
||||
->where('company_id', $companyId)->exists();
|
||||
|
||||
if (! $allowed || ! Company::query()->where('tenant_id', $tenantId)->whereKey($companyId)->exists()) {
|
||||
throw new AccessDeniedHttpException('Empresa não autorizada.');
|
||||
}
|
||||
|
||||
app(Permissions::class)->require($userId, $tenantId, $companyId, 'company.view');
|
||||
if ($unitId !== null) {
|
||||
app(Permissions::class)->require($userId, $tenantId, $companyId, 'unit.view', $unitId);
|
||||
abort_unless(DB::table('units')->where('tenant_id', $tenantId)->where('company_id', $companyId)->where('id', $unitId)->exists(), 403);
|
||||
}
|
||||
|
||||
$this->identifiers = ['tenant_id' => $tenantId, 'company_id' => $companyId];
|
||||
$this->userId = $userId;
|
||||
$this->unitId = $unitId;
|
||||
}
|
||||
|
||||
public function identifiers(): array
|
||||
{
|
||||
return $this->identifiers ?? throw new AccessDeniedHttpException('Contexto de empresa obrigatório.');
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->identifiers = null;
|
||||
$this->userId = null;
|
||||
$this->unitId = null;
|
||||
}
|
||||
|
||||
public function userId(): int { $this->identifiers(); return $this->userId; }
|
||||
public function unitId(): ?int { $this->identifiers(); return $this->unitId; }
|
||||
public function permittedUnitIds(): array
|
||||
{
|
||||
$ids = $this->identifiers();
|
||||
return app(Permissions::class)->unitIds($this->userId(), $ids['tenant_id'], $ids['company_id']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Testing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
final class TestingDatabaseGuard
|
||||
{
|
||||
public static function assertSafe(?string $connection = null): void
|
||||
{
|
||||
$name = $connection ?: config('database.default');
|
||||
$config = config("database.connections.{$name}", []);
|
||||
$expected = [
|
||||
'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432',
|
||||
'database' => 'sgf_tempo_test', 'username' => 'sgf_test_runner',
|
||||
];
|
||||
foreach ($expected as $key => $value) {
|
||||
if ((string) ($config[$key] ?? '') !== $value) {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: apenas sgf_tempo_test no cluster próprio é permitido.');
|
||||
}
|
||||
}
|
||||
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write']) || ($config['search_path'] ?? 'public') !== 'public') {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: redireccionamento de ligação não permitido.');
|
||||
}
|
||||
$identity = DB::connection($name)->selectOne('SELECT current_database() AS db, current_user AS role');
|
||||
if ($identity->db !== 'sgf_tempo_test' || $identity->role !== 'sgf_test_runner') {
|
||||
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: destino efectivo inválido.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user