## Summary - introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources - harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access - add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics ## Included - Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/` - shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver` - shared Filament concern: `InteractsWithTenantOwnedRecords` - resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups - additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails ## Validation - `vendor/bin/sail artisan test --compact` passed - full suite result: `2733 passed, 8 skipped` - formatting applied with `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Livewire v4.0+ compliant via existing Filament v5 stack - provider registration remains in `bootstrap/providers.php` - globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled - destructive actions continue to use confirmation and policy authorization - no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #180
144 lines
5.9 KiB
PHP
144 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
use Closure;
|
|
use Filament\Actions;
|
|
use Filament\Resources\RelationManagers\RelationManager;
|
|
use Filament\Tables;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
class BackupScheduleOperationRunsRelationManager extends RelationManager
|
|
{
|
|
protected static string $relationship = 'operationRuns';
|
|
|
|
protected static ?string $title = 'Executions';
|
|
|
|
/**
|
|
* @param array<string, mixed> $arguments
|
|
* @param array<string, mixed> $context
|
|
*/
|
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
|
{
|
|
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
|
|
$this->resolveOwnerScopedOperationRun($context['recordKey']);
|
|
}
|
|
|
|
return parent::mountAction($name, $arguments, $context);
|
|
}
|
|
|
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
{
|
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
|
->exempt(ActionSurfaceSlot::ListHeader, 'Executions relation list has no header actions.')
|
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Single primary row action is exposed, so no row menu is needed.')
|
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for operation run safety.')
|
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No empty-state actions are exposed in this embedded relation manager.');
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
return $table
|
|
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
|
|
->defaultSort('created_at', 'desc')
|
|
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
|
->columns([
|
|
Tables\Columns\TextColumn::make('created_at')
|
|
->label('Enqueued')
|
|
->dateTime()
|
|
->sortable(),
|
|
|
|
Tables\Columns\TextColumn::make('type')
|
|
->label('Type')
|
|
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
|
|
|
Tables\Columns\TextColumn::make('status')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
|
|
|
Tables\Columns\TextColumn::make('outcome')
|
|
->badge()
|
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
|
|
|
Tables\Columns\TextColumn::make('counts')
|
|
->label('Counts')
|
|
->getStateUsing(function (OperationRun $record): string {
|
|
$counts = is_array($record->summary_counts) ? $record->summary_counts : [];
|
|
|
|
$total = (int) ($counts['total'] ?? 0);
|
|
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
|
$failed = (int) ($counts['failed'] ?? 0);
|
|
|
|
if ($total === 0 && $succeeded === 0 && $failed === 0) {
|
|
return '—';
|
|
}
|
|
|
|
return sprintf('%d/%d (%d failed)', $succeeded, $total, $failed);
|
|
}),
|
|
])
|
|
->filters([])
|
|
->headerActions([])
|
|
->actions([
|
|
Actions\Action::make('view')
|
|
->label('View')
|
|
->icon('heroicon-o-eye')
|
|
->url(function (OperationRun $record): string {
|
|
$record = $this->resolveOwnerScopedOperationRun($record);
|
|
$tenant = Tenant::currentOrFail();
|
|
|
|
return OperationRunLinks::view($record, $tenant);
|
|
})
|
|
->openUrlInNewTab(true),
|
|
])
|
|
->bulkActions([])
|
|
->emptyStateHeading('No schedule runs yet')
|
|
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
|
}
|
|
|
|
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
|
{
|
|
$recordId = $record instanceof OperationRun
|
|
? (int) $record->getKey()
|
|
: (is_numeric($record) ? (int) $record : 0);
|
|
|
|
if ($recordId <= 0) {
|
|
abort(404);
|
|
}
|
|
|
|
$resolvedRecord = $this->getOwnerRecord()
|
|
->operationRuns()
|
|
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
|
->whereKey($recordId)
|
|
->first();
|
|
|
|
if (! $resolvedRecord instanceof OperationRun) {
|
|
abort(404);
|
|
}
|
|
|
|
return $resolvedRecord;
|
|
}
|
|
|
|
public static function formatOperationType(?string $state): string
|
|
{
|
|
return OperationCatalog::label($state);
|
|
}
|
|
}
|