TenantAtlas/apps/platform/app/Filament/System/Pages/RepairWorkspaceOwners.php
ahmido 1e0f21365b PR: 276-support-access-governance → platform-dev (#332)
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
2026-05-05 21:54:26 +00:00

326 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\System\Pages;
use App\Filament\System\Widgets\RepairWorkspaceOwnersStats;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Models\SupportAccessGrant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Services\Auth\SupportAccessGrantResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Auth\WorkspaceRole;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\ValidationException;
class RepairWorkspaceOwners extends Page implements HasTable
{
use InteractsWithTable;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
protected static ?string $navigationLabel = 'Repair workspace owners';
protected static string|\UnitEnum|null $navigationGroup = 'Recovery';
protected string $view = 'filament.system.pages.repair-workspace-owners';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::USE_BREAK_GLASS);
}
public static function getNavigationBadge(): ?string
{
$total = Workspace::query()->count();
$withOwners = WorkspaceMembership::query()
->where('role', WorkspaceRole::Owner->value)
->distinct('workspace_id')
->count('workspace_id');
$ownerless = $total - $withOwners;
return $ownerless > 0 ? (string) $ownerless : null;
}
public static function getNavigationBadgeColor(): string|array|null
{
return 'danger';
}
public function mount(): void
{
$this->mountInteractsWithTable();
}
/**
* @return array<class-string<Widget>|WidgetConfiguration>
*/
protected function getHeaderWidgets(): array
{
return [
RepairWorkspaceOwnersStats::class,
];
}
public function table(Table $table): Table
{
return $table
->heading('Workspaces')
->description('Current workspace ownership status.')
->defaultSort('name', 'asc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return Workspace::query()
->withCount([
'memberships as owner_count' => function (Builder $query): void {
$query->where('role', WorkspaceRole::Owner->value);
},
'memberships as member_count',
'tenants as tenant_count',
]);
})
->columns([
TextColumn::make('name')
->label('Workspace')
->searchable()
->sortable(),
TextColumn::make('owner_count')
->label('Owners')
->badge()
->color(fn (int $state): string => $state > 0 ? 'success' : 'danger')
->sortable(),
TextColumn::make('member_count')
->label('Members')
->sortable(),
TextColumn::make('tenant_count')
->label('Tenants')
->sortable(),
TextColumn::make('recovery_support_access')
->label('Recovery support')
->badge()
->getStateUsing(fn (Workspace $record): string => app(SupportAccessGrantResolver::class)->activeRecoveryGrantFor($record) instanceof SupportAccessGrant
? 'Active'
: 'Missing')
->color(fn (string $state): string => $state === 'Active' ? 'success' : 'warning'),
TextColumn::make('updated_at')
->label('Last activity')
->since()
->sortable(),
])
->emptyStateHeading('No workspaces')
->emptyStateDescription('No workspaces exist in the system yet.')
->bulkActions([]);
}
/**
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
*/
public function getRecentBreakGlassActions(): array
{
return $this->getRecentRecoveryGovernanceActions();
}
/**
* @return array<array{action: string, actor: string|null, workspace: string|null, recorded_at: string}>
*/
public function getRecentRecoveryGovernanceActions(): array
{
return AuditLog::query()
->where(function (Builder $query): void {
$query
->where('action', 'like', '%break_glass%')
->orWhere('action', 'like', 'support_access.%');
})
->orderByDesc('recorded_at')
->limit(10)
->get()
->map(fn (AuditLog $log): array => [
'action' => (string) $log->action,
'actor' => $log->actor_email ?: 'Unknown',
'workspace' => $log->metadata['metadata']['workspace_id'] ?? null
? Workspace::query()->whereKey((int) $log->metadata['metadata']['workspace_id'])->value('name')
: null,
'recorded_at' => $log->recorded_at?->diffForHumans() ?? 'Unknown',
])
->all();
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$breakGlass = app(BreakGlassSession::class);
return [
Action::make('assign_owner')
->label('Emergency: Assign Owner')
->icon('heroicon-o-shield-exclamation')
->color('danger')
->requiresConfirmation()
->modalHeading('Assign workspace owner')
->modalDescription('This is a recovery action. It requires active break-glass mode and an active workspace recovery support-access grant for the selected workspace.')
->form([
Select::make('workspace_id')
->label('Workspace')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Select::make('target_user_id')
->label('User')
->required()
->searchable()
->getSearchResultsUsing(function (string $search): array {
return User::query()
->where('email', 'like', "%{$search}%")
->orderBy('email')
->limit(25)
->pluck('email', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return User::query()->whereKey((int) $value)->value('email');
}),
Textarea::make('reason')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, BreakGlassSession $breakGlass, WorkspaceAuditLogger $auditLogger): void {
$platformUser = auth('platform')->user();
if (! $platformUser instanceof PlatformUser) {
abort(403);
}
if (! $platformUser->hasCapability(PlatformCapabilities::USE_BREAK_GLASS)) {
abort(403);
}
$workspaceId = (int) ($data['workspace_id'] ?? 0);
$targetUserId = (int) ($data['target_user_id'] ?? 0);
$reason = (string) ($data['reason'] ?? '');
$workspace = Workspace::query()->whereKey($workspaceId)->firstOrFail();
$targetUser = User::query()->whereKey($targetUserId)->firstOrFail();
$supportAccessResolver = app(SupportAccessGrantResolver::class);
$supportGrant = $supportAccessResolver->activeRecoveryGrantFor($workspace);
if (! $breakGlass->isActive()) {
throw ValidationException::withMessages([
'workspace_id' => 'Activate break-glass mode before assigning a workspace owner.',
]);
}
if (! $supportGrant instanceof SupportAccessGrant) {
throw ValidationException::withMessages([
'workspace_id' => 'Active workspace recovery support access is required before assigning a workspace owner.',
]);
}
$membership = WorkspaceMembership::query()->firstOrNew([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $targetUser->getKey(),
]);
$fromRole = $membership->exists ? (string) $membership->role : null;
$membership->forceFill([
'role' => WorkspaceRole::Owner->value,
])->save();
$auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceMembershipBreakGlassAssignOwner->value,
context: [
'metadata' => [
'workspace_id' => (int) $workspace->getKey(),
'actor_user_id' => (int) $platformUser->getKey(),
'target_user_id' => (int) $targetUser->getKey(),
'attempted_role' => WorkspaceRole::Owner->value,
'from_role' => $fromRole,
'reason' => trim($reason),
'source' => 'break_glass',
'support_access_grant_id' => (int) $supportGrant->getKey(),
'support_access_scope' => (string) $supportGrant->scope,
'support_access_expires_at' => $supportGrant->expires_at?->toISOString(),
],
],
actor: null,
status: 'success',
resourceType: 'workspace',
resourceId: (string) $workspace->getKey(),
actorId: (int) $platformUser->getKey(),
actorEmail: $platformUser->email,
actorName: $platformUser->name,
);
Notification::make()
->title('Owner assigned')
->success()
->send();
})
->disabled(fn (): bool => ! $breakGlass->isActive())
->tooltip(fn (): ?string => ! $breakGlass->isActive() ? 'Activate break-glass mode on the Dashboard first. Workspace recovery support access is also required for the selected workspace.' : null),
];
}
public function recoveryBoundarySummary(): string
{
$breakGlass = app(BreakGlassSession::class);
if (! $breakGlass->isActive()) {
return 'Blocked: break-glass mode is inactive. Activate break-glass on the system dashboard and confirm the selected workspace has active recovery-scoped support access.';
}
return 'Break-glass mode is active. The selected workspace must still have active recovery-scoped support access before owner repair can execute.';
}
}