TenantAtlas/apps/platform/app/Services/Auth/SupportAccessGrantManager.php
Ahmed Darrazi 37105653a1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m29s
feat(spec-276): implement support access governance — commit all changes
2026-05-05 22:17:14 +02:00

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,
);
}
}