Files
Investidor-V2/app/Services/PortalProfileAccess.php
T
2026-10-22 20:55:08 +02:00

408 lines
23 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('Services/OpportunityClassification.php'), app_path('Http/Controllers/OpportunityClassificationController.php'), resource_path('js/components/opportunity-classification.tsx'), app_path('Services/OpportunityContacts.php'), resource_path('js/components/opportunity-contact-form.tsx'), app_path('Services/PortalInternalDesignation.php'), 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/VerifiedDocumentBinary.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/OpportunityCanonicalResponse.php'), app_path('Services/OpportunityCategoryFields.php'), app_path('Services/OpportunityModularProfiles.php'), app_path('Services/OpportunityWorkflowTasks.php'), base_path('composer.json'), base_path('composer.lock'),
app_path('Services/OpportunityReviewMateriality.php'), app_path('Services/OpportunityReviewConditions.php'), app_path('Services/OpportunityCanonicalMetadata.php'), app_path('Services/OpportunityCompleteness.php'), app_path('Services/OpportunityCanonicalValue.php'),
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('Http/Middleware/EnsurePortalProfileAdministration.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/components/portal-grouped-menu.tsx'), resource_path('js/components/user-menu-content.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'),
resource_path('js/pages/dashboard.tsx'), resource_path('js/pages/PortalInvestidor.tsx'),
resource_path('js/layouts/portal-navigation.tsx'), resource_path('js/components/interactive-investment-map.tsx'),
resource_path('js/components/simulation-form.tsx'), resource_path('js/lib/simulation-form.ts'), app_path('Services/LegacyContent.php'),
resource_path('js/components/ui/sidebar.tsx'), resource_path('js/components/simulation-financial-tools.tsx'), resource_path('js/components/simulation-cashflow-planner.tsx'), resource_path('js/pages/investor-path/show.tsx'), resource_path('views/app.blade.php'), resource_path('views/profiles/creation-unavailable.blade.php'),
resource_path('js/pages/management/opportunity-resources.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') && $this->installed();
}
public function governanceAvailable(): bool
{
return (config('portal.profile_administration_enabled') || config('portal.profile_access_enabled')) && $this->installed();
}
public function installed(): bool
{
return 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->governanceAvailable() || ! $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->governanceAvailable() && $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 internalAssignment(User $actor, string $type): ?\stdClass
{
foreach ($this->grants($actor, 'promoter') as $grant) {
if ($grant->source === 'internal-portal-designation'
&& in_array($type, json_decode($grant->opportunity_types, true), true)
&& app(PortalInternalDesignation::class)->organization($grant->organization_id) !== null) {
return $grant;
}
}
return null;
}
public function promoterRecordScope(User $actor, Opportunity $record): bool
{
foreach ($this->grants($actor, 'promoter') as $grant) {
if (! in_array($record->opportunity_category, json_decode($grant->opportunity_types, true), true)) {
continue;
}
if ($grant->source === 'internal-portal-designation') {
if (app(PortalInternalDesignation::class)->organization($grant->organization_id) !== null
&& ($record->workflow_context['operator_organization_id'] ?? null) === $grant->organization_id
&& ((int) $record->owner_user_id === $actor->id || in_array($actor->id, $record->workflow_context['contributors'] ?? [], true))) {
return true;
}
} elseif ($grant->organization_id === $record->organization_id && $this->represents($actor, $record->organization_id)
&& ($record->organization_id !== null || (int) $record->owner_user_id === $actor->id)) {
return true;
}
}
return false;
}
public function scoped(User $actor, string $role, ?string $organization, string $type): bool
{
if ($role === 'promoter' && $organization !== null && $this->internalAssignment($actor, $type) !== null
&& DB::table('organizations')->where('id', $organization)->whereNull('deleted_at')->exists()) {
return true;
}
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->available()) {
return false;
}
if ($this->has($actor, 'auditor')) {
return false;
}
if (! $this->superuser($actor)) {
return true;
}
// Normal Promotor authority comes from its existing scope, not from the
// Superutilizador membership. Other/extraneous actions retain elevation.
if (in_array($action, ['view', 'edit'], true) && $recordId !== null) {
$record = Opportunity::withTrashed()->find($recordId);
if ($record && $record->owner_user_id !== null && $this->promoterRecordScope($actor, $record)) {
return true;
}
}
if (! $this->mfa($actor)) {
return false;
}
return DB::table('portal_security_approvals')->where('kind', 'elevation')->where('subject_user_id', $actor->id)
->whereNotNull('approved_by')->whereColumn('approved_by', '!=', 'subject_user_id')
->whereColumn('approved_by', '!=', 'requested_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
{
// Draft preparation is allowed within the assigned modality. Executable
// submission support is checked separately by the canonical contract.
$included = in_array($type, OpportunityManagement::CATEGORIES, true);
// This is creation as an authorized Promotor, never an exceptional SU act.
return $included && $this->available() && $this->scoped($actor, 'promoter', $organization, $type) && ! $this->has($actor, 'auditor');
}
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();
}
/** Administration never grants operational access to a dossier or its attachments. */
public function hasOperationalReadScope(User $actor, Opportunity $record): bool
{
$personal = $record->organization_id !== null || (int) $record->owner_user_id === $actor->id;
return $this->promoterRecordScope($actor, $record)
|| $this->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category)
|| $this->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category);
}
public function canView(User $actor, Opportunity $record): bool
{
if ($record->owner_user_id !== null && ! $this->available()) {
return false;
}
if ($this->superuser($actor)) {
return ! $this->has($actor, 'auditor') && $this->hasOperationalReadScope($actor, $record)
&& $this->operational($actor, 'view', $record->id);
}
if ($record->owner_user_id === null) {
return $actor->can('manage-portal');
}
if (! $this->available() || ! $actor->hasVerifiedEmail()) {
return false;
}
return $this->has($actor, 'auditor') || $this->hasOperationalReadScope($actor, $record);
}
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) && $this->promoterRecordScope($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;
}
if ($this->superuser($actor)) {
$ids = DB::table('portal_security_approvals')->where('kind', 'elevation')->where('subject_user_id', $actor->id)
->where('scope->action', 'view')->whereNotNull('approved_by')->whereNull('consumed_at')->where('expires_at', '>', now())
->get()->filter(fn ($row) => $this->operational($actor, 'view', json_decode($row->scope, true)['record_id'] ?? null))
->map(fn ($row) => json_decode($row->scope, true)['record_id'])->all();
$query->where(function ($eligible) use ($ids, $actor) {
$eligible->whereIn('id', $ids);
if ($this->has($actor, 'auditor')) {
return;
}
foreach ($this->grants($actor, 'promoter') as $grant) {
$internal = $grant->source === 'internal-portal-designation';
if (($internal && app(PortalInternalDesignation::class)->organization($grant->organization_id) === null)
|| (! $internal && ! $this->represents($actor, $grant->organization_id))) {
continue;
}
$eligible->orWhere(function ($normal) use ($actor, $grant, $internal) {
$normal->whereNotNull('owner_user_id')->whereIn('opportunity_category', json_decode($grant->opportunity_types, true));
if ($internal) {
$normal->where(fn ($q) => $q->where('owner_user_id', $actor->id)->orWhereJsonContains('workflow_context->contributors', $actor->id))
->where('workflow_context->operator_organization_id', $grant->organization_id);
} elseif ($grant->organization_id !== null) {
$normal->where('organization_id', $grant->organization_id);
} else {
$normal->whereNull('organization_id')->where('owner_user_id', $actor->id);
}
});
}
});
}
return $query->where(function ($scope) use ($actor) {
$scope->whereRaw('1=0');
if ($actor->can('manage-portal') && ! $this->superuser($actor)) {
$scope->orWhereNull('owner_user_id');
}
foreach ($this->grants($actor) as $grant) {
if (! in_array($grant->role, ['promoter', 'validator', 'publisher'], true)) {
continue;
}
if ($grant->role === 'promoter' && $grant->source !== 'internal-portal-designation' && ! $this->represents($actor, $grant->organization_id)) {
continue;
}
if ($grant->role === 'promoter' && $grant->source === 'internal-portal-designation' && app(PortalInternalDesignation::class)->organization($grant->organization_id) === null) {
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->role === 'promoter' && $grant->source === 'internal-portal-designation') {
$allowed->where(fn ($q) => $q->where('owner_user_id', $actor->id)->orWhereJsonContains('workflow_context->contributors', $actor->id))
->where('workflow_context->operator_organization_id', $grant->organization_id);
} elseif ($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->governanceAvailable() || ! $actor->hasVerifiedEmail()) {
return null;
}
$contexts = [];
$seen = [];
$internalPromoter = false;
foreach ($this->grants($actor, 'promoter') as $grant) {
if ($grant->source === 'internal-portal-designation' && app(PortalInternalDesignation::class)->organization($grant->organization_id) !== null) {
$internalPromoter = true;
foreach (DB::table('organizations')->whereNull('deleted_at')->get(['id', 'name']) as $realPromoter) {
foreach (json_decode($grant->opportunity_types, true) as $type) {
$key = $realPromoter->id.'|'.$type;
if (! isset($seen[$key]) && $this->canCreate($actor, $realPromoter->id, $type)) {
$contexts[] = ['organization_id' => $realPromoter->id, 'organization_name' => $realPromoter->name, 'type' => $type];
$seen[$key] = true;
}
}
}
continue;
}
if (! $this->represents($actor, $grant->organization_id)) {
continue;
}
foreach (json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR) as $type) {
$key = ($grant->organization_id ?? 'individual').'|'.$type;
if (! isset($seen[$key]) && $this->canCreate($actor, $grant->organization_id, $type)) {
$contexts[] = ['organization_id' => $grant->organization_id,
'organization_name' => $grant->organization_id ? DB::table('organizations')->where('id', $grant->organization_id)->value('name') : null, 'type' => $type];
$seen[$key] = true;
}
}
}
return ['contexts' => $contexts, 'internalPromoter' => $internalPromoter, 'mine' => $contexts ? '/minhas-oportunidades' : null, 'create' => $contexts ? '/minhas-oportunidades/create' : null,
'request' => '/perfil/promotor', 'review' => $this->available() && ($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();
}
}
}