oportunidades 5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-10 14:15:28 +02:00
parent 6ddafc95ba
commit 01405cbf8a
144 changed files with 28903 additions and 1103 deletions
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class AuditProfileDecision
{
public function handle(Request $request, Closure $next): Response
{
$access = app(PortalProfileAccess::class);
if (! $request->user() || ! $access->available() || ! preg_match('#^(perfil/|perfis/|minhas-oportunidades)#', $request->path())) {
return $next($request);
}
try {
$response = $next($request);
} catch (\Throwable $error) {
$status = $error instanceof HttpExceptionInterface ? $error->getStatusCode() : ($error instanceof ValidationException ? 422 : 500);
$this->record($request, $access, $status);
throw $error;
}
if ($response->getStatusCode() >= 400) {
$this->record($request, $access, $response->getStatusCode());
}
return $response;
}
private function record(Request $request, PortalProfileAccess $access, int $status): void
{
$access->audit($request->user(), 'request_denied', ['method' => $request->method(), 'path' => $request->path(), 'status' => $status]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePortalProfiles
{
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('portal.profile_access_enabled'), 404);
abort_unless(app(PortalProfileAccess::class)->available(), 503, 'Autorização por perfis ainda não instalada.');
return $next($request);
}
}
@@ -3,6 +3,8 @@
namespace App\Http\Middleware;
use App\Services\InvestorPath;
use App\Services\OpportunityManagement;
use App\Services\PortalProfileAccess;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -24,6 +26,14 @@ class HandleInertiaRequests extends Middleware
*/
public function version(Request $request): ?string
{
if (app()->environment('testing') && config('portal.qa_build_directory')) {
$manifest = public_path(config('portal.qa_build_directory').'/manifest.json');
$version = is_file($manifest) ? hash_file('xxh128', $manifest) : false;
return $version === false ? null : $version;
}
return parent::version($request);
}
@@ -39,11 +49,18 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'portalOperator' => config('portal.operator_name'),
'educationEnabled' => (bool) config('education.enabled', false),
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
'existingBusinessEnabled' => (bool) config('existing-business.enabled', false),
'investorPathEnabled' => app(InvestorPath::class)->available(),
'opportunityManagement' => config('portal.opportunity_dashboard_access_enabled') && $request->user() && app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()) ? [
'installed' => app(OpportunityManagement::class)->installed(),
'manage' => ['label' => 'Gestão de Oportunidades', 'href' => '/management/opportunities'],
'create' => ['label' => 'Nova oportunidade', 'href' => '/management/opportunities/create'],
'installationHref' => '/management/opportunity-management-install',
] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
'auth' => [
'user' => $request->user(),
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class PortalProfileSession
{
public function handle(Request $request, Closure $next): Response
{
$access = app(PortalProfileAccess::class);
$available = $access->available();
if ($request->user() && config('portal.profile_access_enabled') && ! $available) {
$recovery = $request->is('management/profile-install', 'management/profile-install/bootstrap')
|| ($request->is('logout') && $request->isMethod('POST'));
abort_if(! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && ! $recovery, 503,
'Autorizações indisponíveis: recuperar a consistência dos perfis antes de gravar. Pode terminar a sessão ou consultar o instalador.');
}
if ($request->user() && $available) {
$revision = (int) DB::table('users')->where('id', $request->user()->id)->value('portal_access_revision');
$bound = $request->session()->get('portal.access_revision');
if (($bound === null && $revision > 0) || ($bound !== null && (int) $bound !== $revision)) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return $request->expectsJson() ? response()->json(['message' => 'A autorização foi alterada. Inicie uma nova sessão.'], 401)
: redirect('/login')->withErrors(['email' => 'A autorização foi alterada. Inicie uma nova sessão.']);
}
$request->session()->put('portal.access_revision', $revision);
}
return $next($request);
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ProtectPrivilegedIdentity
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
$access = app(PortalProfileAccess::class);
if (! $actor || ! $access->available()) {
return $next($request);
}
if (! in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true) && $access->has($actor, 'auditor')
&& $request->is('management/*', 'api/v1/investment/admin/*', 'api/v2/investment/admin/*')) {
$access->audit($actor, 'auditor_administrative_write_denied', ['method' => $request->method(), 'path' => $request->path()]);
abort(403, 'Auditor só permite leitura administrativa, mesmo com outro papel ou permissão legada.');
}
$mandatoryMfa = $access->superuser($actor) || $access->has($actor, 'validator') || $access->has($actor, 'publisher');
$denied = $request->isMethod('DELETE') && $request->is('user/two-factor-authentication') && $mandatoryMfa;
$denied = $denied || ($access->superuser($actor) && $request->is('settings/profile') && $request->isMethod('PATCH')
&& $request->has('email') && $request->input('email') !== $actor->email);
if ($request->isMethod('DELETE') && $request->is('settings/profile')) {
$denied = $denied || $access->superuser($actor)
|| DB::table('portal_role_assignments')->where('user_id', $actor->id)->orWhere('approved_by', $actor->id)->exists()
|| DB::table('portal_profile_requests')->where('user_id', $actor->id)->orWhere('decided_by', $actor->id)->exists();
}
if ($denied) {
$access->audit($actor, 'privileged_identity_change_denied', ['method' => $request->method(), 'path' => $request->path()]);
abort(403, 'Esta alteração removeria MFA obrigatório, um titular ou histórico de autorização. Use revogação ou substituição controlada; não desative MFA para recuperar acesso.');
}
return $next($request);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Models\Opportunity;
use App\Services\PortalProfileAccess;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
class ProtectProfileOpportunity
{
public function handle(Request $request, Closure $next): Response
{
$id = null;
if (preg_match('#^(?:(?:management|api/v1)/opportunities|api/v1/investment/admin/opportunities|minhas-oportunidades|management/territories/opportunities)/(\d+)(?:/|$)#', $request->path(), $match)) {
$id = (int) $match[1];
} elseif ($request->is('management/opportunity-origin/preview', 'management/opportunity-origin/apply')) {
$id = filter_var($request->input('id'), FILTER_VALIDATE_INT) ?: null;
} elseif ($request->is('api/v2/investment/admin/zones/*/links')) {
$id = filter_var($request->input('opportunity_id'), FILTER_VALIDATE_INT) ?: null;
}
if ($request->user() && $id !== null && Schema::hasColumn('opportunities', 'owner_user_id')) {
$record = Opportunity::withTrashed()->find($id);
if ($record && $record->owner_user_id === null && ! $request->isMethod('GET')) {
abort_unless(app(PortalProfileAccess::class)->legacyWriteAllowed($request->user()), 403, 'O papel v1 não contorna os controlos através de um registo legado.');
}
if ($record && ($record->owner_user_id !== null || str_starts_with($request->path(), 'minhas-oportunidades'))) {
abort_if($record->owner_user_id === null && str_starts_with($request->path(), 'minhas-oportunidades'), 403, 'Registo legado não convertido para a política v1.');
$access = app(PortalProfileAccess::class);
$access->authorize($request->user(), $request->isMethod('GET') || str_ends_with($request->path(), '/workflow') || str_ends_with($request->path(), '/preview') ? 'view' : 'edit', $record);
abort_if($request->is('management/opportunity-origin/apply'), 403, 'O cadastro privado v1 não usa a classificação editorial dos exemplos legados.');
abort_if($request->isMethod('POST') && str_ends_with($request->path(), '/lifecycle') && ! str_contains($request->path(), '/resources/'), 403, 'Use o workflow com decisões segregadas; o perfil não concede exclusão.');
}
}
return $next($request);
}
}