Automated PR created via MCP by Copilot on user request: "pr gegen platform-dev". Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #332
269 lines
9.7 KiB
PHP
269 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\PlatformUser;
|
|
use App\Models\SupportAccessGrant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Audit\AuditActorType;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Auth\PlatformCapabilities;
|
|
use App\Support\Auth\WorkspaceRole;
|
|
use Carbon\CarbonImmutable;
|
|
use DomainException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class SupportAccessGrantManager
|
|
{
|
|
public function __construct(
|
|
private readonly SupportAccessGrantResolver $resolver,
|
|
private readonly WorkspaceAuditLogger $auditLogger,
|
|
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
|
private readonly BreakGlassSession $breakGlassSession,
|
|
) {}
|
|
|
|
public function request(
|
|
Workspace $workspace,
|
|
PlatformUser $actor,
|
|
string $scope,
|
|
string $reason,
|
|
int $ttlMinutes,
|
|
?string $waiverReason = null,
|
|
): SupportAccessGrant {
|
|
$this->authorizePlatformActor($actor);
|
|
|
|
$scope = $this->resolver->validateScope($scope);
|
|
$reason = $this->validatedText($reason, 'reason');
|
|
$ttlMinutes = $this->validatedTtl($ttlMinutes);
|
|
$approvalMode = $this->resolver->approvalModeFor($workspace, $scope);
|
|
$waiverReason = $waiverReason !== null ? trim($waiverReason) : null;
|
|
|
|
if ($approvalMode === SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
|
if (! $this->breakGlassSession->isActive()) {
|
|
throw ValidationException::withMessages([
|
|
'scope' => 'Ownerless workspace recovery requires active break-glass mode.',
|
|
]);
|
|
}
|
|
|
|
$waiverReason = $this->validatedText((string) $waiverReason, 'waiver_reason');
|
|
}
|
|
|
|
if ($approvalMode !== SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
|
$waiverReason = null;
|
|
}
|
|
|
|
$this->resolver->expireStaleActiveGrants($workspace);
|
|
|
|
$existing = SupportAccessGrant::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('requested_by_platform_user_id', (int) $actor->getKey())
|
|
->where('scope', $scope)
|
|
->open()
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($existing instanceof SupportAccessGrant) {
|
|
return $existing;
|
|
}
|
|
|
|
return DB::transaction(function () use ($workspace, $actor, $scope, $reason, $ttlMinutes, $approvalMode, $waiverReason): SupportAccessGrant {
|
|
$now = CarbonImmutable::now();
|
|
$activeImmediately = in_array($approvalMode, [
|
|
SupportAccessGrant::APPROVAL_MODE_AUTO,
|
|
SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER,
|
|
], true);
|
|
|
|
$grant = SupportAccessGrant::query()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'requested_by_platform_user_id' => (int) $actor->getKey(),
|
|
'approved_by_user_id' => null,
|
|
'scope' => $scope,
|
|
'status' => $activeImmediately
|
|
? SupportAccessGrant::STATUS_ACTIVE
|
|
: SupportAccessGrant::STATUS_REQUESTED,
|
|
'approval_mode' => $approvalMode,
|
|
'reason' => $reason,
|
|
'waiver_reason' => $waiverReason,
|
|
'ttl_minutes' => $ttlMinutes,
|
|
'requested_at' => $now,
|
|
'approved_at' => null,
|
|
'starts_at' => $activeImmediately ? $now : null,
|
|
'expires_at' => $activeImmediately ? $now->addMinutes($ttlMinutes) : null,
|
|
]);
|
|
|
|
$this->audit($grant, AuditActionId::SupportAccessRequested, $actor, 'Support access requested for '.$workspace->name);
|
|
|
|
if ($approvalMode === SupportAccessGrant::APPROVAL_MODE_OWNERLESS_WAIVER) {
|
|
$this->audit($grant, AuditActionId::SupportAccessOwnerlessWaiverUsed, $actor, 'Ownerless support-access waiver used for '.$workspace->name);
|
|
}
|
|
|
|
if ($activeImmediately) {
|
|
$this->audit($grant, AuditActionId::SupportAccessActivated, $actor, 'Support access activated for '.$workspace->name);
|
|
}
|
|
|
|
return $grant;
|
|
});
|
|
}
|
|
|
|
public function approve(SupportAccessGrant $grant, User $actor): SupportAccessGrant
|
|
{
|
|
$this->authorizeWorkspaceOwnerApproval($grant, $actor);
|
|
|
|
if ($grant->status !== SupportAccessGrant::STATUS_REQUESTED) {
|
|
throw new DomainException('Only pending support-access requests may be approved.');
|
|
}
|
|
|
|
if ($grant->scope !== SupportAccessGrant::SCOPE_WORKSPACE_RECOVERY) {
|
|
throw new DomainException('Only recovery support-access requests require owner approval.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
|
$now = CarbonImmutable::now();
|
|
|
|
$grant->forceFill([
|
|
'status' => SupportAccessGrant::STATUS_ACTIVE,
|
|
'approved_by_user_id' => (int) $actor->getKey(),
|
|
'approved_at' => $now,
|
|
'starts_at' => $now,
|
|
'expires_at' => $now->addMinutes((int) $grant->ttl_minutes),
|
|
])->save();
|
|
|
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessApproved, $actor, 'Support access approved for '.$grant->workspace->name);
|
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessActivated, $actor, 'Support access activated for '.$grant->workspace->name);
|
|
|
|
return $grant->fresh();
|
|
});
|
|
}
|
|
|
|
public function deny(SupportAccessGrant $grant, User $actor): SupportAccessGrant
|
|
{
|
|
$this->authorizeWorkspaceOwnerApproval($grant, $actor);
|
|
|
|
if ($grant->status !== SupportAccessGrant::STATUS_REQUESTED) {
|
|
throw new DomainException('Only pending support-access requests may be denied.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
|
$grant->forceFill([
|
|
'status' => SupportAccessGrant::STATUS_DENIED,
|
|
'denied_at' => CarbonImmutable::now(),
|
|
])->save();
|
|
|
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessDenied, $actor, 'Support access denied for '.$grant->workspace->name);
|
|
|
|
return $grant->fresh();
|
|
});
|
|
}
|
|
|
|
public function end(SupportAccessGrant $grant, PlatformUser $actor): SupportAccessGrant
|
|
{
|
|
$this->authorizePlatformActor($actor);
|
|
$this->resolver->expireStaleActiveGrants($grant->workspace);
|
|
|
|
$grant = $grant->fresh();
|
|
|
|
if (! $grant instanceof SupportAccessGrant || $grant->status !== SupportAccessGrant::STATUS_ACTIVE) {
|
|
throw new DomainException('Only active support-access grants may be ended.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($grant, $actor): SupportAccessGrant {
|
|
$grant->forceFill([
|
|
'status' => SupportAccessGrant::STATUS_ENDED,
|
|
'ended_at' => CarbonImmutable::now(),
|
|
])->save();
|
|
|
|
$this->audit($grant->fresh(), AuditActionId::SupportAccessEnded, $actor, 'Support access ended for '.$grant->workspace->name);
|
|
|
|
return $grant->fresh();
|
|
});
|
|
}
|
|
|
|
private function authorizePlatformActor(PlatformUser $actor): void
|
|
{
|
|
if (! $actor->hasCapability(PlatformCapabilities::SUPPORT_ACCESS_MANAGE)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function authorizeWorkspaceOwnerApproval(SupportAccessGrant $grant, User $actor): void
|
|
{
|
|
$workspace = $grant->workspace;
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->workspaceCapabilities->isMember($actor, $workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->workspaceCapabilities->can($actor, $workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($this->workspaceCapabilities->getRole($actor, $workspace) !== WorkspaceRole::Owner) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function validatedText(string $value, string $field): string
|
|
{
|
|
$trimmed = trim($value);
|
|
|
|
if (mb_strlen($trimmed) < 5) {
|
|
throw ValidationException::withMessages([
|
|
$field => 'Enter at least 5 characters.',
|
|
]);
|
|
}
|
|
|
|
if (mb_strlen($trimmed) > 500) {
|
|
throw ValidationException::withMessages([
|
|
$field => 'Enter no more than 500 characters.',
|
|
]);
|
|
}
|
|
|
|
return $trimmed;
|
|
}
|
|
|
|
private function validatedTtl(int $ttlMinutes): int
|
|
{
|
|
$max = $this->resolver->maxTtlMinutes();
|
|
|
|
if ($ttlMinutes < 1 || $ttlMinutes > $max) {
|
|
throw ValidationException::withMessages([
|
|
'ttl_minutes' => 'TTL must be between 1 and '.$max.' minutes.',
|
|
]);
|
|
}
|
|
|
|
return $ttlMinutes;
|
|
}
|
|
|
|
private function audit(SupportAccessGrant $grant, AuditActionId $action, User|PlatformUser $actor, string $summary): void
|
|
{
|
|
$workspace = $grant->workspace;
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return;
|
|
}
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $workspace,
|
|
action: $action,
|
|
context: $this->resolver->auditContext($grant),
|
|
actor: $actor,
|
|
status: 'success',
|
|
resourceType: 'support_access_grant',
|
|
resourceId: (string) $grant->getKey(),
|
|
actorType: $actor instanceof PlatformUser ? AuditActorType::Platform : null,
|
|
targetLabel: $this->resolver->targetLabel($grant),
|
|
summary: $summary,
|
|
);
|
|
}
|
|
}
|