Harden nested Filament Livewire context contract
This commit is contained in:
parent
0c7adefe5b
commit
a21635ee73
@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationCatalog;
|
||||
@ -16,7 +16,6 @@
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupScheduleOperationRunsRelationManager extends RelationManager
|
||||
{
|
||||
@ -37,12 +36,11 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey()))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
|
||||
->recordUrl(function (OperationRun $record): string {
|
||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
||||
$tenant = ManagedEnvironment::currentOrFail();
|
||||
$tenant = $this->resolveOwnerTenantOrFail();
|
||||
|
||||
return OperationRunLinks::view($record, $tenant);
|
||||
})
|
||||
@ -106,7 +104,6 @@ private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
|
||||
$resolvedRecord = $this->getOwnerRecord()
|
||||
->operationRuns()
|
||||
->where('managed_environment_id', ManagedEnvironment::currentOrFail()->getKey())
|
||||
->whereKey($recordId)
|
||||
->first();
|
||||
|
||||
@ -117,6 +114,35 @@ private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private function resolveOwnerTenantOrFail(): ManagedEnvironment
|
||||
{
|
||||
$ownerRecord = $this->getOwnerRecord();
|
||||
|
||||
if (
|
||||
is_object($ownerRecord)
|
||||
&& method_exists($ownerRecord, 'getRelationValue')
|
||||
&& method_exists($ownerRecord, 'getAttribute')
|
||||
) {
|
||||
$tenant = $ownerRecord->getRelationValue('tenant');
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
$tenantId = $ownerRecord->getAttribute('managed_environment_id');
|
||||
|
||||
if (is_numeric($tenantId)) {
|
||||
$tenant = ManagedEnvironment::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
public static function formatOperationType(?string $state): string
|
||||
{
|
||||
return OperationCatalog::label($state);
|
||||
|
||||
@ -12,10 +12,10 @@
|
||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContext;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextResolver;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\PortfolioTriage\ManagedEnvironmentTriageReviewStateResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||
use Filament\Actions\Action;
|
||||
@ -26,12 +26,15 @@
|
||||
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||
use Filament\Schemas\Contracts\HasSchemas;
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ManagedEnvironmentTriageArrivalContinuity extends Widget implements HasActions, HasSchemas
|
||||
{
|
||||
use InteractsWithActions;
|
||||
use InteractsWithSchemas;
|
||||
|
||||
public ?Model $record = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
@ -66,6 +69,14 @@ public function mount(): void
|
||||
$this->arrivalState = PortfolioArrivalContextToken::decode(
|
||||
request()->query(PortfolioArrivalContextToken::QUERY_PARAMETER),
|
||||
);
|
||||
|
||||
if ($this->record === null) {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
$this->record = $tenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,7 +84,7 @@ public function mount(): void
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->resolveManagedEnvironment();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return ['context' => null, 'reviewState' => null];
|
||||
@ -133,7 +144,7 @@ public function markFollowUpNeededAction(): Action
|
||||
|
||||
private function canShowReviewActions(): bool
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->resolveManagedEnvironment();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
@ -151,7 +162,7 @@ private function canShowReviewActions(): bool
|
||||
private function reviewModalDescription(string $targetManualState): \Closure
|
||||
{
|
||||
return function () use ($targetManualState): string {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->resolveManagedEnvironment();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return 'This triage session is no longer available.';
|
||||
@ -186,7 +197,7 @@ private function reviewModalDescription(string $targetManualState): \Closure
|
||||
|
||||
private function handleReviewMutation(string $targetManualState, ManagedEnvironmentTriageReviewService $service): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = $this->resolveManagedEnvironment();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return;
|
||||
@ -254,6 +265,17 @@ private function handleReviewMutation(string $targetManualState, ManagedEnvironm
|
||||
->send();
|
||||
}
|
||||
|
||||
private function resolveManagedEnvironment(): ?ManagedEnvironment
|
||||
{
|
||||
if ($this->record instanceof ManagedEnvironment) {
|
||||
return $this->record;
|
||||
}
|
||||
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
|
||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -18,6 +19,7 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
@ -26,7 +28,6 @@
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BackupSetPolicyPickerTable extends TableComponent
|
||||
@ -59,17 +60,27 @@ public static function externalIdShort(?string $externalId): string
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$backupSet = BackupSet::query()->find($this->backupSetId);
|
||||
$tenantId = $backupSet?->managed_environment_id ?? ManagedEnvironment::currentOrFail()->getKey();
|
||||
$existingPolicyIds = $backupSet
|
||||
? $backupSet->items()->pluck('policy_id')->filter()->all()
|
||||
: [];
|
||||
$context = $this->resolveBackupSetContext();
|
||||
$existingPolicyIds = [];
|
||||
$tenantId = $context !== null ? (int) $context['tenant']->getKey() : null;
|
||||
|
||||
if ($context !== null) {
|
||||
$existingPolicyIds = $context['backupSet']
|
||||
->items()
|
||||
->pluck('policy_id')
|
||||
->filter()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $table
|
||||
->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId))
|
||||
->query(
|
||||
Policy::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->when(
|
||||
$context !== null,
|
||||
fn (Builder $query) => $query->where('managed_environment_id', (int) $context['tenant']->getKey()),
|
||||
fn (Builder $query) => $query->whereRaw('1 = 0'),
|
||||
)
|
||||
->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds))
|
||||
)
|
||||
->deferLoading(! app()->runningUnitTests())
|
||||
@ -141,13 +152,19 @@ public function table(Table $table): Table
|
||||
->options(static::policyTypeOptions()),
|
||||
SelectFilter::make('platform')
|
||||
->label('Platform')
|
||||
->options(fn (): array => Policy::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->whereNotNull('platform')
|
||||
->distinct()
|
||||
->orderBy('platform')
|
||||
->pluck('platform', 'platform')
|
||||
->all()),
|
||||
->options(function () use ($tenantId): array {
|
||||
if (! is_int($tenantId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Policy::query()
|
||||
->where('managed_environment_id', $tenantId)
|
||||
->whereNotNull('platform')
|
||||
->distinct()
|
||||
->orderBy('platform')
|
||||
->pluck('platform', 'platform')
|
||||
->all();
|
||||
}),
|
||||
SelectFilter::make('synced_within')
|
||||
->label('Last synced')
|
||||
->options([
|
||||
@ -219,77 +236,20 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-m-plus')
|
||||
->authorize(function (): bool {
|
||||
$this->dispatch(OpsUxBrowserEvents::RunEnqueued);
|
||||
$user = auth()->user();
|
||||
$context = $this->resolveBackupSetContext(requireSyncCapability: true);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$tenant = ManagedEnvironment::current();
|
||||
} catch (\RuntimeException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return BackupSet::query()
|
||||
->whereKey($this->backupSetId)
|
||||
->where('managed_environment_id', $tenant->getKey())
|
||||
->exists();
|
||||
return $context !== null;
|
||||
})
|
||||
->action(function (Collection $records): void {
|
||||
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
||||
$tenant = null;
|
||||
$context = $this->resolveBackupSetContext(requireSyncCapability: true);
|
||||
|
||||
try {
|
||||
$tenant = ManagedEnvironment::current();
|
||||
} catch (\RuntimeException) {
|
||||
$tenant = $backupSet->tenant;
|
||||
}
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
if ($context === null) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $tenant->getKey() !== (int) $backupSet->managed_environment_id) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||
Notification::make()
|
||||
->title('Not allowed')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
$user = $context['user'];
|
||||
$tenant = $context['tenant'];
|
||||
$backupSet = $context['backupSet'];
|
||||
|
||||
$policyIds = $records
|
||||
->pluck('id')
|
||||
@ -324,6 +284,21 @@ public function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$validatedPolicyIds = Policy::query()
|
||||
->where('managed_environment_id', (int) $tenant->getKey())
|
||||
->whereIn('id', $policyIds)
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
sort($validatedPolicyIds);
|
||||
|
||||
if ($validatedPolicyIds !== $policyIds) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($policyIds);
|
||||
@ -433,4 +408,53 @@ private static function policyTypeOptions(): array
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user: User, tenant: ManagedEnvironment, backupSet: BackupSet}|null
|
||||
*/
|
||||
private function resolveBackupSetContext(bool $requireSyncCapability = false): ?array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::query()
|
||||
->with(['tenant'])
|
||||
->find($this->backupSetId);
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $backupSet->tenant;
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ambientTenant = Filament::getTenant();
|
||||
|
||||
if ($ambientTenant instanceof ManagedEnvironment && ! $ambientTenant->is($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($requireSyncCapability && ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'tenant' => $tenant,
|
||||
'backupSet' => $backupSet,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -363,9 +363,57 @@ private function resolveRouteTenantCandidate(?Request $request = null, ?AdminSur
|
||||
return $this->resolveTenantIdentifier($route->parameter('tenant'));
|
||||
}
|
||||
|
||||
$refererEnvironment = $this->resolveRefererTenantCandidate($request, $pageCategory);
|
||||
|
||||
if ($refererEnvironment instanceof ManagedEnvironment) {
|
||||
return $refererEnvironment;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveRefererTenantCandidate(?Request $request, AdminSurfaceScope $pageCategory): ?ManagedEnvironment
|
||||
{
|
||||
if (! $request instanceof Request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = '/'.ltrim((string) $request->path(), '/');
|
||||
|
||||
$isLivewireUpdateRequest = preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1
|
||||
|| $request->headers->has('x-livewire');
|
||||
|
||||
if (! $isLivewireUpdateRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pageCategory !== AdminSurfaceScope::EnvironmentBound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH);
|
||||
|
||||
if (! is_string($refererPath) || $refererPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedRefererPath = '/'.ltrim($refererPath, '/');
|
||||
|
||||
$matches = [];
|
||||
|
||||
if (preg_match('#^/admin/workspaces/[^/]+/environments/([^/]+)(?:/|$)#', $normalizedRefererPath, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$environmentIdentifier = $matches[1] ?? null;
|
||||
|
||||
if (! is_string($environmentIdentifier) || $environmentIdentifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolveTenantIdentifier($environmentIdentifier);
|
||||
}
|
||||
|
||||
private function resolveQueryTenantHint(?Request $request = null): ?ManagedEnvironment
|
||||
{
|
||||
if ($request instanceof Request && WorkspaceHubRegistry::isWorkspaceHubPath('/'.ltrim((string) $request->path(), '/'))) {
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
it('guards Filament::setTenant usage behind an explicit infrastructure allowlist', function (): void {
|
||||
$allowlist = [
|
||||
base_path('app/Support/OperateHub/OperateHubShell.php'),
|
||||
base_path('app/Support/Middleware/EnsureEnvironmentContextSelected.php'),
|
||||
base_path('app/Filament/Pages/Monitoring/Operations.php'),
|
||||
base_path('app/Http/Controllers/SwitchWorkspaceController.php'),
|
||||
base_path('app/Http/Controllers/ClearEnvironmentContextController.php'),
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach (File::allFiles(base_path('app')) as $file) {
|
||||
$realPath = $file->getRealPath();
|
||||
|
||||
if (! is_string($realPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = File::get($realPath);
|
||||
|
||||
if (! str_contains($contents, 'Filament::setTenant(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($realPath, $allowlist, true)) {
|
||||
$violations[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $realPath);
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
test()->fail("Unexpected Filament::setTenant usage detected:\n- ".implode("\n- ", $violations));
|
||||
}
|
||||
|
||||
expect($violations)->toBeEmpty();
|
||||
});
|
||||
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
function spec334SmokeLoginUrl(User $user, ManagedEnvironment $tenant, string $redirect = ''): string
|
||||
{
|
||||
$tenant->loadMissing('workspace');
|
||||
|
||||
$redirect = trim($redirect);
|
||||
|
||||
if ($redirect !== '') {
|
||||
$parsed = parse_url($redirect);
|
||||
|
||||
if (is_array($parsed) && ! isset($parsed['scheme'], $parsed['host'])) {
|
||||
$redirect = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
||||
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
||||
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
||||
$redirect = $redirect.$query.$fragment;
|
||||
} elseif (is_array($parsed)) {
|
||||
$redirect = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
||||
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
||||
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
||||
$redirect = $redirect.$query.$fragment;
|
||||
}
|
||||
}
|
||||
|
||||
return route('admin.local.smoke-login', array_filter([
|
||||
'email' => (string) $user->email,
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
'workspace' => (string) ($tenant->workspace?->slug ?? ''),
|
||||
'redirect' => $redirect,
|
||||
], static fn (?string $value): bool => filled($value)));
|
||||
}
|
||||
|
||||
it('smokes the backup set add policies picker without selection-column regressions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
config()->set('queue.default', 'database');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec334 Browser Backup Set',
|
||||
]);
|
||||
|
||||
Policy::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Spec334 Browser Policy A',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
Policy::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'display_name' => 'Spec334 Browser Policy B',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$backupSetUrl = BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'admin', tenant: $tenant);
|
||||
|
||||
$page = visit(spec334SmokeLoginUrl($user, $tenant, $backupSetUrl))
|
||||
->waitForText('Spec334 Browser Backup Set')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Related context');
|
||||
|
||||
$page->script('window.scrollTo(0, document.body.scrollHeight);');
|
||||
|
||||
$page
|
||||
->waitForText('Add Policies')
|
||||
->click('Add Policies')
|
||||
->waitForText('Spec334 Browser Policy A')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
expect($page->script(<<<'JS'
|
||||
(() => {
|
||||
const visibleTables = Array.from(document.querySelectorAll('.fi-ta')).filter((table) => table && table.offsetParent !== null);
|
||||
const table = visibleTables.at(-1);
|
||||
|
||||
if (!table) {
|
||||
return { selectable: 0, total: 0 };
|
||||
}
|
||||
|
||||
const rows = Array.from(table.querySelectorAll('tbody tr.fi-ta-row'));
|
||||
const selectable = rows.filter((row) => row.querySelector('input[type="checkbox"]:not([disabled])')).length;
|
||||
|
||||
return { selectable, total: rows.length };
|
||||
})();
|
||||
JS))->toMatchArray([
|
||||
'total' => 2,
|
||||
'selectable' => 2,
|
||||
]);
|
||||
|
||||
$page
|
||||
->click('.fi-ta:visible tbody tr.fi-ta-row:has-text("Spec334 Browser Policy A") input[type="checkbox"]')
|
||||
->waitForText('Add selected')
|
||||
->click('Add selected')
|
||||
->waitForText('Backup set update queued')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('smokes the restore run create wizard without tenantless Livewire update crashes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Spec334 Restore Fixture Backup Set',
|
||||
]);
|
||||
|
||||
$createUrl = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant)
|
||||
.'?backup_set_id='.(int) $backupSet->getKey();
|
||||
|
||||
visit(spec334SmokeLoginUrl($user, $tenant, $createUrl))
|
||||
->waitForText('Select Backup Set')
|
||||
->waitForText('Backup set')
|
||||
->assertDontSee('No tenant context selected')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('button:has-text("Next")')
|
||||
->waitForText('Define Restore Scope')
|
||||
->assertDontSee('No tenant context selected')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
@ -56,6 +56,52 @@ function makeBackupScheduleForTenant(\App\Models\ManagedEnvironment $tenant, str
|
||||
->assertCanNotSeeTableRecords([$runB]);
|
||||
});
|
||||
|
||||
it('renders operation runs when ambient Filament tenant context is missing', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext(null);
|
||||
|
||||
$schedule = makeBackupScheduleForTenant($tenant, 'Schedule A');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => 'backup_schedule_run',
|
||||
'context' => ['backup_schedule_id' => (int) $schedule->getKey()],
|
||||
]);
|
||||
|
||||
Livewire::test(BackupScheduleOperationRunsRelationManager::class, [
|
||||
'ownerRecord' => $schedule,
|
||||
'pageClass' => EditBackupSchedule::class,
|
||||
])
|
||||
->assertCanSeeTableRecords([$run]);
|
||||
});
|
||||
|
||||
it('does not broaden scope when ambient Filament tenant context is wrong', function (): void {
|
||||
$tenantA = \App\Models\ManagedEnvironment::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = \App\Models\ManagedEnvironment::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext($tenantB);
|
||||
|
||||
$scheduleA = makeBackupScheduleForTenant($tenantA, 'Schedule A');
|
||||
|
||||
$runA = OperationRun::factory()->forTenant($tenantA)->create([
|
||||
'type' => 'backup_schedule_run',
|
||||
'context' => ['backup_schedule_id' => (int) $scheduleA->getKey()],
|
||||
]);
|
||||
|
||||
Livewire::test(BackupScheduleOperationRunsRelationManager::class, [
|
||||
'ownerRecord' => $scheduleA,
|
||||
'pageClass' => EditBackupSchedule::class,
|
||||
])
|
||||
->assertCanSeeTableRecords([$runA]);
|
||||
});
|
||||
|
||||
it('returns 404 when a forged same-tenant run key is mounted on the wrong schedule relation manager', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||
use App\Livewire\BackupSetPolicyPickerTable;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -81,6 +81,39 @@
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup set update queued');
|
||||
});
|
||||
|
||||
test('policy picker remains usable when Filament tenant context is missing (authorized operator)', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
setAdminPanelContext(null);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => $tenant->id,
|
||||
'name' => 'Tenantless context backup',
|
||||
]);
|
||||
|
||||
$policies = Policy::factory()->count(1)->create([
|
||||
'managed_environment_id' => $tenant->id,
|
||||
'ignored_at' => null,
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
bindFailHardGraphClient();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetPolicyPickerTable::class, [
|
||||
'backupSetId' => $backupSet->id,
|
||||
])
|
||||
->assertTableBulkActionVisible('add_selected_to_backup_set')
|
||||
->assertCanSeeTableRecords($policies)
|
||||
->callTableBulkAction('add_selected_to_backup_set', $policies)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1);
|
||||
});
|
||||
|
||||
test('policy picker keeps provider-missing policies visible but blocks add run creation', function () {
|
||||
Queue::fake();
|
||||
|
||||
@ -273,10 +306,7 @@
|
||||
});
|
||||
|
||||
test('policy picker table can filter by has versions', function () {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$user = User::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'managed_environment_id' => $tenant->id,
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\ManagedEnvironment\ManagedEnvironmentTriageArrivalContinuity;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
uses(BuildsPortfolioTriageFixtures::class);
|
||||
|
||||
it('uses the widget record context when Filament tenant context is missing', function (): void {
|
||||
[$user, $tenant] = $this->makePortfolioTriageActor('Record context tenant', workspaceRole: 'owner');
|
||||
|
||||
$this->seedPortfolioBackupConcern($tenant, TenantBackupHealthAssessment::POSTURE_STALE);
|
||||
|
||||
$this->actingAs($user);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
setAdminPanelContext(null);
|
||||
request()->attributes->remove('portfolio_triage.arrival_context');
|
||||
|
||||
$arrivalState = [
|
||||
'sourceSurface' => PortfolioArrivalContextToken::SOURCE_TENANT_REGISTRY,
|
||||
'tenantRouteKey' => (string) $tenant->external_id,
|
||||
'workspaceId' => (int) $tenant->workspace_id,
|
||||
'concernFamily' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
||||
'concernState' => TenantBackupHealthAssessment::POSTURE_STALE,
|
||||
'concernReason' => TenantBackupHealthAssessment::REASON_LATEST_BACKUP_STALE,
|
||||
];
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
PortfolioArrivalContextToken::QUERY_PARAMETER => PortfolioArrivalContextToken::encode($arrivalState),
|
||||
])->actingAs($user)->test(ManagedEnvironmentTriageArrivalContinuity::class, [
|
||||
'record' => $tenant,
|
||||
]);
|
||||
|
||||
$instance = $component->instance();
|
||||
|
||||
$method = new ReflectionMethod($instance, 'getViewData');
|
||||
$method->setAccessible(true);
|
||||
$viewData = $method->invoke($instance);
|
||||
|
||||
expect($viewData['context'] ?? null)->not->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('does not crash when restore create wizard Livewire update loses route parameters', function (): void {
|
||||
[$user, $tenant] = createMinimalUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
BackupSet::factory()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Restore fixture backup set',
|
||||
]);
|
||||
|
||||
$createUrl = RestoreRunResource::getUrl('create', panel: 'admin', tenant: $tenant);
|
||||
|
||||
$response = $this
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get($createUrl);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$html = (string) $response->getContent();
|
||||
|
||||
preg_match_all('/wire:snapshot="([^"]+)"/', $html, $snapshotMatches);
|
||||
|
||||
$snapshots = $snapshotMatches[1] ?? [];
|
||||
|
||||
expect($snapshots)->not->toBeEmpty('No Livewire snapshot found in restore create HTML');
|
||||
|
||||
$expectedPath = sprintf(
|
||||
'admin/workspaces/%s/environments/%s/restore-runs/create',
|
||||
$tenant->workspace_id,
|
||||
$tenant->getRouteKey(),
|
||||
);
|
||||
|
||||
$snapshotJson = null;
|
||||
|
||||
foreach ($snapshots as $encodedSnapshot) {
|
||||
$candidateJson = htmlspecialchars_decode($encodedSnapshot);
|
||||
$candidate = json_decode($candidateJson, true);
|
||||
|
||||
if (! is_array($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($candidate['memo']['path'] ?? null) === $expectedPath) {
|
||||
$snapshotJson = $candidateJson;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($snapshotJson)->toBeString("No snapshot with memo.path [$expectedPath] found.");
|
||||
|
||||
$updatePayload = [
|
||||
'components' => [[
|
||||
'snapshot' => $snapshotJson,
|
||||
'updates' => new stdClass,
|
||||
'calls' => [],
|
||||
]],
|
||||
];
|
||||
|
||||
$updateUri = '/'.collect(app('router')->getRoutes()->getRoutes())
|
||||
->first(fn ($route): bool => str_contains((string) $route->getName(), 'livewire.update'))
|
||||
?->uri();
|
||||
|
||||
expect($updateUri)->toBeString('Livewire update route must exist');
|
||||
|
||||
$updateResponse = $this
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->withHeaders([
|
||||
'X-Livewire' => 'true',
|
||||
'Referer' => $createUrl,
|
||||
])
|
||||
->postJson($updateUri, $updatePayload);
|
||||
|
||||
$updateResponse->assertSuccessful();
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
# Spec 334 Requirements Checklist
|
||||
|
||||
- Purpose: Preparation-quality validation for Spec 334 before runtime implementation.
|
||||
- Created: 2026-05-24
|
||||
- Feature: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] Spec is focused on a concrete operator-visible defect class (nested context loss) and does not drift into a tenancy rewrite.
|
||||
- [x] Spec Candidate Check is filled and justifies why this is Core Enterprise work (correctness + blocker removal).
|
||||
- [x] Non-goals explicitly exclude routing/panel/RBAC rewrites and broad repo sweeps.
|
||||
- [x] Acceptance criteria are testable and bounded to confirmed surfaces.
|
||||
- [x] Preparation artifacts contain no runtime implementation code changes.
|
||||
|
||||
## Repo Truth
|
||||
|
||||
- [x] Confirmed scope file paths were verified as existing:
|
||||
- `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`
|
||||
- `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`
|
||||
- [x] Repo-truth deviation is documented: user draft references Spec 332 WIP, but `platform-dev` has no `specs/332-*` directory today.
|
||||
|
||||
## Security / Isolation / RBAC
|
||||
|
||||
- [x] Contract explicitly treats UI visibility as non-authorization and requires mutation-time rechecks.
|
||||
- [x] Fail-closed posture is explicit: no broadening, no silent tenant override, no cross-scope selection/attachment.
|
||||
- [x] Unsafe model-derived `Filament::setTenant(...)` switching is explicitly guarded.
|
||||
|
||||
## Test And Smoke Readiness
|
||||
|
||||
- [x] Plan includes the intended lane mix (Feature/Livewire + architecture guard; browser smoke only where user-visible bugs exist).
|
||||
- [x] Tasks include a guardrail-first step and explicit validation commands.
|
||||
- [x] Browser smoke scenarios are explicitly listed for the two originally user-visible bugs.
|
||||
150
specs/334-nested-filament-context-contract-hardening/plan.md
Normal file
150
specs/334-nested-filament-context-contract-hardening/plan.md
Normal file
@ -0,0 +1,150 @@
|
||||
# Implementation Plan: Spec 334 - Nested Filament / Livewire Context Contract Hardening
|
||||
|
||||
- Branch: `334-nested-filament-context-contract-hardening`
|
||||
- Date: 2026-05-24
|
||||
- Spec: `specs/334-nested-filament-context-contract-hardening/spec.md`
|
||||
- Input: User-provided Spec 334 draft + repo inspection for path truth.
|
||||
|
||||
## Summary
|
||||
|
||||
Define and enforce a nested Filament/Livewire context contract so operator-critical nested surfaces do not depend on ambient `Filament::getTenant()` during modal, wizard, relation manager, widget, and Livewire update lifecycles.
|
||||
|
||||
Implement a narrow hardening slice across four confirmed surfaces, plus guard tests:
|
||||
|
||||
- `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php` (+ `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` seam)
|
||||
- `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`
|
||||
- `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`
|
||||
- new architecture guard: `apps/platform/tests/Architecture/FilamentTenantContextContractTest.php`
|
||||
|
||||
## Technical Context
|
||||
|
||||
- Language/Version: PHP 8.4.15, Laravel 12.52.x.
|
||||
- Primary Dependencies: Filament 5.2.x, Livewire 4.1.x, Pest 4.x, Tailwind CSS 4.x.
|
||||
- Storage: PostgreSQL; no schema change expected.
|
||||
- Testing: Pest Feature + Livewire component tests + one architecture guard; browser smoke only for the two user-visible regression scenarios.
|
||||
- Validation Lanes: confidence + browser (scoped).
|
||||
- Target Platform: Laravel Sail locally; Dokploy/container deployment posture unchanged.
|
||||
- Project Type: Laravel monolith under `apps/platform`.
|
||||
- Performance Goals: DB-only context recovery; no Graph calls during UI render; no broad unscoped queries in nested surfaces.
|
||||
- Constraints: No new persisted truth, migrations, packages, env vars, queue/scheduler changes, or routing architecture changes.
|
||||
**Scale/Scope**: Four confirmed nested surfaces + shared seam hardening + guard tests.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed existing operator-facing nested surfaces (modal, wizard, relation manager, widget).
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
|
||||
- Backup Set “Add policies” modal table (Livewire component).
|
||||
- Restore Run Create wizard (resource create page).
|
||||
- Backup Schedule Operation Runs relation manager table (nested resource detail).
|
||||
- Environment dashboard triage widget (nested widget).
|
||||
- **No-impact class**: N/A.
|
||||
- **Native vs custom classification summary**: native Filament + Livewire surfaces; no custom UI framework.
|
||||
- **Shared-family relevance**: environment context resolution, authorization/visibility gating, table selection semantics, wizard option closures, remembered context validation.
|
||||
- **State layers in scope**: route-owned workspace/environment, Livewire component state (captured at mount), remembered environment context (validated only), ambient Filament tenant (fallback convenience only).
|
||||
- **Audience modes in scope**: operator-MSP / platform operators. No customer/read-only surface changes expected.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: fail-closed states must be honest; do not show “no records” when context is invalid; no raw debug “framework state” troubleshooting exposed to operators.
|
||||
- **Handling modes**: review-mandatory. This is a correctness hardening slice affecting authorization and scope.
|
||||
- **Special surface test profiles**: `exception-coded-surface` (context recovery) + `shared-detail-family` (nested relation manager) + browser smoke for two user-visible regressions.
|
||||
- **Required tests or manual smoke**:
|
||||
- Feature/Livewire tests for each confirmed surface and its fail-closed behavior.
|
||||
- Architecture guard preventing unsafe `Filament::setTenant(...)` usage in nested surfaces.
|
||||
- Browser smoke for:
|
||||
- Backup Set “Add policies” checkbox visibility + selection.
|
||||
- Restore Run Create wizard no-crash on Livewire update transitions.
|
||||
- **Exception path and spread control**: no broad fallback that sets tenant from arbitrary model IDs. Any referer-based recovery must validate workspace membership and environment entitlement.
|
||||
- **UI/Productization coverage decision**: no new routes/pages; cover via tests + browser smoke + PR close-out note.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes (multiple nested surfaces).
|
||||
- **Systems touched**:
|
||||
- Filament tenant resolution seam: `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- Route/shell context: `apps/platform/app/Support/OperateHub/OperateHubShell.php`
|
||||
- Workspace/environment context + remembered context: `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
|
||||
- UI gating: `apps/platform/app/Support/Rbac/UiEnforcement.php`
|
||||
- **Shared abstractions reused**: prefer existing helpers/policies over new framework layers.
|
||||
- **New abstraction introduced?**: none by default. A small helper class may be introduced only if at least two confirmed surfaces would otherwise duplicate validated context recovery logic (ABSTR-001).
|
||||
- **Bounded deviation / spread control**: any new helper must be feature-local in scope and must not become a generic “ambient context fixer” without explicit follow-up spec approval.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
N/A. This feature does not introduce new queued operations. It must not create new OperationRun types or change OperationRun UX policy.
|
||||
|
||||
## Provider Boundary / Platform Core Check
|
||||
|
||||
Platform-core hardening only. No Graph contract, provider registry, or provider vocabulary changes.
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1 — Repo truth + reproduce
|
||||
|
||||
- Inspect current behavior and confirm the concrete failure path(s) for:
|
||||
- Add Policies modal selection checkbox hidden.
|
||||
- Restore Run Create wizard crash during Livewire update.
|
||||
- Document the exact call path(s) and which closures/methods access context.
|
||||
|
||||
### Phase 2 — Define nested context resolution order
|
||||
|
||||
Implement (or codify within existing seams) a deterministic resolution order for managed-environment context in nested surfaces:
|
||||
|
||||
1. **Owner/domain record** (owner record, component-bound record, backup set record, schedule record, widget record).
|
||||
2. **Validated component state captured at mount** (e.g., environment ID saved in the Livewire component state).
|
||||
3. **Route-owned workspace/environment** (when route params are available).
|
||||
4. **Remembered environment** only if workspace membership and entitlement validate.
|
||||
5. **Ambient Filament tenant** as fallback convenience only.
|
||||
6. **Fail closed** with a clear, honest UI state.
|
||||
|
||||
Prohibited:
|
||||
|
||||
- blindly trusting referer as authority
|
||||
- unguarded `Filament::setTenant($model->...)` derived from an arbitrary identifier
|
||||
- broad “set tenant from model id” fallback in the shared seam
|
||||
|
||||
### Phase 3 — Apply the contract to confirmed surfaces
|
||||
|
||||
- **BackupSetPolicyPickerTable**:
|
||||
- Resolve context from the BackupSet (validated) and scope policy query accordingly.
|
||||
- Ensure mutation-time recheck before attaching policy IDs.
|
||||
- Do not override a mismatched existing tenant; fail closed.
|
||||
- **RestoreRunResource**:
|
||||
- Ensure option closures used in wizard lifecycles can resolve environment context without route params.
|
||||
- Implement validated Livewire update context recovery via the shared seam (`apps/platform/app/Support/OperateHub/OperateHubShell.php`) by treating the `Referer` path as a candidate (never authority) and re-validating workspace + membership + operability before using it.
|
||||
- **BackupScheduleOperationRunsRelationManager**:
|
||||
- Use owner schedule context (`$this->getOwnerRecord()`) as primary context.
|
||||
- Avoid ambient `ManagedEnvironment::currentOrFail()`-style assumptions when owner exists.
|
||||
- **ManagedEnvironmentTriageArrivalContinuity**:
|
||||
- Prefer widget record context; ambient tenant only fallback.
|
||||
- Use the same resolver for visibility and mutation-time checks.
|
||||
|
||||
### Phase 4 — Guardrails
|
||||
|
||||
- Add `apps/platform/tests/Architecture/FilamentTenantContextContractTest.php` to:
|
||||
- detect unsafe `Filament::setTenant(...)` patterns in nested surfaces
|
||||
- keep an explicit allowlist for infrastructure-only locations
|
||||
- optionally classify (not fail) direct `Filament::getTenant()` usage in known high-risk directories first, to avoid noisy failures
|
||||
|
||||
### Phase 5 — Tests and validation
|
||||
|
||||
Add focused tests (Feature/Livewire) for:
|
||||
|
||||
- tenantless Add Policies picker: authorized user still sees checkboxes and can select items
|
||||
- restore create wizard: does not throw when route params are missing in Livewire update lifecycle
|
||||
- owner-scoped relation manager: query does not broaden when ambient tenant is null/wrong
|
||||
- widget context: record context is preferred; missing context fails closed
|
||||
|
||||
Validation commands (narrow first):
|
||||
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --filter='BackupSetPolicyPicker|RestoreRun|BackupScheduleOperationRuns|Triage' --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Architecture --filter='FilamentTenantContextContract' --compact`
|
||||
|
||||
Browser smoke (only when feature tests pass):
|
||||
|
||||
- `cd apps/platform && php vendor/bin/pest tests/Browser/Spec334NestedFilamentContextContractSmokeTest.php --compact`
|
||||
|
||||
## Deployment / Ops Impact
|
||||
|
||||
- **Migrations**: none expected.
|
||||
- **Env vars**: none expected.
|
||||
- **Queues/scheduler**: none expected.
|
||||
- **Filament assets**: no new registered assets expected; deployment posture unchanged.
|
||||
343
specs/334-nested-filament-context-contract-hardening/spec.md
Normal file
343
specs/334-nested-filament-context-contract-hardening/spec.md
Normal file
@ -0,0 +1,343 @@
|
||||
# Feature Specification: Spec 334 - Nested Filament / Livewire Context Contract Hardening
|
||||
|
||||
- Feature Branch: `334-nested-filament-context-contract-hardening`
|
||||
- Created: 2026-05-24
|
||||
- Status: Draft
|
||||
- Type: Platform integrity / Livewire lifecycle hardening / Filament context contract / productization blocker removal
|
||||
- Runtime posture: Narrow platform hardening. No tenancy rewrite.
|
||||
- Input: User-provided Spec 334 draft + repo inspection for path truth.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
This spec exists because two independent operator flows exposed the same underlying defect class:
|
||||
|
||||
1) **Backup Set → “Add Policies” picker modal**
|
||||
|
||||
- `Filament::getTenant()` can be `null` in nested/modal contexts.
|
||||
- Authorization then fails false-negative for a bulk action / row selection.
|
||||
- Filament hides the row selection checkbox column.
|
||||
- Operator sees policies as non-selectable.
|
||||
|
||||
2) **Restore Run → Create wizard (Livewire update lifecycle)**
|
||||
|
||||
- Livewire update request is a `POST /livewire-.../update` request with no route parameters.
|
||||
- Option closures can evaluate in that lifecycle.
|
||||
- Context resolution that hard-requires ambient Filament tenant can throw, blocking the wizard.
|
||||
|
||||
Repo truth note: the user draft references a “Spec 332 Restore Flow Productization WIP” as the direct dependency. On `platform-dev` there is currently no `specs/332-*` directory. This spec still stands as a narrow platform hardening slice, and should be linked to the active restore productization spec/branch once it exists in-repo.
|
||||
|
||||
Related existing work (context only; do not modify closed specs here):
|
||||
|
||||
- `specs/152-livewire-context-locking` (draft): broader “trusted state” hardening. This spec is narrower and explicitly tenant-context-centric.
|
||||
- `specs/302-tenant-owned-surface-route-audit`: established the current `ResolvesPanelTenantContext` seam and route-owned environment posture.
|
||||
- `docs/research/filament-v5-notes.md`: source of truth when Filament/Livewire behavior is uncertain.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Nested Filament / Livewire surfaces sometimes execute without reliable ambient tenant context, causing false-negative authorization, hidden selection controls, or hard runtime failures in operator-critical flows.
|
||||
- **Today's failure**: Authorized operators cannot select policies in the Add Policies picker (checkbox column missing) and restore create flows can crash during Livewire updates with “no tenant context selected”.
|
||||
- **User-visible improvement**: Operator-critical nested surfaces remain stable (no missing checkboxes, no wizard crash) and fail closed with honest UI states when context is genuinely unavailable.
|
||||
- **Smallest enterprise-capable version**: Harden only confirmed high-risk nested surfaces and the shared context seam; add guardrails/tests so unsafe patterns do not reappear. No route architecture rewrite, no tenancy model redesign.
|
||||
- **Explicit non-goals**: No new tenancy concept, no workspace model redesign, no panel rewrite, no broad repo-wide replacement of `Filament::getTenant()`, no RBAC rebuild, no speculative general framework beyond what at least two confirmed surfaces require.
|
||||
- **Permanent complexity imported**: Targeted context resolver adjustments, a small optional helper (only if reused by ≥2 confirmed surfaces), focused Pest tests (Feature/Livewire + one architecture guard), and browser smoke steps for the two originally user-visible bugs.
|
||||
- **Why now**: This is a platform-level productization blocker for restore workflows and breaks a visible day-to-day operator action (“Add policies”).
|
||||
- **Why not local**: The same defect class occurs across independent surfaces (modal table, wizard update, relation manager, widget). Without an explicit contract + guardrails, it will recur.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: (2) new meta-infrastructure risk (context helper + guard tests), (3) multiple surfaces. **Defense**: scope is explicitly limited to confirmed surfaces; helper is optional and must satisfy ABSTR-001 (≥2 real consumers); no new persisted truth, routes, or global “magic tenant switch”.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: tenant (managed-environment scoped operator surfaces under `/admin/workspaces/{workspace}/environments/{environment}/...`) + Livewire update lifecycle hardening.
|
||||
- **Primary Routes / Surfaces** (representative, non-exhaustive):
|
||||
- Backup Set detail → “Add policies” modal table (Livewire component).
|
||||
- Restore Runs → Create wizard (Filament resource create page).
|
||||
- Backup Schedule detail → Operation Runs relation manager table.
|
||||
- Managed Environment dashboard → triage widget.
|
||||
- **Data Ownership**:
|
||||
- Tenant/environment-owned records: `ManagedEnvironment`, `BackupSet`, `BackupSchedule`, `RestoreRun`, related policy inventory models.
|
||||
- Workspace ownership remains authoritative; context recovery must validate workspace membership/entitlement.
|
||||
- No new persisted entity/table is introduced by this spec.
|
||||
- **RBAC**:
|
||||
- Workspace membership + environment entitlement required to view tenant-scoped surfaces.
|
||||
- Mutations (attach policies, restore run create/confirm, etc.) require explicit capability checks and policy/gate enforcement; UI visibility is not authorization.
|
||||
|
||||
For canonical-view specs: N/A. This spec hardens environment/tenant-scoped nested surfaces, not a workspace-owned canonical viewer.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [x] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [x] Dangerous action changed
|
||||
- [ ] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage
|
||||
|
||||
- **Route/page/surface**: Add Policies modal table, Restore Run Create wizard, Backup Schedule Operation Runs relation manager, Managed Environment triage widget.
|
||||
- **Current or new page archetype**: Domain Pattern Surface (nested CRUD tooling and wizards), not a new strategic page archetype.
|
||||
- **Design depth**: Internal/Hidden (but operator-reachable and workflow-critical).
|
||||
- **Repo-truth level**: repo-verified for file paths and current helper seams; runtime behavior must be validated by tests + browser smoke.
|
||||
- **Existing pattern reused**: current `ResolvesPanelTenantContext` seam, route-owned environment posture, `UiEnforcement`, policies/gates, and owner-record scoping patterns.
|
||||
- **New pattern required**: explicit nested context resolution order for high-risk surfaces; optional small helper only if used by ≥2 surfaces.
|
||||
- **Screenshot required**: no new design screenshots required; browser smoke is required for the two user-visible bugs.
|
||||
- **Page audit required**: no UI audit registry update expected unless implementation changes navigation or surface archetype.
|
||||
- **Customer-safe review required**: no. These are operator surfaces; still must fail closed and never broaden scope.
|
||||
- **Dangerous-action review required**: yes. Restore-related and attach-related actions are high-impact and must keep explicit confirmation + authorization + audit posture.
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [x] `N/A - no new routes/pages; coverage handled via tests + browser smoke + PR close-out note`
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse
|
||||
|
||||
- **Cross-cutting feature?**: yes.
|
||||
- **Interaction class(es)**: authorization/visibility, table row selection, bulk actions, wizard step transitions, select option closures, widget visibility/mutation.
|
||||
- **Systems touched**: Filament tenant resolution seam, route-owned environment posture, remembered environment context (validated only), and nested Livewire lifecycle behavior.
|
||||
- **Existing pattern(s) to extend**: owner-record scoping, `UiEnforcement`, deny-as-not-found (404) vs forbidden (403) semantics, and existing environment context middleware/helpers.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Support/OperateHub/OperateHubShell.php`, `apps/platform/app/Support/Workspaces/WorkspaceContext.php`, `apps/platform/app/Support/Rbac/UiEnforcement.php`, and existing policies.
|
||||
- **Why the existing shared path is sufficient or insufficient**: sufficient for route-owned page loads; insufficient for nested Livewire update requests and nested surfaces that currently assume ambient tenant is always present.
|
||||
- **Allowed deviation and why**: bounded “nested context contract” logic may be added only where confirmed surfaces prove it is needed; no global ambient-tenant-as-truth fallback is allowed.
|
||||
- **Review focus**: fail-closed semantics (no broadening), no unsafe `Filament::setTenant($model->...)` without validated ownership/capability, and stable UX for authorized operators when context is recoverable.
|
||||
|
||||
## Summary
|
||||
|
||||
Harden TenantPilot’s nested Filament and Livewire context handling so critical nested surfaces do not depend on unreliable ambient `Filament::getTenant()` state during modal, wizard, relation manager, widget, and Livewire update lifecycles.
|
||||
|
||||
This spec does **not** rewrite tenancy, routing, RBAC, or the workspace model. It introduces targeted hardening for confirmed high-risk surfaces and installs guardrails so the same issue does not keep reappearing.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Nested Filament/Livewire surfaces can execute outside the full page routing lifecycle:
|
||||
|
||||
- modal tables
|
||||
- relation managers
|
||||
- wizard step transitions
|
||||
- select option closures
|
||||
- Livewire update requests (`POST /livewire-.../update`)
|
||||
- widgets
|
||||
- bulk actions
|
||||
- row selection
|
||||
- table refresh/search/filter/pagination
|
||||
|
||||
In those contexts:
|
||||
|
||||
- route parameters may be missing
|
||||
- ambient Filament tenant may be `null`
|
||||
- authorization can fail false-negative
|
||||
- UI controls may disappear
|
||||
- option closures may hard-fail
|
||||
|
||||
Root problem:
|
||||
|
||||
> Ambient framework context is being used where explicit domain context is required.
|
||||
|
||||
Desired nested context contract:
|
||||
|
||||
1. Owner/domain context first.
|
||||
2. Validated route/workspace/environment context second.
|
||||
3. Remembered/session environment only if validated.
|
||||
4. Ambient Filament tenant only as fallback convenience.
|
||||
5. Fail closed with a clear product state.
|
||||
6. Never switch tenant from a model before ownership/capability is proven.
|
||||
|
||||
## Goals
|
||||
|
||||
### G1 — Define and enforce a nested context contract
|
||||
|
||||
Create a consistent rule for nested Filament/Livewire surfaces:
|
||||
|
||||
> Domain context is authoritative. Filament tenant is runtime convenience.
|
||||
|
||||
The contract must cover: table queries, option closures, visibility/disabled checks, row selection, bulk actions, mutation handlers, relation managers, widgets, and wizard transitions.
|
||||
|
||||
### G2 — Fix the confirmed Add Policies / BackupSet picker class
|
||||
|
||||
`apps/platform/app/Livewire/BackupSetPolicyPickerTable.php` must be self-protecting:
|
||||
|
||||
- does not trust a passed BackupSet ID before validating workspace/environment ownership and access
|
||||
- authorized operators can select policies even if ambient tenant is initially null
|
||||
- forged cross-workspace/cross-environment IDs fail closed
|
||||
- no unguarded `Filament::setTenant(...)`
|
||||
|
||||
### G3 — Fix the confirmed Restore Run Create Wizard Livewire context loss
|
||||
|
||||
`apps/platform/app/Filament/Resources/RestoreRunResource.php` and shared context resolution must not hard-fail during Livewire update requests when context is recoverable from a validated source.
|
||||
|
||||
### G4 — Harden owner-derived relation/widget surfaces
|
||||
|
||||
At minimum:
|
||||
|
||||
- `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`
|
||||
- `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`
|
||||
|
||||
must resolve context from owner record / widget record before using ambient tenant.
|
||||
|
||||
### G5 — Install guardrails
|
||||
|
||||
Add guardrails/tests that prevent reintroducing unsafe patterns such as model-derived `Filament::setTenant(...)` in nested surfaces without prior ownership/capability validation.
|
||||
|
||||
### G6 — Unblock restore productization validation
|
||||
|
||||
After this spec is implemented, restore flow productization work should be able to run restore create wizard browser validation without a tenantless Livewire update crash.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no workspace model redesign
|
||||
- no managed environment model redesign
|
||||
- no Filament panel rewrite
|
||||
- no route architecture rewrite
|
||||
- no RBAC rebuild
|
||||
- no broad resource authorization audit
|
||||
- no restore UX redesign beyond what is needed to make the wizard stable
|
||||
- no broad repo-wide replacement of `Filament::getTenant()` usage
|
||||
|
||||
## Core Rules
|
||||
|
||||
### R1 — Domain context before Filament tenant
|
||||
|
||||
Nested surfaces must use owner/domain records first.
|
||||
|
||||
### R2 — No unguarded model-derived tenant switch
|
||||
|
||||
Switching tenant based on a loaded model is forbidden unless ownership/capability is validated and mismatch handling fails closed.
|
||||
|
||||
### R3 — UI visibility is not authorization
|
||||
|
||||
Hidden checkboxes/buttons are not security boundaries. Mutations must be authorization-checked at execution time.
|
||||
|
||||
### R4 — Fail closed, but not mysteriously
|
||||
|
||||
If context is invalid: no data leak, no mutation, no unscoped query, and no silent cross-tenant switch.
|
||||
|
||||
### R5 — Livewire update requests need recoverable context
|
||||
|
||||
During Livewire update (`POST /livewire-.../update`), route params may be absent. Recovery must come from validated component state / owner record / validated remembered context. Referer may be a candidate only, never authority.
|
||||
|
||||
### R6 — Mutation-time recheck
|
||||
|
||||
Every mutation handler must re-resolve and re-check context and authorization.
|
||||
|
||||
## Confirmed Scope
|
||||
|
||||
### S1 — BackupSetPolicyPickerTable
|
||||
|
||||
- File: `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- Issue class: tenantless modal context → false-negative authorization → checkbox column hidden.
|
||||
- Required outcome: authorized users can select and attach in-scope policies; cross-scope mounts fail closed; no unsafe tenant switching.
|
||||
|
||||
### S2 — RestoreRunResource Livewire update context loss
|
||||
|
||||
- Files:
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- Issue class: Livewire update has no route params → tenant resolution throws.
|
||||
- Required outcome: wizard does not crash; option closures do not depend exclusively on ambient tenant; context recovery is validated.
|
||||
|
||||
### S3 — BackupScheduleOperationRunsRelationManager
|
||||
|
||||
- File: `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`
|
||||
- Required outcome: owner schedule context drives query and URLs; missing/wrong ambient tenant does not broaden scope.
|
||||
|
||||
### S4 — ManagedEnvironmentTriageArrivalContinuity
|
||||
|
||||
- File: `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`
|
||||
- Required outcome: record context first, ambient tenant fallback only; missing context fails closed; visibility and mutation use the same resolver.
|
||||
|
||||
### S5 — Guardrail tests
|
||||
|
||||
New or existing guard tests must live alongside existing architecture tests at:
|
||||
|
||||
- `apps/platform/tests/Architecture/FilamentTenantContextContractTest.php` (new)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC1 — Restore Run Create Wizard no longer crashes on Livewire update
|
||||
|
||||
- Livewire updates do not throw “no tenant context selected” when context is recoverable.
|
||||
- Backup set options remain environment-scoped.
|
||||
- Cross-workspace or cross-environment options are never visible.
|
||||
- If context is not recoverable, the UI fails closed (disabled/empty) without leaking scope.
|
||||
|
||||
### AC2 — BackupSet Policy Picker is self-protecting
|
||||
|
||||
- Authorized tenantless modal mount shows selectable in-scope policies.
|
||||
- Checkbox column remains visible for authorized users.
|
||||
- Cross-scope or forged mounts fail closed.
|
||||
- Cross-environment policy IDs cannot be attached.
|
||||
|
||||
### AC3 — BackupSchedule Operation Runs relation is owner-scoped
|
||||
|
||||
- Relation uses owner BackupSchedule context.
|
||||
- Missing ambient tenant does not crash.
|
||||
- Wrong ambient tenant does not broaden query or URLs.
|
||||
|
||||
### AC4 — Triage widget uses explicit context
|
||||
|
||||
- Record context is preferred; ambient tenant is fallback only.
|
||||
- Missing context fails closed.
|
||||
- Authorized operators do not lose actions due to ambient context loss.
|
||||
|
||||
### AC5 — Guardrails exist
|
||||
|
||||
- Unsafe model-derived `Filament::setTenant(...)` in nested surfaces is detected.
|
||||
- Allowlist is explicit and limited to infrastructure.
|
||||
|
||||
### AC6 — No broad unrelated changes
|
||||
|
||||
- No panel/routing rewrite, no workspace/managed-environment redesign, no new persisted truth, and no unrelated UX/productization expansion.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **New source of truth?**: no.
|
||||
- **New persisted entity/table/artifact?**: no.
|
||||
- **New abstraction?**: optional small helper only if it is reused by ≥2 confirmed surfaces; otherwise local resolvers only.
|
||||
- **New enum/status family?**: no.
|
||||
- **Current operator problem**: missing row selection and crashing restore wizard due to context loss.
|
||||
- **Narrowest correct implementation**: validate and re-resolve context from owner/domain and canonical workspace/environment truth; fall back only when safe; add guard tests.
|
||||
- **Ownership cost**: limited helper/tests + browser smoke for two user-visible issues.
|
||||
- **Alternative rejected**: tenancy rewrite, route architecture changes, broad repo sweep of `Filament::getTenant()`, or “always set tenant from model” fallback.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Feature + Livewire + Architecture (guard); browser smoke for the two user-visible bugs.
|
||||
- **Validation lane(s)**: confidence + browser (only for the named smoke flows).
|
||||
- **Why this lane mix is sufficient**: Feature/Livewire tests prove scoped behavior and prevent regressions; browser smoke verifies the original “checkbox missing” and “wizard crash” end-to-end.
|
||||
- **No Graph calls during UI render**: must remain true; context recovery must be DB-only and authorization-safe.
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Risk**: Resolver becomes too broad or “magic”.
|
||||
- **Mitigation**: Fix only confirmed seams; keep helper optional and bounded; add explicit tests and allowlists.
|
||||
- **Risk**: Referer parsing introduces security risk.
|
||||
- **Mitigation**: Referer can only be a candidate; any extracted identifiers must be validated against DB membership and scope.
|
||||
- **Risk**: Guardrail is too noisy.
|
||||
- **Mitigation**: Start by guarding unsafe `setTenant` patterns; classify `getTenant` usage before hard failing.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which exact capability constants gate:
|
||||
- attaching policies to a backup set
|
||||
- creating a restore run
|
||||
- viewing schedule operation runs
|
||||
- acting in triage widget actions
|
||||
(Implementation must use the existing capability registry; no new strings.)
|
||||
- Does the repo already have a safe “current environment” helper for nested Livewire updates beyond `ResolvesPanelTenantContext`? If yes, reuse it.
|
||||
|
||||
## Follow-Up Spec Candidates *(out of scope for Spec 334)*
|
||||
|
||||
- Broader “environment-resource-context-follow-through” rollout beyond the 4 confirmed surfaces.
|
||||
- Harmonize with Spec 152 “trusted state” hardening if both proceed; avoid duplicated helper layers.
|
||||
@ -0,0 +1,92 @@
|
||||
# Tasks: Spec 334 - Nested Filament / Livewire Context Contract Hardening
|
||||
|
||||
- Input: `specs/334-nested-filament-context-contract-hardening/spec.md`, `specs/334-nested-filament-context-contract-hardening/plan.md`
|
||||
- Prerequisites: `spec.md`, `plan.md`
|
||||
|
||||
**Tests**: Required. This is runtime-hardening for nested operator surfaces with authorization/scope impact.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is explicit and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New tests stay in the smallest honest family (Feature/Livewire + one architecture guard; browser smoke only for the two user-visible regressions).
|
||||
- [x] New helpers/factories/seeds/context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] Any deviation resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth
|
||||
|
||||
**Purpose**: Confirm repo truth and capture the current failure paths before touching runtime code.
|
||||
|
||||
- [x] T001 Re-read `spec.md`, `plan.md`, and this `tasks.md`.
|
||||
- [x] T002 Confirm working tree intent and record baseline commit (`git status`, `git log -1`).
|
||||
- [x] T003 Verify confirmed scope file paths exist and inspect current context usage:
|
||||
- `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`
|
||||
- `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`
|
||||
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
|
||||
- `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
|
||||
- [x] T004 Capture the current Add Policies modal failure path (tenantless modal → checkbox column hidden) and record the exact call chain and where tenant/context is read.
|
||||
- Captured via regression: `apps/platform/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php` and browser smoke `apps/platform/tests/Browser/Spec334NestedFilamentContextContractSmokeTest.php`.
|
||||
- [x] T005 Capture the current Restore Run Create wizard failure path (Livewire update request without route params → tenant resolver throws) and record the exact call chain and closure(s) involved.
|
||||
- Captured via regression: `apps/platform/tests/Feature/Filament/RestoreRunResourceLivewireTenantContextTest.php` (real Livewire update payload) + browser smoke `apps/platform/tests/Browser/Spec334NestedFilamentContextContractSmokeTest.php`.
|
||||
- [x] T006 Decide whether any shared helper is justified (≥2 confirmed consumers). If not, keep fixes local to each surface + the existing shared seam.
|
||||
- No new helper introduced (ABSTR-001). Shared seam hardening applied in `apps/platform/app/Support/OperateHub/OperateHubShell.php`.
|
||||
|
||||
## Phase 2: Guardrails And Regression Tests (before refactor)
|
||||
|
||||
**Purpose**: Lock “fail-closed and scoped” behavior and block unsafe tenant switching patterns before implementation refactors.
|
||||
|
||||
- [x] T007 Add an architecture guard test: `apps/platform/tests/Architecture/FilamentTenantContextContractTest.php`.
|
||||
- Detect and fail on unsafe `Filament::setTenant($model->...)` patterns in nested surfaces without explicit allowlist.
|
||||
- Keep an explicit allowlist limited to infrastructure-only context selection locations.
|
||||
- [x] T008 Add a regression test covering Add Policies picker behavior when ambient tenant is null (authorized user still sees row selection and can select).
|
||||
- [x] T009 Add a regression test covering Restore Run Create wizard Livewire update lifecycle (no crash when route params are missing; options are scoped).
|
||||
- [x] T010 Add a regression test for BackupSchedule operation runs relation manager scope (owner record scopes query; ambient tenant missing/wrong does not broaden).
|
||||
- [x] T011 Add a regression test for the triage widget context (record context preferred; ambient tenant is fallback; missing context fails closed).
|
||||
|
||||
## Phase 3: Implement Nested Context Contract (confirmed surfaces only)
|
||||
|
||||
**Purpose**: Apply the contract to the confirmed high-risk surfaces without a broad tenancy rewrite.
|
||||
|
||||
- [x] T012 Harden `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`:
|
||||
- Resolve context from validated BackupSet ownership (workspace + managed environment).
|
||||
- Scope table query by resolved environment.
|
||||
- Re-resolve and re-authorize at mutation time (bulk action).
|
||||
- Never silently override mismatched ambient tenant.
|
||||
- [x] T013 Harden `apps/platform/app/Filament/Resources/RestoreRunResource.php`:
|
||||
- Ensure option closures used during Livewire updates can resolve validated environment context without route params.
|
||||
- Livewire update context recovery is implemented in the shared seam: `apps/platform/app/Support/OperateHub/OperateHubShell.php` (validated referer path candidate for `/livewire-*/update` requests).
|
||||
- Referer is treated as a candidate only; access is still validated via workspace + membership + operability checks.
|
||||
- [x] T014 Harden `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` only if the seam is shared and the fix is not restore-specific.
|
||||
- No change required (the shared seam hardening in `OperateHubShell` unblocks the failing lifecycle).
|
||||
- [x] T015 Harden `apps/platform/app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`:
|
||||
- Prefer owner schedule record as primary context (`$this->getOwnerRecord()`).
|
||||
- Remove dependencies on ambient tenant where owner context exists.
|
||||
- [x] T016 Harden `apps/platform/app/Filament/Widgets/ManagedEnvironment/ManagedEnvironmentTriageArrivalContinuity.php`:
|
||||
- Prefer widget record context.
|
||||
- Use one resolver consistently for visibility and mutation checks.
|
||||
- Fail closed when context is missing.
|
||||
|
||||
## Phase 4: Browser Smoke (required because the original bugs were user-visible)
|
||||
|
||||
- [x] T017 Backup Set Add Policies smoke:
|
||||
- Open Backup Set detail → Add Policies.
|
||||
- Verify policies are visible and row selection checkbox column is visible.
|
||||
- Select one or more rows; verify “Add selected” path works.
|
||||
- [x] T018 Restore Run Create wizard smoke:
|
||||
- Open Restore Runs → Create.
|
||||
- Trigger Livewire interactions (wizard next/previous, selections).
|
||||
- Verify no “no tenant context selected” crash.
|
||||
|
||||
## Phase 5: Validation
|
||||
|
||||
- [x] T019 Run narrow tests first (confidence lane):
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Architecture --filter='FilamentTenantContextContract' --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --filter='BackupSetPolicyPicker|RestoreRun|BackupScheduleOperationRuns|Triage' --compact`
|
||||
- [ ] T020 Run broader related suites only if needed (keep lane cost honest):
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament --compact`
|
||||
- [x] T021 Run `cd apps/platform && ./vendor/bin/sail pint --dirty` and `git diff --check`.
|
||||
- [x] T022 Report full-suite status honestly if not run.
|
||||
- Full suite not executed; this spec was validated via targeted Feature/Architecture tests + a dedicated browser smoke file.
|
||||
Loading…
Reference in New Issue
Block a user