Files
Investidor-V2/app/Services/PortalProfileAccess.php
T
nala 01405cbf8a
tests / ci (push) Canceled after 0s
oportunidades 5
2026-10-10 14:15:28 +02:00

263 lines
14 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class PortalProfileAccess
{
public const VERSION = 'profiles-v1-20261010';
/** @return list<string> */
public static function fingerprintFiles(): array
{
return [__FILE__, app_path('Models/User.php'), app_path('Models/Opportunity.php'), config_path('portal.php'), base_path('routes/web.php'),
app_path('Http/Middleware/HandleInertiaRequests.php'), app_path('Services/OpportunityManagement.php'),
app_path('Services/OpportunityResources.php'), app_path('Services/OpportunityImageProcessor.php'),
app_path('Services/PortalProfileAdministration.php'), app_path('Services/PortalSuperusers.php'),
app_path('Services/OpportunityProfileWorkflow.php'), app_path('Http/Controllers/PortalProfileController.php'), app_path('Http/Controllers/DashboardController.php'),
app_path('Http/Controllers/OpportunityManagementController.php'),
app_path('Services/OpportunityCanonicalContract.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'),
resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/opportunity-canonical-fields.tsx'), resource_path('js/components/opportunity-parcel-geometry.tsx'),
app_path('Http/Controllers/OpportunityResourceController.php'),
app_path('Http/Controllers/InvestmentTerritoryController.php'),
app_path('Http/Middleware/PortalProfileSession.php'), app_path('Http/Middleware/ProtectProfileOpportunity.php'),
app_path('Http/Middleware/ProtectPrivilegedIdentity.php'), app_path('Http/Middleware/EnsurePortalProfiles.php'),
app_path('Http/Middleware/AuditProfileDecision.php'), app_path('Providers/AppServiceProvider.php'),
database_path('migrations/2026_10_10_040000_create_portal_profile_authorization.php'), base_path('bootstrap/app.php'),
resource_path('js/pages/profiles/request.tsx'), resource_path('js/pages/profiles/administration.tsx'), resource_path('js/pages/profiles/audit.tsx'),
resource_path('js/components/profile-navigation.tsx'), resource_path('js/components/opportunity-profile-actions.tsx'),
resource_path('js/pages/management/opportunities.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'),
resource_path('js/types/profile-access.ts'), resource_path('js/lib/profile-json.ts'), resource_path('js/pages/management/shared.tsx'),
app_path('Services/PortalProfileInstallation.php'), app_path('Http/Controllers/PortalProfileInstallationController.php'), config_path('portal_profiles.php'), resource_path('js/pages/profiles/installation.tsx')];
}
public function available(): bool
{
return config('portal.profile_access_enabled') && Schema::hasTable('portal_role_assignments')
&& Schema::hasTable('portal_superuser_pair') && Schema::hasTable('portal_profile_requests')
&& Schema::hasTable('portal_security_approvals') && Schema::hasColumn('users', 'portal_access_revision')
&& Schema::hasColumn('opportunities', 'workflow_context') && Schema::hasColumn('opportunities', 'owner_user_id')
&& Schema::hasColumn('opportunities', 'organization_id');
}
/** @return list<\stdClass> */
public function grants(User $actor, ?string $role = null): array
{
if (! $this->available() || ! $actor->hasVerifiedEmail()) {
return [];
}
return array_values(DB::table('portal_role_assignments')->where('user_id', $actor->id)->whereNull('revoked_at')->where('starts_at', '<=', now())
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->when($role, fn ($q) => $q->where('role', $role))->get()->all());
}
public function has(User $actor, string $role): bool
{
return $role === 'superuser' ? $this->superuser($actor) : $this->grants($actor, $role) !== [];
}
public function superuser(User $actor): bool
{
return $this->available() && $actor->hasVerifiedEmail() && DB::table('portal_superuser_pair')->where('id', 1)
->where(fn ($q) => $q->where('first_user_id', $actor->id)->orWhere('second_user_id', $actor->id))->exists();
}
public function represents(User $actor, ?string $organization): bool
{
return $organization === null || DB::table('memberships as m')->join('organizations as o', 'o.id', '=', 'm.organization_id')
->where('m.user_id', $actor->id)->where('m.organization_id', $organization)
->whereNull('m.deleted_at')->whereNull('o.deleted_at')->whereIn('m.role', ['admin', 'manager', 'officer'])->exists();
}
public function mfa(User $actor): bool
{
// Enrollment alone is insufficient: Fortify must have completed MFA in this session.
return $actor->two_factor_confirmed_at !== null && $actor->two_factor_secret !== null
&& session('login.id') === null && session('portal.mfa_actor') === $actor->id
&& (int) session('portal.mfa_at', 0) >= time() - 900;
}
public function scoped(User $actor, string $role, ?string $organization, string $type): bool
{
foreach ($this->grants($actor, $role) as $grant) {
if (($grant->organization_id === $organization || ($role !== 'promoter' && $grant->organization_id === null))
&& in_array($type, json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true)) {
return $role !== 'promoter' || $this->represents($actor, $organization);
}
}
return false;
}
public function operational(User $actor, string $action, ?int $recordId = null): bool
{
if ($this->has($actor, 'auditor')) {
return false;
}
if (! $this->superuser($actor)) {
return true;
}
return DB::table('portal_security_approvals')->where('kind', 'elevation')->where('subject_user_id', $actor->id)
->whereNotNull('approved_by')->whereNull('consumed_at')->where('expires_at', '>', now())
->whereExists(fn ($q) => $q->selectRaw('1')->from('portal_superuser_pair')
->where(fn ($holders) => $holders->whereColumn('first_user_id', 'portal_security_approvals.approved_by')->orWhereColumn('second_user_id', 'portal_security_approvals.approved_by')))
->where('scope->action', $action)->where('scope->record_id', $recordId)->exists();
}
public function canCreate(User $actor, ?string $organization, string $type): bool
{
return $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && $this->operational($actor, 'create');
}
public function legacyWriteAllowed(User $actor): bool
{
// A deliberate v1 assignment cannot use the old allowlist to bypass its scope.
if (! $actor->can('manage-portal')) {
return false;
}
if (! Schema::hasTable('portal_role_assignments')) {
return true;
}
// Revocation/expiry must not silently restore an older, wider privilege.
return ! DB::table('portal_role_assignments')->where('user_id', $actor->id)->exists()
&& ! DB::table('portal_superuser_pair')->where(fn ($q) => $q->where('first_user_id', $actor->id)->orWhere('second_user_id', $actor->id))->exists()
&& ! DB::table('portal_security_approvals')->where('kind', 'replacement')->whereNotNull('consumed_at')->where('scope->old_user_id', $actor->id)->exists();
}
public function canView(User $actor, Opportunity $record): bool
{
if ($record->owner_user_id === null) {
return $actor->can('manage-portal');
}
if (! $this->available() || ! $actor->hasVerifiedEmail()) {
return false;
}
if ($this->has($actor, 'auditor') || $this->superuser($actor)) {
return true;
}
$personal = $record->organization_id !== null || (int) $record->owner_user_id === $actor->id;
return ($personal && $this->scoped($actor, 'promoter', $record->organization_id, $record->opportunity_category))
|| $this->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
|| $this->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category);
}
public function canEdit(User $actor, Opportunity $record, ?string $newType = null): bool
{
if ($record->owner_user_id === null) {
return $this->legacyWriteAllowed($actor);
}
return $this->canView($actor, $record) && ! $record->trashed()
&& in_array($this->state($record), ['draft', 'needs_information'], true)
&& $this->scoped($actor, 'promoter', $record->organization_id, $newType ?? $record->opportunity_category)
&& $this->operational($actor, 'edit', $record->id);
}
public function conflict(User $actor, Opportunity $record): bool
{
$context = $record->workflow_context ?? [];
return (int) $record->owner_user_id === $actor->id || in_array($actor->id, $context['contributors'] ?? [], true)
|| ($record->organization_id !== null && DB::table('memberships')->where('user_id', $actor->id)
->where('organization_id', $record->organization_id)->whereNull('deleted_at')->exists());
}
public function state(Opportunity $record): string
{
return $record->workflow_context['working_state'] ?? $record->status;
}
public function authorize(User $actor, string $action, Opportunity $record): void
{
$allowed = $action === 'view' ? $this->canView($actor, $record) : $this->canEdit($actor, $record);
if (! $allowed) {
$this->audit($actor, 'access_denied', ['record_id' => $record->id, 'action' => $action]);
}
abort_unless($allowed, 403, 'O perfil, representação, tipo ou estado não autoriza esta operação.');
}
/**
* @param Builder<Opportunity> $query
* @return Builder<Opportunity>
*/
public function scope(Builder $query, User $actor): Builder
{
if ($this->has($actor, 'auditor') || $this->superuser($actor)) {
return $query;
}
return $query->where(function ($scope) use ($actor) {
$scope->whereRaw('1=0');
if ($actor->can('manage-portal')) {
$scope->orWhereNull('owner_user_id');
}
foreach ($this->grants($actor) as $grant) {
if ($grant->role === 'promoter' && ! $this->represents($actor, $grant->organization_id)) {
continue;
}
$scope->orWhere(function ($allowed) use ($actor, $grant) {
$allowed->whereNotNull('owner_user_id')->whereIn('opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR));
if ($grant->organization_id !== null) {
$allowed->where('organization_id', $grant->organization_id);
} elseif ($grant->role === 'promoter') {
$allowed->whereNull('organization_id')->where('owner_user_id', $actor->id);
}
});
}
});
}
/** @return array<string,mixed>|null */
public function navigation(?User $actor): ?array
{
if (! $actor || ! $this->available() || ! $actor->hasVerifiedEmail()) {
return null;
}
$contexts = [];
foreach ($this->grants($actor, 'promoter') as $grant) {
if (! $this->represents($actor, $grant->organization_id)) {
continue;
}
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
if ($this->canCreate($actor, $grant->organization_id, $type)) {
$contexts[] = ['organization_id' => $grant->organization_id, 'type' => $type];
}
}
}
return ['contexts' => $contexts, 'mine' => $contexts ? '/minhas-oportunidades' : null, 'create' => $contexts ? '/minhas-oportunidades/create' : null,
'request' => '/perfil/promotor', 'review' => $this->has($actor, 'validator') || $this->has($actor, 'publisher') || $this->has($actor, 'auditor') || $this->superuser($actor) ? '/minhas-oportunidades' : null,
'audit' => $this->has($actor, 'auditor') || $this->superuser($actor) ? '/perfis/auditoria' : null, 'administration' => $this->superuser($actor) ? '/perfis/gestao' : null];
}
/** @param array<string,mixed> $data */
public function audit(User $actor, string $action, array $data): void
{
DB::table('audit_events')->insert(['source_system' => 'portal-profiles-v1', 'entity_type' => 'authorization', 'actor_user_id' => $actor->id,
'action' => $action, 'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode([...$data, 'policy_version' => self::VERSION, 'origin_ip' => request()->ip()], JSON_THROW_ON_ERROR)]);
}
public function revokeSessions(User $subject): void
{
DB::table('users')->where('id', $subject->id)->increment('portal_access_revision');
// Invalidate remember-me as well; no password or MFA changes.
DB::table('users')->where('id', $subject->id)->update(['remember_token' => null]);
if (Schema::hasTable('sessions')) {
DB::table('sessions')->where('user_id', $subject->id)->delete();
}
if (Schema::hasTable('personal_access_tokens')) {
DB::table('personal_access_tokens')->where('tokenable_type', User::class)->where('tokenable_id', $subject->id)->delete();
}
}
}