feat: implement explicit UiActionContext contract (#434)
Implements explicit UiActionContext contract as requested. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #434
This commit is contained in:
parent
548a37c888
commit
01ee82a8e2
@ -7,6 +7,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
@ -19,10 +20,15 @@ protected static function resolveTenantContextForCurrentPanel(): ?ManagedEnviron
|
||||
if (static::currentPanelId($request) === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
return static::environmentFromRequestRoutePathOrReferer($request);
|
||||
}
|
||||
|
||||
$tenant = ManagedEnvironment::current();
|
||||
$tenant = static::environmentFromRequestRoutePathOrReferer($request)
|
||||
?? ManagedEnvironment::current();
|
||||
|
||||
return $tenant instanceof ManagedEnvironment ? $tenant : null;
|
||||
}
|
||||
@ -89,12 +95,9 @@ private static function currentPanelId(mixed $request): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH);
|
||||
if (! is_string($refererPath) || $refererPath === '') {
|
||||
return null;
|
||||
}
|
||||
$refererPath = static::refererPath($request);
|
||||
|
||||
return static::panelIdFromPath('/'.ltrim($refererPath, '/'));
|
||||
return static::panelIdFromPath($refererPath);
|
||||
}
|
||||
|
||||
private static function panelIdFromPath(?string $path): ?string
|
||||
@ -115,4 +118,88 @@ private static function isLivewireRequest(Request $request, ?string $path): bool
|
||||
return $request->headers->has('x-livewire')
|
||||
|| (is_string($path) && preg_match('#^/(?:livewire(?:-[^/]+)?/update|livewire-unit-test-endpoint)(?:/|$)#', $path) === 1);
|
||||
}
|
||||
|
||||
private static function environmentFromRequestRoutePathOrReferer(mixed $request): ?ManagedEnvironment
|
||||
{
|
||||
$routeEnvironment = is_object($request) && method_exists($request, 'route')
|
||||
? ($request->route('environment') ?? $request->route('tenant'))
|
||||
: null;
|
||||
|
||||
if ($routeEnvironment instanceof ManagedEnvironment) {
|
||||
return $routeEnvironment;
|
||||
}
|
||||
|
||||
if (is_string($routeEnvironment) || is_int($routeEnvironment)) {
|
||||
$routeTenant = static::environmentFromIdentifier((string) $routeEnvironment);
|
||||
|
||||
if ($routeTenant instanceof ManagedEnvironment) {
|
||||
return $routeTenant;
|
||||
}
|
||||
}
|
||||
|
||||
$path = is_object($request) && method_exists($request, 'path')
|
||||
? '/'.ltrim((string) $request->path(), '/')
|
||||
: null;
|
||||
|
||||
$identifier = static::environmentIdentifierFromPath($path)
|
||||
?? static::environmentIdentifierFromPath(static::refererPath($request));
|
||||
|
||||
if (! is_string($identifier) || $identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::environmentFromIdentifier($identifier);
|
||||
}
|
||||
|
||||
private static function environmentFromIdentifier(string $identifier): ?ManagedEnvironment
|
||||
{
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ManagedEnvironment::query()
|
||||
->withTrashed()
|
||||
->where(static function (Builder $query) use ($identifier): void {
|
||||
$query->where('slug', $identifier);
|
||||
|
||||
if (ctype_digit($identifier)) {
|
||||
$query->orWhereKey((int) $identifier);
|
||||
}
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private static function environmentIdentifierFromPath(?string $path): ?string
|
||||
{
|
||||
if (! is_string($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedPath = '/'.ltrim($path, '/');
|
||||
|
||||
if (preg_match('#^/admin/workspaces/[^/]+/environments/([^/]+)(?:/|$)#', $normalizedPath, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identifier = urldecode((string) $matches[1]);
|
||||
|
||||
return $identifier === '' ? null : $identifier;
|
||||
}
|
||||
|
||||
private static function refererPath(mixed $request): ?string
|
||||
{
|
||||
if (! is_object($request) || ! method_exists($request, 'headers')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$referer = $request->headers->get('referer');
|
||||
|
||||
if (! is_string($referer) || $referer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = parse_url($referer, PHP_URL_PATH);
|
||||
|
||||
return is_string($path) && $path !== '' ? $path : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Widgets\Dashboard\DashboardKpis;
|
||||
use App\Filament\Widgets\Dashboard\EnvironmentDashboardContextChips;
|
||||
@ -20,6 +21,8 @@
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
|
||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
|
||||
@ -44,6 +47,9 @@
|
||||
|
||||
class EnvironmentDashboard extends Dashboard
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::Full;
|
||||
|
||||
/**
|
||||
@ -418,7 +424,10 @@ private function requestSupportAction(): Action
|
||||
->send();
|
||||
});
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
return UiEnforcement::forScopedAction(
|
||||
$action,
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||
->apply();
|
||||
}
|
||||
@ -443,7 +452,10 @@ private function openSupportDiagnosticsAction(): Action
|
||||
'bundle' => $this->tenantSupportDiagnosticBundle(),
|
||||
]));
|
||||
|
||||
return UiEnforcement::forAction($action)
|
||||
return UiEnforcement::forScopedAction(
|
||||
$action,
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||
->apply();
|
||||
}
|
||||
@ -518,7 +530,7 @@ private function resolveDashboardActor(): User
|
||||
private function resolveCurrentTenantForCapability(string $capability): ManagedEnvironment
|
||||
{
|
||||
$user = $this->resolveDashboardActor();
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
abort(404);
|
||||
@ -539,7 +551,7 @@ private function resolveCurrentTenantForCapability(string $capability): ManagedE
|
||||
|
||||
private function tenantSupportRequestAttachmentSummary(): string
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Services\Auth\ManagedEnvironmentDiagnosticsService;
|
||||
use App\Services\Auth\ManagedEnvironmentMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -20,6 +22,7 @@
|
||||
class EnvironmentDiagnostics extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
@ -61,11 +64,12 @@ public function mount(): void
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Action::make('bootstrapOwner')
|
||||
->label('Bootstrap owner')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->bootstrapOwner()),
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
@ -73,11 +77,12 @@ protected function getHeaderActions(): array
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->missingOwner),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Action::make('mergeDuplicateMemberships')
|
||||
->label('Merge duplicate access scopes')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContextSource;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
@ -48,7 +50,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => OperationRunLinks::index($tenant))
|
||||
->visible(fn (): bool => $tenant instanceof ManagedEnvironment),
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -73,7 +75,11 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
})
|
||||
}),
|
||||
fn (): UiActionContext => UiActionContext::forEnvironment(
|
||||
EntraGroupResource::panelTenantContext(),
|
||||
UiActionContextSource::PageResolver,
|
||||
),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync groups.')
|
||||
|
||||
@ -31,6 +31,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter;
|
||||
@ -69,6 +71,7 @@ class EnvironmentReviewResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
use WorkspaceScopedEnvironmentRoutes;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -402,7 +405,7 @@ public static function makeCreateReviewAction(
|
||||
? __('localization.review.create_review')
|
||||
: $label;
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
return UiEnforcement::forScopedAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon($icon)
|
||||
@ -418,6 +421,7 @@ public static function makeCreateReviewAction(
|
||||
]),
|
||||
])
|
||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||
->apply();
|
||||
|
||||
@ -26,6 +26,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -64,6 +66,7 @@ class EvidenceSnapshotResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
use WorkspaceScopedEnvironmentRoutes;
|
||||
|
||||
protected static ?string $model = EvidenceSnapshot::class;
|
||||
@ -432,11 +435,12 @@ public static function table(Table $table): Table
|
||||
->emptyStateHeading('No evidence snapshots yet')
|
||||
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
||||
->emptyStateActions([
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Actions\Action::make('create_first_snapshot')
|
||||
->label('Create first snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(fn (): mixed => static::executeGeneration([])),
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContextSource;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
@ -19,7 +21,7 @@ class ListEvidenceSnapshots extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Actions\Action::make('create_snapshot')
|
||||
->label('Create snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
@ -32,6 +34,10 @@ protected function getHeaderActions(): array
|
||||
->default(false),
|
||||
]),
|
||||
]),
|
||||
fn (): UiActionContext => UiActionContext::forEnvironment(
|
||||
EvidenceSnapshotResource::panelTenantContext(),
|
||||
UiActionContextSource::PageResolver,
|
||||
),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -34,6 +36,7 @@
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
@ -66,7 +69,7 @@ protected function getHeaderWidgets(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
UiEnforcement::forScopedAction(
|
||||
Action::make('run_inventory_sync')
|
||||
->label('Run Inventory Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -242,7 +245,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
}),
|
||||
fn (): ?ManagedEnvironment => static::resolveTenantContextForCurrentPanel()
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_INVENTORY_SYNC_RUN)
|
||||
|
||||
@ -30,6 +30,8 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -61,6 +63,7 @@ class PolicyResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
use WorkspaceScopedEnvironmentRoutes;
|
||||
|
||||
@ -119,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
return UiEnforcement::forScopedAction(
|
||||
Actions\Action::make($name)
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -179,7 +182,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn (): ?ManagedEnvironment => static::resolveTenantContextForCurrentPanel()
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||
|
||||
@ -50,6 +50,8 @@
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
@ -93,6 +95,7 @@ class RestoreRunResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
use WorkspaceScopedEnvironmentRoutes;
|
||||
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
@ -284,7 +287,10 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
$action = Actions\CreateAction::make()
|
||||
->label('New restore run');
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
UiEnforcement::forScopedAction(
|
||||
$action,
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
use App\Support\Navigation\NavigationScope;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -52,6 +54,7 @@
|
||||
class ReviewPackResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
use ResolvesUiActionContext;
|
||||
use WorkspaceScopedEnvironmentRoutes;
|
||||
|
||||
protected static ?string $model = ReviewPack::class;
|
||||
@ -381,7 +384,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
$action = UiEnforcement::forScopedAction(
|
||||
Actions\Action::make($name)
|
||||
->label($label)
|
||||
->icon('heroicon-o-plus')
|
||||
@ -389,7 +392,8 @@ public static function generatePackAction(string $name = 'generate_pack', string
|
||||
->action(function (array $data): void {
|
||||
static::executeGeneration($data);
|
||||
})
|
||||
->form(static::reviewPackGenerationFormSchema())
|
||||
->form(static::reviewPackGenerationFormSchema()),
|
||||
fn (): UiActionContext => static::tenantUiActionContext(),
|
||||
)
|
||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||
->preserveDisabled()
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac\Actions;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Workspace;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
trait ResolvesUiActionContext
|
||||
{
|
||||
protected static function tenantUiActionContext(
|
||||
?ManagedEnvironment $tenant = null,
|
||||
UiActionContextSource $source = UiActionContextSource::PageResolver,
|
||||
): UiActionContext {
|
||||
if (! $tenant instanceof ManagedEnvironment && method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
$filamentTenant = Filament::getTenant();
|
||||
$tenant = $filamentTenant instanceof ManagedEnvironment ? $filamentTenant : null;
|
||||
}
|
||||
|
||||
return UiActionContext::forEnvironment($tenant, $source);
|
||||
}
|
||||
|
||||
protected static function workspaceUiActionContext(
|
||||
?Workspace $workspace = null,
|
||||
UiActionContextSource $source = UiActionContextSource::WorkspaceContext,
|
||||
): UiActionContext {
|
||||
return UiActionContext::forWorkspace($workspace, $source);
|
||||
}
|
||||
}
|
||||
160
apps/platform/app/Support/Rbac/Actions/UiActionContext.php
Normal file
160
apps/platform/app/Support/Rbac/Actions/UiActionContext.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac\Actions;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use RuntimeException;
|
||||
|
||||
final readonly class UiActionContext
|
||||
{
|
||||
public const string CONTEXT_MISSING = 'context_missing';
|
||||
|
||||
public const string WORKSPACE_MISSING = 'workspace_missing';
|
||||
|
||||
public const string ENVIRONMENT_MISSING = 'environment_missing';
|
||||
|
||||
private function __construct(
|
||||
public UiActionScope $scope,
|
||||
public UiActionContextSource $source,
|
||||
private ?Workspace $workspace = null,
|
||||
private ?ManagedEnvironment $environment = null,
|
||||
private ?Model $record = null,
|
||||
private ?string $missingReason = null,
|
||||
) {}
|
||||
|
||||
public static function forWorkspace(?Workspace $workspace, UiActionContextSource $source = UiActionContextSource::Explicit): self
|
||||
{
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return self::missing(self::WORKSPACE_MISSING, $source);
|
||||
}
|
||||
|
||||
return new self(
|
||||
scope: UiActionScope::Workspace,
|
||||
source: $source,
|
||||
workspace: $workspace,
|
||||
);
|
||||
}
|
||||
|
||||
public static function forEnvironment(?ManagedEnvironment $environment, UiActionContextSource $source = UiActionContextSource::Explicit): self
|
||||
{
|
||||
if (! $environment instanceof ManagedEnvironment) {
|
||||
return self::missing(self::ENVIRONMENT_MISSING, $source);
|
||||
}
|
||||
|
||||
return new self(
|
||||
scope: UiActionScope::Environment,
|
||||
source: $source,
|
||||
workspace: $environment->workspace,
|
||||
environment: $environment,
|
||||
);
|
||||
}
|
||||
|
||||
public static function forRecord(Model $record, UiActionContextSource $source = UiActionContextSource::Record): self
|
||||
{
|
||||
$environment = self::environmentFromRecord($record);
|
||||
|
||||
return new self(
|
||||
scope: UiActionScope::Record,
|
||||
source: $source,
|
||||
workspace: $environment?->workspace,
|
||||
environment: $environment,
|
||||
record: $record,
|
||||
);
|
||||
}
|
||||
|
||||
public static function forSystem(UiActionContextSource $source = UiActionContextSource::Explicit): self
|
||||
{
|
||||
return new self(
|
||||
scope: UiActionScope::System,
|
||||
source: $source,
|
||||
);
|
||||
}
|
||||
|
||||
public static function missing(
|
||||
string $reason = self::CONTEXT_MISSING,
|
||||
UiActionContextSource $source = UiActionContextSource::Missing,
|
||||
): self {
|
||||
return new self(
|
||||
scope: UiActionScope::Missing,
|
||||
source: $source,
|
||||
missingReason: $reason === '' ? self::CONTEXT_MISSING : $reason,
|
||||
);
|
||||
}
|
||||
|
||||
public function isMissing(): bool
|
||||
{
|
||||
return $this->scope === UiActionScope::Missing || $this->missingReason !== null;
|
||||
}
|
||||
|
||||
public function missingReason(): ?string
|
||||
{
|
||||
return $this->missingReason;
|
||||
}
|
||||
|
||||
public function workspace(): ?Workspace
|
||||
{
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
public function environment(): ?ManagedEnvironment
|
||||
{
|
||||
return $this->environment;
|
||||
}
|
||||
|
||||
public function tenant(): ?ManagedEnvironment
|
||||
{
|
||||
return $this->environment();
|
||||
}
|
||||
|
||||
public function record(): ?Model
|
||||
{
|
||||
return $this->record;
|
||||
}
|
||||
|
||||
public function requireWorkspace(): Workspace
|
||||
{
|
||||
if ($this->workspace instanceof Workspace) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
throw new RuntimeException($this->missingReason ?? self::WORKSPACE_MISSING);
|
||||
}
|
||||
|
||||
public function requireEnvironment(): ManagedEnvironment
|
||||
{
|
||||
if ($this->environment instanceof ManagedEnvironment) {
|
||||
return $this->environment;
|
||||
}
|
||||
|
||||
throw new RuntimeException($this->missingReason ?? self::ENVIRONMENT_MISSING);
|
||||
}
|
||||
|
||||
private static function environmentFromRecord(Model $record): ?ManagedEnvironment
|
||||
{
|
||||
if ($record instanceof ManagedEnvironment) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
if (method_exists($record, 'relationLoaded') && $record->relationLoaded('tenant')) {
|
||||
$tenant = $record->getRelation('tenant');
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
if (method_exists($record, 'tenant')) {
|
||||
$tenant = $record->getAttribute('tenant');
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac\Actions;
|
||||
|
||||
enum UiActionContextSource: string
|
||||
{
|
||||
case Explicit = 'explicit';
|
||||
case PageResolver = 'page_resolver';
|
||||
case WorkspaceContext = 'workspace_context';
|
||||
case Record = 'record';
|
||||
case LivewireReferer = 'livewire_referer';
|
||||
case Missing = 'missing';
|
||||
}
|
||||
14
apps/platform/app/Support/Rbac/Actions/UiActionScope.php
Normal file
14
apps/platform/app/Support/Rbac/Actions/UiActionScope.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Rbac\Actions;
|
||||
|
||||
enum UiActionScope: string
|
||||
{
|
||||
case Workspace = 'workspace';
|
||||
case Environment = 'environment';
|
||||
case Record = 'record';
|
||||
case System = 'system';
|
||||
case Missing = 'missing';
|
||||
}
|
||||
@ -20,6 +20,7 @@ public function __construct(
|
||||
public ?ManagedEnvironment $tenant,
|
||||
public bool $isMember,
|
||||
public bool $hasCapability,
|
||||
public ?string $denialReason = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -30,6 +31,16 @@ public function shouldDenyAsNotFound(): bool
|
||||
return ! $this->isMember;
|
||||
}
|
||||
|
||||
public function isContextMissing(): bool
|
||||
{
|
||||
return in_array($this->denialReason, [
|
||||
'context_missing',
|
||||
'workspace_missing',
|
||||
'environment_missing',
|
||||
'tenant_missing',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Members without capability should receive 403 (forbidden).
|
||||
*/
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContextSource;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
@ -48,6 +50,8 @@ final class UiEnforcement
|
||||
|
||||
private bool $isBulk = false;
|
||||
|
||||
private UiActionContext|Closure|null $actionContext = null;
|
||||
|
||||
private bool $preserveExistingVisibility = false;
|
||||
|
||||
private bool $preserveExistingDisabled = false;
|
||||
@ -70,6 +74,14 @@ public static function forAction(Action $action, Model|Closure|null $record = nu
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function forScopedAction(Action $action, UiActionContext|Closure $context): self
|
||||
{
|
||||
$instance = new self($action);
|
||||
$instance->actionContext = $context;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enforcement for a table row action.
|
||||
*
|
||||
@ -259,6 +271,10 @@ private function applyVisibility(): void
|
||||
$this->action->visible(function (?Model $record = null) use ($existingVisibility) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isContextMissing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $context->isMember) {
|
||||
return false;
|
||||
}
|
||||
@ -362,6 +378,10 @@ private function applyDisabledState(): void
|
||||
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isContextMissing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-members are hidden, so this only affects members
|
||||
if (! $context->isMember) {
|
||||
return true;
|
||||
@ -391,6 +411,10 @@ private function applyDisabledState(): void
|
||||
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
if ($context->isContextMissing()) {
|
||||
return UiTooltips::CONTEXT_UNAVAILABLE;
|
||||
}
|
||||
|
||||
if ($context->isMember && ! $context->hasCapability) {
|
||||
return $tooltip;
|
||||
}
|
||||
@ -607,16 +631,24 @@ private function makeTenantStub(int $tenantId): ManagedEnvironment
|
||||
private function resolveContextWithRecord(?Model $record = null): TenantAccessContext
|
||||
{
|
||||
$user = auth()->user();
|
||||
$explicitContext = $this->resolveExplicitActionContext();
|
||||
|
||||
// For table actions, resolve the record and use it as tenant if it's a ManagedEnvironment
|
||||
$tenant = $this->resolveTenantWithRecord($record);
|
||||
// For table actions, resolve the record and use it as the managed environment context.
|
||||
$tenant = $explicitContext instanceof UiActionContext
|
||||
? $explicitContext->tenant()
|
||||
: $this->resolveTenantWithRecord($record);
|
||||
|
||||
$denialReason = $explicitContext?->isMissing() === true
|
||||
? $explicitContext->missingReason()
|
||||
: (! $tenant instanceof ManagedEnvironment ? 'environment_missing' : null);
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return new TenantAccessContext(
|
||||
user: null,
|
||||
user: $user instanceof User ? $user : null,
|
||||
tenant: null,
|
||||
isMember: false,
|
||||
hasCapability: false,
|
||||
denialReason: $denialReason,
|
||||
);
|
||||
}
|
||||
|
||||
@ -635,9 +667,39 @@ private function resolveContextWithRecord(?Model $record = null): TenantAccessCo
|
||||
tenant: $tenant,
|
||||
isMember: $isMember,
|
||||
hasCapability: $hasCapability,
|
||||
denialReason: ! $isMember ? 'not_member' : ($hasCapability ? null : 'missing_capability'),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveExplicitActionContext(): ?UiActionContext
|
||||
{
|
||||
if ($this->actionContext === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$resolved = $this->actionContext instanceof Closure
|
||||
? ($this->actionContext)()
|
||||
: $this->actionContext;
|
||||
} catch (Throwable) {
|
||||
return UiActionContext::missing(UiActionContext::CONTEXT_MISSING, UiActionContextSource::Missing);
|
||||
}
|
||||
|
||||
if ($resolved instanceof UiActionContext) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if ($resolved instanceof ManagedEnvironment) {
|
||||
return UiActionContext::forEnvironment($resolved, UiActionContextSource::Explicit);
|
||||
}
|
||||
|
||||
if ($resolved instanceof Model) {
|
||||
return UiActionContext::forRecord($resolved);
|
||||
}
|
||||
|
||||
return UiActionContext::missing(UiActionContext::CONTEXT_MISSING, UiActionContextSource::Missing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the tenant for this action with an optional record.
|
||||
*
|
||||
|
||||
@ -21,6 +21,11 @@ final class UiTooltips
|
||||
*/
|
||||
public const INSUFFICIENT_PERMISSION = 'Insufficient permission — ask a tenant Owner.';
|
||||
|
||||
/**
|
||||
* Tooltip shown when Livewire/action transport cannot resolve product scope.
|
||||
*/
|
||||
public const CONTEXT_UNAVAILABLE = 'Environment context unavailable.';
|
||||
|
||||
/**
|
||||
* Modal heading for destructive action confirmation.
|
||||
*/
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function scopedUiActionContextContractChecks(): array
|
||||
{
|
||||
return [
|
||||
'inventory sync header action' => [
|
||||
'file' => 'app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php',
|
||||
'action' => 'run_inventory_sync',
|
||||
],
|
||||
'policy sync action factory' => [
|
||||
'file' => 'app/Filament/Resources/PolicyResource.php',
|
||||
'action' => 'sync',
|
||||
'anchor' => 'public static function makeSyncAction',
|
||||
],
|
||||
'directory group sync header action' => [
|
||||
'file' => 'app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
|
||||
'action' => 'sync_groups',
|
||||
],
|
||||
'evidence snapshot header action' => [
|
||||
'file' => 'app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php',
|
||||
'action' => 'create_snapshot',
|
||||
],
|
||||
'evidence snapshot empty-state action' => [
|
||||
'file' => 'app/Filament/Resources/EvidenceSnapshotResource.php',
|
||||
'action' => 'create_first_snapshot',
|
||||
],
|
||||
'review pack generation action factory' => [
|
||||
'file' => 'app/Filament/Resources/ReviewPackResource.php',
|
||||
'action' => 'generate_pack',
|
||||
'anchor' => 'public static function generatePackAction',
|
||||
],
|
||||
'environment review creation action factory' => [
|
||||
'file' => 'app/Filament/Resources/EnvironmentReviewResource.php',
|
||||
'action' => 'create_review',
|
||||
'anchor' => 'public static function makeCreateReviewAction',
|
||||
],
|
||||
'environment diagnostics bootstrap action' => [
|
||||
'file' => 'app/Filament/Pages/EnvironmentDiagnostics.php',
|
||||
'action' => 'bootstrapOwner',
|
||||
],
|
||||
'environment diagnostics merge action' => [
|
||||
'file' => 'app/Filament/Pages/EnvironmentDiagnostics.php',
|
||||
'action' => 'mergeDuplicateMemberships',
|
||||
],
|
||||
'restore run create action factory' => [
|
||||
'file' => 'app/Filament/Resources/RestoreRunResource.php',
|
||||
'action' => 'New restore run',
|
||||
'anchor' => 'public static function makeCreateAction',
|
||||
],
|
||||
'environment dashboard support request action' => [
|
||||
'file' => 'app/Filament/Pages/EnvironmentDashboard.php',
|
||||
'action' => 'requestSupport',
|
||||
'anchor' => 'private function requestSupportAction',
|
||||
],
|
||||
'environment dashboard support diagnostics action' => [
|
||||
'file' => 'app/Filament/Pages/EnvironmentDashboard.php',
|
||||
'action' => 'openSupportDiagnostics',
|
||||
'anchor' => 'private function openSupportDiagnosticsAction',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
it('requires explicit UiActionContext for representative risky scoped no-record actions', function (array $check): void {
|
||||
$path = base_path($check['file']);
|
||||
$source = file_get_contents($path);
|
||||
|
||||
expect($source)->not->toBeFalse();
|
||||
|
||||
$source = (string) $source;
|
||||
$anchor = $check['anchor'] ?? "Action::make('{$check['action']}'";
|
||||
$position = strpos($source, $anchor);
|
||||
|
||||
expect($position)
|
||||
->not->toBeFalse("Could not find {$check['action']} in {$check['file']}.");
|
||||
|
||||
$slice = substr($source, max(0, (int) $position - 500), 10000);
|
||||
|
||||
expect($slice)
|
||||
->toContain('UiEnforcement::forScopedAction')
|
||||
->and($source)
|
||||
->toContain('UiActionContext');
|
||||
})->with(fn (): array => collect(scopedUiActionContextContractChecks())
|
||||
->mapWithKeys(static fn (array $check, string $name): array => [$name => [$check]])
|
||||
->all());
|
||||
|
||||
it('does not reintroduce the guarded no-record scoped action names through UiEnforcement::forAction', function (): void {
|
||||
$checks = scopedUiActionContextContractChecks();
|
||||
$guardedFiles = collect($checks)->pluck('file')->unique()->values()->all();
|
||||
$guardedActionNames = collect($checks)
|
||||
->pluck('action')
|
||||
->reject(static fn (string $action): bool => $action === 'sync' || $action === 'New restore run')
|
||||
->map(static fn (string $action): string => preg_quote($action, '/'))
|
||||
->implode('|');
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($guardedFiles as $relativePath) {
|
||||
$path = base_path($relativePath);
|
||||
$source = file_get_contents($path);
|
||||
|
||||
if (! is_string($source) || ! str_contains($source, 'UiEnforcement::forAction')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$patterns = [
|
||||
'/UiEnforcement::forAction\s*\(.{0,1200}Action::make\(\s*[\'"](?:'.$guardedActionNames.')[\'"]/s',
|
||||
'/public static function makeSyncAction.{0,1400}UiEnforcement::forAction/s',
|
||||
'/public static function makeCreateAction.{0,1400}UiEnforcement::forAction/s',
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $source, $match, PREG_OFFSET_CAPTURE) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$line = substr_count(substr($source, 0, (int) $match[0][1]), "\n") + 1;
|
||||
$violations[] = "{$relativePath}:{$line} use UiEnforcement::forScopedAction(..., UiActionContext resolver) for risky no-record scoped actions.";
|
||||
}
|
||||
}
|
||||
|
||||
expect($violations)->toBeEmpty(implode("\n", $violations));
|
||||
});
|
||||
@ -2,19 +2,19 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -159,6 +159,26 @@ function suspendEvidenceSnapshotWorkspace(ManagedEnvironment $tenant): void
|
||||
Queue::assertPushed(GenerateEvidenceSnapshotJob::class);
|
||||
});
|
||||
|
||||
it('opens the create snapshot modal without creating a snapshot or queueing work', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
seedEvidenceDomain($tenant);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->mountAction('create_snapshot')
|
||||
->assertActionMounted('create_snapshot');
|
||||
|
||||
expect(EvidenceSnapshot::query()->count())->toBe(0);
|
||||
Queue::assertNotPushed(GenerateEvidenceSnapshotJob::class);
|
||||
});
|
||||
|
||||
it('renders the view page for an active snapshot', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -104,3 +104,24 @@
|
||||
expect(array_values(collect($coverage)->map(fn (array $row): int => (int) ($row['item_count'] ?? 0))->all()))
|
||||
->toBe(array_fill(0, count($policyTypes), 1));
|
||||
});
|
||||
|
||||
it('opens inventory sync action without creating a run or queueing work', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListInventoryItems::class)
|
||||
->mountAction('run_inventory_sync')
|
||||
->assertActionMounted('run_inventory_sync');
|
||||
|
||||
Queue::assertNotPushed(RunInventorySyncJob::class);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
@ -65,6 +65,28 @@ function getPolicyEmptyStateAction(Testable $component, string $name): ?Action
|
||||
});
|
||||
});
|
||||
|
||||
it('opens policy sync confirmation without creating a run or queueing work', function (): void {
|
||||
Queue::fake();
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListPolicies::class)
|
||||
->mountAction('sync')
|
||||
->assertActionMounted('sync');
|
||||
|
||||
Queue::assertNotPushed(SyncPoliciesJob::class);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'policy.sync')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('reuses an active policy sync run and does not enqueue twice', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -8,17 +8,17 @@
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Settings\SettingsWriter;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -26,8 +26,8 @@
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
@ -247,6 +247,32 @@ function createCurrentReviewPackForResourcePreview(ManagedEnvironment $tenant, \
|
||||
->assertActionVisible('generate_pack');
|
||||
});
|
||||
|
||||
it('opens review pack generation without creating a pack or queueing work', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
seedReviewPackEvidence($tenant);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->mountAction('generate_pack')
|
||||
->assertActionMounted('generate_pack');
|
||||
|
||||
expect(ReviewPack::query()->where('managed_environment_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('disables the generate_first action for a readonly user in the empty state', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Rbac\Actions\ResolvesUiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContextSource;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function uiActionContextProbe(?ManagedEnvironment $resolvedTenant = null): object
|
||||
{
|
||||
return new class($resolvedTenant)
|
||||
{
|
||||
use ResolvesUiActionContext;
|
||||
|
||||
private static ?ManagedEnvironment $resolvedTenant = null;
|
||||
|
||||
public function __construct(?ManagedEnvironment $resolvedTenant)
|
||||
{
|
||||
self::$resolvedTenant = $resolvedTenant;
|
||||
}
|
||||
|
||||
public function tenantContext(?ManagedEnvironment $tenant = null): UiActionContext
|
||||
{
|
||||
return self::tenantUiActionContext($tenant);
|
||||
}
|
||||
|
||||
public function workspaceContext(?Workspace $workspace = null): UiActionContext
|
||||
{
|
||||
return self::workspaceUiActionContext($workspace);
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?ManagedEnvironment
|
||||
{
|
||||
return self::$resolvedTenant;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('uses page resolver tenant context before Filament tenant fallback', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$fallbackTenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
Filament::setTenant($fallbackTenant, true);
|
||||
|
||||
$context = uiActionContextProbe($tenant)->tenantContext();
|
||||
|
||||
expect($context->tenant())->toBe($tenant)
|
||||
->and($context->source)->toBe(UiActionContextSource::PageResolver);
|
||||
});
|
||||
|
||||
it('uses explicit tenant context before fallback sources', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$fallbackTenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
Filament::setTenant($fallbackTenant, true);
|
||||
|
||||
$context = uiActionContextProbe()->tenantContext($tenant);
|
||||
|
||||
expect($context->tenant())->toBe($tenant)
|
||||
->and($context->source)->toBe(UiActionContextSource::PageResolver);
|
||||
});
|
||||
|
||||
it('uses Filament tenant as the last tenant-panel fallback', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$context = uiActionContextProbe()->tenantContext();
|
||||
|
||||
expect($context->tenant())->toBe($tenant)
|
||||
->and($context->isMissing())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns missing environment context when no tenant can be resolved', function (): void {
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$context = uiActionContextProbe()->tenantContext();
|
||||
|
||||
expect($context->isMissing())->toBeTrue()
|
||||
->and($context->missingReason())->toBe(UiActionContext::ENVIRONMENT_MISSING);
|
||||
});
|
||||
|
||||
it('builds workspace context with explicit workspace source', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$context = uiActionContextProbe()->workspaceContext($workspace);
|
||||
|
||||
expect($context->workspace())->toBe($workspace)
|
||||
->and($context->source)->toBe(UiActionContextSource::WorkspaceContext);
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\Actions\UiActionContextSource;
|
||||
use App\Support\Rbac\Actions\UiActionScope;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('builds workspace action context', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$context = UiActionContext::forWorkspace($workspace);
|
||||
|
||||
expect($context->scope)->toBe(UiActionScope::Workspace)
|
||||
->and($context->source)->toBe(UiActionContextSource::Explicit)
|
||||
->and($context->workspace())->toBe($workspace)
|
||||
->and($context->requireWorkspace())->toBe($workspace)
|
||||
->and($context->environment())->toBeNull()
|
||||
->and($context->isMissing())->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds environment action context with workspace ownership', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
|
||||
$context = UiActionContext::forEnvironment($tenant, UiActionContextSource::PageResolver);
|
||||
|
||||
expect($context->scope)->toBe(UiActionScope::Environment)
|
||||
->and($context->source)->toBe(UiActionContextSource::PageResolver)
|
||||
->and($context->environment())->toBe($tenant)
|
||||
->and($context->tenant())->toBe($tenant)
|
||||
->and($context->requireEnvironment())->toBe($tenant)
|
||||
->and($context->workspace()?->getKey())->toBe($tenant->workspace_id)
|
||||
->and($context->isMissing())->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds record context from tenant records and loaded tenant relations', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$policy = Policy::factory()->for($tenant)->create()->load('tenant');
|
||||
|
||||
$tenantContext = UiActionContext::forRecord($tenant);
|
||||
$policyContext = UiActionContext::forRecord($policy);
|
||||
|
||||
expect($tenantContext->scope)->toBe(UiActionScope::Record)
|
||||
->and($tenantContext->environment())->toBe($tenant)
|
||||
->and($tenantContext->record())->toBe($tenant)
|
||||
->and($policyContext->scope)->toBe(UiActionScope::Record)
|
||||
->and($policyContext->environment()?->getKey())->toBe($tenant->getKey())
|
||||
->and($policyContext->record())->toBe($policy);
|
||||
});
|
||||
|
||||
it('builds system context without workspace or environment requirements', function (): void {
|
||||
$context = UiActionContext::forSystem();
|
||||
|
||||
expect($context->scope)->toBe(UiActionScope::System)
|
||||
->and($context->workspace())->toBeNull()
|
||||
->and($context->environment())->toBeNull()
|
||||
->and($context->isMissing())->toBeFalse();
|
||||
|
||||
expect(fn () => $context->requireWorkspace())->toThrow(RuntimeException::class, UiActionContext::WORKSPACE_MISSING);
|
||||
expect(fn () => $context->requireEnvironment())->toThrow(RuntimeException::class, UiActionContext::ENVIRONMENT_MISSING);
|
||||
});
|
||||
|
||||
it('represents missing context with stable reasons', function (): void {
|
||||
$workspaceContext = UiActionContext::forWorkspace(null, UiActionContextSource::WorkspaceContext);
|
||||
$environmentContext = UiActionContext::forEnvironment(null, UiActionContextSource::PageResolver);
|
||||
$genericContext = UiActionContext::missing('');
|
||||
|
||||
expect($workspaceContext->scope)->toBe(UiActionScope::Missing)
|
||||
->and($workspaceContext->source)->toBe(UiActionContextSource::WorkspaceContext)
|
||||
->and($workspaceContext->missingReason())->toBe(UiActionContext::WORKSPACE_MISSING)
|
||||
->and($environmentContext->missingReason())->toBe(UiActionContext::ENVIRONMENT_MISSING)
|
||||
->and($genericContext->missingReason())->toBe(UiActionContext::CONTEXT_MISSING);
|
||||
|
||||
expect(fn () => $environmentContext->requireEnvironment())->toThrow(RuntimeException::class, UiActionContext::ENVIRONMENT_MISSING);
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\Actions\UiActionContext;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps missing scoped context visible but disabled with neutral copy', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$action = UiEnforcement::forScopedAction(
|
||||
Action::make('sync')->action(fn (): null => null),
|
||||
UiActionContext::missing(UiActionContext::ENVIRONMENT_MISSING),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
expect($action->isVisible())->toBeTrue()
|
||||
->and($action->isDisabled())->toBeTrue()
|
||||
->and($action->getTooltip())->toBe(UiTooltips::CONTEXT_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it('enables scoped actions for members with capability when explicit context is present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$action = UiEnforcement::forScopedAction(
|
||||
Action::make('sync')->action(fn (): null => null),
|
||||
UiActionContext::forEnvironment($tenant),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
expect($action->isVisible())->toBeTrue()
|
||||
->and($action->isDisabled())->toBeFalse()
|
||||
->and($action->getTooltip())->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps explicit scoped context distinct from non-membership', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$action = UiEnforcement::forScopedAction(
|
||||
Action::make('sync')->action(fn (): null => null),
|
||||
UiActionContext::forEnvironment($tenant),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply();
|
||||
|
||||
expect($action->isVisible())->toBeFalse()
|
||||
->and($action->isDisabled())->toBeTrue()
|
||||
->and($action->getTooltip())->toBeNull();
|
||||
});
|
||||
|
||||
it('preserves record-backed action resolution without explicit no-record context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$action = UiEnforcement::forTableAction(
|
||||
Action::make('view')->action(fn (): null => null),
|
||||
$tenant,
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_VIEW)
|
||||
->apply();
|
||||
|
||||
expect($action->isVisible())->toBeTrue()
|
||||
->and($action->isDisabled())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
# Requirements Checklist: Spec 363 - Explicit UiActionContext Contract for Scoped No-Record Actions
|
||||
|
||||
**Purpose**: Preparation analysis for Spec 363 readiness
|
||||
**Created**: 2026-06-07
|
||||
**Feature**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/363-explicit-uiactioncontext-contract/spec.md`
|
||||
|
||||
## Candidate Selection And Guardrails
|
||||
|
||||
- [x] CHK001 The candidate source is explicit: the direct user-provided Spec 363 draft from `/Users/ahmeddarrazi/.codex/attachments/36f3aea8-0303-4548-a83c-9f1cdd15f527/pasted-text.txt`.
|
||||
- [x] CHK002 No `specs/363-*` package existed before Spec Kit branch creation.
|
||||
- [x] CHK003 No existing `363-*` branch was found before Spec Kit branch creation.
|
||||
- [x] CHK004 The active candidate queue's empty-state note is respected; this package is an intentional manual promotion, not an auto-selected queue item.
|
||||
- [x] CHK005 Related completed specs were checked and treated correctly: Specs 338, 340, 358, 359, 360, 361, and 362 remain historical/context packages and were not modified.
|
||||
- [x] CHK006 Repo-truth deviations from the user draft are recorded in `spec.md`, especially the explicit preparation boundary, no application implementation, and the guard that broader OperationRun authorization hardening becomes a follow-up only if repo-verified.
|
||||
|
||||
## Required Prep Artifacts
|
||||
|
||||
- [x] CHK007 `spec.md` exists and contains no template placeholders.
|
||||
- [x] CHK008 `plan.md` exists and is repo-aware.
|
||||
- [x] CHK009 `tasks.md` exists and is ordered, small, and verifiable.
|
||||
- [x] CHK010 This checklist exists.
|
||||
|
||||
## Spec Quality
|
||||
|
||||
- [x] CHK011 Spec Candidate Check is completed and scores above the approval threshold.
|
||||
- [x] CHK012 The spec explains why new request-time context classes and enums are justified under PROP-001 / ABSTR-001 / BLOAT-001.
|
||||
- [x] CHK013 The spec keeps scope bounded to scoped no-record action context, representative retrofits, first-click modal/no-run tests, and a static recurrence guard.
|
||||
- [x] CHK014 The spec keeps `OperationRun`, RBAC, policies, migrations, panel providers, assets, routes, capabilities, and global search unchanged unless a later implementation proves a blocker and updates artifacts first.
|
||||
- [x] CHK015 The spec preserves record-backed action context as a valid source and does not force broad record action churn.
|
||||
|
||||
## Plan / Task Alignment
|
||||
|
||||
- [x] CHK016 The plan identifies actual repo surfaces likely to change, including `UiEnforcement`, `ResolvesPanelTenantContext`, representative Filament action classes, and test/guard files.
|
||||
- [x] CHK017 The plan keeps Filament v5 / Livewire v4 posture and provider-registration location visible.
|
||||
- [x] CHK018 The plan explicitly states no migration, no new panel/provider, no global-search change, and no new asset strategy are expected.
|
||||
- [x] CHK019 The tasks start with repo truth and failing tests before runtime edits.
|
||||
- [x] CHK020 The tasks include anti-creep guardrails against compatibility shims, OperationRun redesign, product-feature expansion, and broad record-backed action migration.
|
||||
- [x] CHK021 The tasks include final validation and close-out requirements.
|
||||
|
||||
## UI / Action Lifecycle Coverage
|
||||
|
||||
- [x] CHK022 UI Surface Impact is completed and classifies existing action/page behavior changes without claiming a new page family.
|
||||
- [x] CHK023 Dangerous/high-impact action implications are explicit: destructive/high-impact actions keep confirmation, authorization, audit, and tests.
|
||||
- [x] CHK024 First-click modal mount and modal-no-run/no-job behavior are explicit acceptance criteria and tasks.
|
||||
- [x] CHK025 Missing-context UX is distinct from permission denial and avoids low-level Livewire/Filament copy.
|
||||
- [x] CHK026 OperationRun start behavior is limited to submit/execute and reuses existing OperationRun UX helpers.
|
||||
|
||||
## Test Governance
|
||||
|
||||
- [x] CHK027 The declared test families are the narrowest honest proof: Unit + Feature/Filament-Livewire + Architecture/guard; Browser optional only if visible hierarchy/copy changes materially.
|
||||
- [x] CHK028 Planned validation commands are explicit and scoped to Spec 363 plus directly related existing action tests.
|
||||
- [x] CHK029 No heavy-governance, PGSQL-only schema, or browser family is introduced by default.
|
||||
- [x] CHK030 Shared test helper growth is bounded and justified only if it reduces duplication across at least two retrofitted actions.
|
||||
|
||||
## Filament v5 Blueprint Output Contract
|
||||
|
||||
- [x] CHK031 Livewire v4.0+ compliance is stated in `spec.md`, `plan.md`, and `tasks.md`.
|
||||
- [x] CHK032 Provider registration location remains `apps/platform/bootstrap/providers.php`; no provider change is planned.
|
||||
- [x] CHK033 No globally searchable resource is added or changed; global search behavior remains unchanged.
|
||||
- [x] CHK034 Destructive/high-impact action confirmation + authorization handling is explicitly preserved.
|
||||
- [x] CHK035 Asset strategy remains unchanged; no new asset registration or `filament:assets` deployment impact is expected from this spec.
|
||||
- [x] CHK036 Testing plan names Filament/Livewire action tests, static guard tests, and optional Browser smoke boundary.
|
||||
|
||||
## Readiness Gate Outcome
|
||||
|
||||
- [x] CHK037 Candidate Selection Gate passes.
|
||||
- [x] CHK038 Spec Readiness Gate passes.
|
||||
- [x] CHK039 Runtime implementation has not started in this preparation step.
|
||||
- [x] CHK040 Recommended next step is implementation, not more prep.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] CHK041 Outcome class: acceptable-special-case
|
||||
- [x] CHK042 Workflow outcome: keep
|
||||
- [x] CHK043 Final note location: active feature PR close-out entry `Guardrail / Smoke Coverage`
|
||||
- [x] CHK044 Preparation analyze result: pass via repo-based cross-artifact review; no standalone local `speckit.analyze` generator command was exposed in this repo surface beyond prompts and agent instructions.
|
||||
- [x] CHK045 Tooling note: Spec Kit branch/spec creation succeeded via `create-new-feature.sh`, `setup-plan.sh` generated the plan file, and `tasks.md` plus this checklist were authored manually to match the repo's Spec Kit templates and agent instructions.
|
||||
252
specs/363-explicit-uiactioncontext-contract/plan.md
Normal file
252
specs/363-explicit-uiactioncontext-contract/plan.md
Normal file
@ -0,0 +1,252 @@
|
||||
# Implementation Plan: Spec 363 - Explicit UiActionContext Contract for Scoped No-Record Actions
|
||||
|
||||
**Branch**: `feat/363-explicit-uiactioncontext-contract` | **Preparation Branch**: `363-explicit-uiactioncontext-contract` | **Date**: 2026-06-07 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/363-explicit-uiactioncontext-contract/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/363-explicit-uiactioncontext-contract/spec.md`
|
||||
|
||||
**Note**: This plan is repo-aware and preparation-only. No application implementation is performed in this step.
|
||||
|
||||
## Summary
|
||||
|
||||
Add an explicit action-context contract for workspace- and environment-scoped no-record Filament actions.
|
||||
|
||||
The implementation should:
|
||||
|
||||
- introduce a narrow request-time `UiActionContext` contract
|
||||
- harden `UiEnforcement` so scoped no-record actions cannot silently use implicit `Filament::getTenant()` as product scope truth
|
||||
- preserve record-backed action behavior where scope comes from the record
|
||||
- retrofit representative latent-risk no-record actions
|
||||
- add first-click modal/no-run test helpers and action lifecycle tests
|
||||
- add a static guard that fails future risky scoped no-record actions without explicit context
|
||||
|
||||
No migrations, panel/provider changes, global-search changes, asset strategy changes, or new product workflows are planned.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1
|
||||
**Storage**: PostgreSQL via Sail, but no schema changes are planned
|
||||
**Testing**: Pest Unit + Feature/Filament-Livewire + Architecture/guard; Browser optional only if visible UI hierarchy/copy changes materially
|
||||
**Validation Lanes**: fast-feedback, confidence, browser optional
|
||||
**Target Platform**: Laravel monolith in Sail / Dokploy container workflow
|
||||
**Project Type**: single web application (`apps/platform`)
|
||||
**Performance Goals**: no Graph calls during render/action-state/modal mount; static scan remains bounded and deterministic
|
||||
**Constraints**: no new persistence, no operation type, no capability string, no panel/provider, no asset registration, no global-search change, no compatibility shim, no broad record-backed action rewrite
|
||||
**Scale/Scope**: no-record scoped actions first, plus representative action retrofits and static recurrence guard
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed existing actions on existing pages
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
|
||||
- Inventory list `run_inventory_sync`
|
||||
- Policy list/header sync and related Policy Sync start surface
|
||||
- Entra Groups `sync_groups`
|
||||
- Evidence Snapshot `create_snapshot` and `create_first_snapshot`
|
||||
- Review Pack `generate_pack` and `generate_first`
|
||||
- Environment Review `create_review`
|
||||
- Environment Diagnostics `bootstrapOwner` and `mergeDuplicateMemberships`
|
||||
- Restore Run create entrypoint and create wizard
|
||||
- Environment Dashboard support request / support diagnostics actions
|
||||
- **No-impact class, if applicable**: N/A, because existing high-impact actions change context behavior
|
||||
- **Native vs custom classification summary**: native Filament pages/resources/actions with shared RBAC enforcement
|
||||
- **Shared-family relevance**: header/page/empty-state action family, RBAC UI enforcement family, OperationRun start family
|
||||
- **State layers in scope**: page render, action visibility, action disabled state, first-click modal mount, submit/execute handler
|
||||
- **Audience modes in scope**: operator-MSP and support-platform
|
||||
- **Decision/diagnostic/raw hierarchy plan**: show safe action state and missing-context copy only; keep low-level Livewire/Filament request details out of UI
|
||||
- **Raw/support gating plan**: no raw request, referer, token, Graph, credential, or payload data in visible UI
|
||||
- **One-primary-action / duplicate-truth control**: do not duplicate run outcome truth in action modals; after submit reuse existing OperationRun links/toasts
|
||||
- **Handling modes by drift class or surface**: review-mandatory for any scoped no-record action that opens a modal, dispatches work, creates an `OperationRun`, or mutates state
|
||||
- **Repository-signal treatment**: record-backed actions are context only unless the static guard proves they behave like no-record scoped actions
|
||||
- **Special surface test profiles**: standard-native-filament plus action-lifecycle-contract
|
||||
- **Required tests or manual smoke**: Unit + Feature/Filament-Livewire + static guard; Browser only if implementation changes visible copy/hierarchy materially
|
||||
- **Exception path and spread control**: bounded static-guard exceptions must name the file/action and why record/page scope is safe
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **UI/Productization coverage decision**: no new UI coverage registry update is expected because no new route/page family is introduced
|
||||
- **Coverage artifacts to update**: none by default
|
||||
- **No-impact rationale**: N/A
|
||||
- **Navigation / Filament provider-panel handling**: no panel provider, panel path, navigation, or cluster change
|
||||
- **Screenshot or page-report need**: no by default
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**:
|
||||
- `apps/platform/app/Support/Rbac/UiEnforcement.php`
|
||||
- `apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php` as reference/comparison only unless small shared context support is justified
|
||||
- new `apps/platform/app/Support/Rbac/Actions/` contract classes if implementation confirms this namespace is still best
|
||||
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- selected existing Filament resources/pages/actions
|
||||
- selected existing action tests and new guard/test helper
|
||||
- **Shared abstractions reused**: existing RBAC helpers, capability resolvers, policies/gates, page context resolvers, OperationRun start helpers, Filament action testing helpers
|
||||
- **New abstraction introduced? why?**: yes, a narrow `UiActionContext` request-time contract is allowed because the problem is RBAC/isolation/queue legitimacy-critical and already repo-verified across multiple surfaces
|
||||
- **Why the existing abstraction was sufficient or insufficient**: `UiEnforcement` is the right shared path, but it currently allows ambiguous nullable no-record context and implicit Filament tenant fallback
|
||||
- **Bounded deviation / spread control**: keep new classes under an RBAC/action namespace; do not create a generic UI framework, presenter, workflow engine, or action catalog rewrite
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, start/mount/submit lifecycle only
|
||||
- **Central contract reused**: existing `OperationRunService`, run dispatch handlers, `OperationRunLinks`, `OperationUxPresenter`, and `OpsUxBrowserEvents`
|
||||
- **Delegated UX behaviors**: queued toasts, run links, run-enqueued browser events, and terminal lifecycle notifications remain unchanged
|
||||
- **Surface-owned behavior kept local**: action form values and confirmation/missing-context copy
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception path**: if broader execution reauthorization is weaker than UI gating, stop and propose `OperationRun Start Authorization Contract Hardening`
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no new provider seam
|
||||
- **Provider-owned seams**: existing Graph/provider jobs and services remain provider-owned and are not modified beyond receiving explicit, reauthorized scope
|
||||
- **Platform-core seams**: workspace/environment action context and RBAC UI enforcement
|
||||
- **Neutral platform terms / contracts preserved**: workspace, environment, record, system, product scope, action context
|
||||
- **Retained provider-specific semantics and why**: only current Microsoft/Graph semantics inside existing actions/jobs remain
|
||||
- **Bounded extraction or follow-up path**: none unless broader OperationRun execution authorization weakness is discovered
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation starts. Re-check if scope changes.*
|
||||
|
||||
- Inventory-first: PASS. No inventory truth changes; Inventory Sync action context becomes explicit only.
|
||||
- Read/write separation: PASS. Modal open remains non-mutating; submit/execute must reauthorize before writes/jobs.
|
||||
- Graph contract path: PASS. No new Graph calls; render/mount must remain no-Graph.
|
||||
- Deterministic capabilities: PASS. Existing capabilities remain authoritative.
|
||||
- Workspace and tenant isolation: PASS. Explicit context improves workspace/environment scope safety.
|
||||
- RBAC-UX: PASS. UI state remains affordance only; server-side policies/gates remain execution truth.
|
||||
- TEST-GOV-001: PASS. Unit + Feature/action + static guard are the narrowest honest proof.
|
||||
- PROP-001 / ABSTR-001: PASS only because the new abstraction is security/isolation/action-lifecycle critical and already justified by repeated repo evidence.
|
||||
- PERSIST-001 / STATE-001: PASS. No new persisted truth or persisted status family is introduced.
|
||||
- XCUT-001 / LAYER-001: PASS. Extend the existing shared `UiEnforcement` path instead of adding local one-off action rules.
|
||||
- UI-COV-001: PASS. Existing reachable surfaces are classified; no new page family is expected.
|
||||
- LEAN-001: PASS. No compatibility shim for old no-record fallback behavior.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**:
|
||||
- Unit: `UiActionContext`, scope/source enums, resolver helpers, missing-context behavior, `UiEnforcement` context resolution
|
||||
- Feature/Filament-Livewire: selected page/resource actions, first-click modal mount, no-run/no-job on modal open, submit reauthorization, wrong-context/readonly behavior
|
||||
- Architecture/Feature guard: static source scan for risky scoped no-record actions without explicit context
|
||||
- Browser: optional smoke only if visible hierarchy/copy changes materially
|
||||
- **Affected validation lanes**: fast-feedback, confidence, browser optional
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the behavior is request-time action lifecycle and static source guard behavior, not DB schema or provider API behavior
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Rbac/Actions tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Architecture/ScopedUiActionContextContractTest.php tests/Feature/Filament/InventoryItemResourceTest.php tests/Feature/PolicySyncStartSurfaceTest.php tests/Feature/RunStartAuthorizationTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupAdminScopeTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: keep workspace/environment/member/capability setup explicit; do not introduce full-context defaults in shared helpers
|
||||
- **Expensive defaults or shared helper growth introduced?**: no
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none by default
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament plus action-lifecycle-contract
|
||||
- **Closing validation and reviewer handoff**: reviewers should verify no hidden `Filament::getTenant()` product-scope fallback for scoped no-record actions, no modal-open side effects, and no compatibility path
|
||||
- **Budget / baseline / trend follow-up**: none expected
|
||||
- **Review-stop questions**: did implementation broaden into record-backed action migration, OperationRun redesign, UI redesign, or product features?
|
||||
- **Escalation path**: document-in-feature for contained static-guard false positives; follow-up-spec for broader execution authorization weakness; reject-or-split for scope creep
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
|
||||
- **Why no dedicated follow-up spec is needed now**: the root issue is explicit enough to solve as one bounded action-context contract
|
||||
|
||||
## Repo-Verified Runtime Surfaces Likely Affected
|
||||
|
||||
### Core RBAC / Context
|
||||
|
||||
- `apps/platform/app/Support/Rbac/UiEnforcement.php`
|
||||
- `apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php` (context/reference only unless sharing is justified)
|
||||
- `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
- new `apps/platform/app/Support/Rbac/Actions/UiActionContext.php`
|
||||
- new `apps/platform/app/Support/Rbac/Actions/UiActionScope.php`
|
||||
- new `apps/platform/app/Support/Rbac/Actions/UiActionContextSource.php`
|
||||
- new `apps/platform/app/Support/Rbac/Actions/ResolvesUiActionContext.php`
|
||||
|
||||
### Representative Action Surfaces
|
||||
|
||||
- `apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
|
||||
- `apps/platform/app/Filament/Resources/PolicyResource.php`
|
||||
- `apps/platform/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
|
||||
- `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php`
|
||||
- `apps/platform/app/Filament/Resources/ReviewPackResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
|
||||
- `apps/platform/app/Filament/Pages/EnvironmentDiagnostics.php`
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- `apps/platform/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`
|
||||
- `apps/platform/app/Filament/Pages/EnvironmentDashboard.php`
|
||||
|
||||
### Tests / Guards
|
||||
|
||||
- `apps/platform/tests/Unit/Support/Rbac/Actions/*`
|
||||
- `apps/platform/tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php`
|
||||
- `apps/platform/tests/Feature/Architecture/ScopedUiActionContextContractTest.php`
|
||||
- `apps/platform/tests/Support/Filament/ScopedActionAssertions.php`
|
||||
- existing action tests under `apps/platform/tests/Feature/Filament`, `apps/platform/tests/Feature/Evidence`, `apps/platform/tests/Feature/ReviewPack`, and `apps/platform/tests/Feature/RunStartAuthorizationTest.php`
|
||||
- existing support action tests under `apps/platform/tests/Feature/SupportDiagnostics` and `apps/platform/tests/Feature/SupportRequests`
|
||||
|
||||
## Technical Approach
|
||||
|
||||
1. Re-read this spec, plan, tasks, checklist, the constitution, and relevant guidelines before runtime edits.
|
||||
2. Re-verify the current action callsites and exact action names.
|
||||
3. Add failing Unit coverage for context value objects and `UiEnforcement` scoped behavior.
|
||||
4. Add failing Feature/Filament coverage for the known latent-risk actions.
|
||||
5. Add the narrow `UiActionContext` contract and helper/trait.
|
||||
6. Harden `UiEnforcement` to require explicit context for scoped no-record actions while preserving record-backed paths.
|
||||
7. Retrofit representative actions to pass explicit context and reauthorize on submit.
|
||||
8. Add reusable first-click modal/no-run helper only where it reduces duplication.
|
||||
9. Add the static guard with actionable failures.
|
||||
10. Run focused tests, Pint dirty, and diff checks.
|
||||
|
||||
## Risk Controls
|
||||
|
||||
- Keep the first implementation scoped to no-record workspace/environment actions.
|
||||
- Do not remove valid record-backed context resolution.
|
||||
- Do not allow `Filament::getTenant()` as fallback product-scope truth for scoped no-record actions.
|
||||
- Keep modal open non-mutating.
|
||||
- Keep server-side authorization in handlers/services/policies.
|
||||
- Keep missing-context copy enterprise-safe and localizable later.
|
||||
- No new migrations, assets, global search, panel provider, navigation, capability, operation type, or route.
|
||||
- Stop if implementation needs persistence, broad OperationRun redesign, or many static-guard false positives across record-backed actions.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Baseline and Repo-Truth Inventory
|
||||
|
||||
Confirm current branch/worktree, re-read dependencies, and inventory exact callsites/action names before edits.
|
||||
|
||||
### Phase 2: Contract and Unit Tests
|
||||
|
||||
Add the action-context value object/enums/trait or equivalent and Unit coverage for workspace, environment, record, system, and missing context behavior.
|
||||
|
||||
### Phase 3: UiEnforcement Contract
|
||||
|
||||
Add explicit scoped-action API or compatible signature changes, remove implicit scoped no-record fallback, preserve record-backed behavior, and cover missing-context classification.
|
||||
|
||||
### Phase 4: Representative Action Retrofits
|
||||
|
||||
Retrofit Inventory Sync, Policy Sync, Entra Group Sync, Evidence Snapshot, Review Pack, Environment Review, Environment Diagnostics, Restore create, and Environment Dashboard support actions.
|
||||
|
||||
### Phase 5: Test Helper and Static Guard
|
||||
|
||||
Add the reusable first-click modal/no-run helper and static architecture guard.
|
||||
|
||||
### Phase 6: Validation and Close-Out
|
||||
|
||||
Run focused tests, optional browser smoke only if needed, Pint dirty, `git diff --check`, and record no-migration/no-asset/no-provider/global-search status.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/363-explicit-uiactioncontext-contract/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Application (later implementation only)
|
||||
|
||||
```text
|
||||
apps/platform/app/Support/Rbac/Actions/
|
||||
apps/platform/app/Support/Rbac/UiEnforcement.php
|
||||
apps/platform/app/Filament/...
|
||||
apps/platform/tests/...
|
||||
```
|
||||
|
||||
No application files are changed during this preparation step.
|
||||
462
specs/363-explicit-uiactioncontext-contract/spec.md
Normal file
462
specs/363-explicit-uiactioncontext-contract/spec.md
Normal file
@ -0,0 +1,462 @@
|
||||
# Feature Specification: Spec 363 - Explicit UiActionContext Contract for Scoped No-Record Actions
|
||||
|
||||
**Feature Branch**: `feat/363-explicit-uiactioncontext-contract`
|
||||
**Preparation Branch**: `363-explicit-uiactioncontext-contract`
|
||||
**Created**: 2026-06-07
|
||||
**Status**: Implemented
|
||||
**Type**: Filament/Livewire action-context safety / RBAC UX hardening / no new persistence
|
||||
**Runtime posture**: Harden workspace- and environment-scoped no-record Filament actions so render, first-click modal mount, and submit execution use explicit product scope instead of implicit Filament tenant fallback.
|
||||
**Input**: Direct user-provided Spec 363 draft in `/Users/ahmeddarrazi/.codex/attachments/36f3aea8-0303-4548-a83c-9f1cdd15f527/pasted-text.txt` plus repo inspection of Spec 362 root-cause audit and current action/RBAC seams.
|
||||
|
||||
## Dependencies And Historical Context
|
||||
|
||||
This package follows the action-context root-cause audit produced during Spec 362:
|
||||
|
||||
- `specs/362-sync-capture-backup-operation-semantics/artifacts/action-context-root-cause-audit.md` classifies the issue as **Case C - Systemic Action Context Contract Gap**.
|
||||
- The audit shows that Inventory Sync and Policy Sync already needed an explicit page resolver pattern to make first-click modal mounting reliable under `/livewire/update`.
|
||||
- The same latent class remains on no-record environment/workspace actions that can open modals, create `OperationRun` records, dispatch jobs, generate artifacts, or mutate support/diagnostics state.
|
||||
|
||||
Current repo truth already provides useful foundations:
|
||||
|
||||
- `App\Support\Rbac\UiEnforcement` centralizes environment-scoped UI affordance rules.
|
||||
- `App\Support\Rbac\WorkspaceUiEnforcement` already uses a stricter explicit workspace source pattern.
|
||||
- `App\Filament\Concerns\ResolvesPanelTenantContext` can resolve panel context across Livewire referer requests.
|
||||
- `OperationRun` and OperationRun UX helpers already make queued work observable.
|
||||
- Existing Filament/Livewire tests already use `mountAction()`, `callAction()`, queue fakes, and no-run assertions for Inventory and Policy Sync.
|
||||
|
||||
This spec turns those local fixes into an explicit product contract.
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Workspace- and environment-scoped no-record Filament actions can lose their original route/page context on Livewire update requests. When `UiEnforcement` falls back to `Filament::getTenant()` as product scope truth, an authorized operator can see silent disabled/hidden actions, modals that do not open, or render/submit scope divergence.
|
||||
- **Today's failure**:
|
||||
- Initial `/admin/...` page render can have enough context, while first-click `/livewire/update` action mounting does not.
|
||||
- Missing product context is collapsed into ordinary non-membership or missing-capability state.
|
||||
- Modal-open tests and "no OperationRun/job on modal mount" tests exist only for selected local fixes.
|
||||
- Latent no-record actions such as Entra Group Sync, Evidence Snapshot create, Review Pack generate, Environment Review create, Environment Diagnostics repair, Restore create entry, and Environment Dashboard support actions still need the same contract.
|
||||
- **User-visible improvement**: Authorized operators get reliable first-click modals and truthful disabled/missing-context states. High-impact actions create runs, dispatch jobs, generate artifacts, or mutate records only on submit after explicit reauthorization with the same workspace/environment context.
|
||||
- **Smallest enterprise-capable version**:
|
||||
- introduce a narrow `UiActionContext` contract for action product scope
|
||||
- make scoped no-record action enforcement require explicit context
|
||||
- preserve record-backed action behavior where scope comes from the record
|
||||
- retrofit representative latent-risk no-record actions
|
||||
- add reusable first-click modal/no-run test support
|
||||
- add a static guard for risky scoped no-record actions without explicit context
|
||||
- **Explicit non-goals**:
|
||||
- no migrations or persisted action-context table
|
||||
- no new product feature
|
||||
- no UI redesign or new route family
|
||||
- no OperationRun model redesign
|
||||
- no RBAC architecture rewrite
|
||||
- no broad rewrite of record-backed row/table/detail actions
|
||||
- no compatibility mode for old scoped no-record actions
|
||||
- no Filament panel/provider change
|
||||
- no global-search behavior change
|
||||
- no asset pipeline change
|
||||
- no broad support desk, billing, promotion, AI, or governance-inbox feature work
|
||||
- **Permanent complexity imported**: one value object, two small enums, one resolver trait or equivalent helper, one explicit scoped-action API on `UiEnforcement`, one reusable test helper, and one static architecture/guard test. No new persistence, operation type, capability string, or cross-domain UI framework is introduced.
|
||||
- **Why now**: Spec 362 proved this is not a local Inventory/Policy bug. New roadmap/productization work is likely to add modal-first, approval, support, entitlement, and OperationRun-starting actions; those should not inherit an implicit context fallback.
|
||||
- **Why not local**: A per-page resolver fix already repeated once. Continuing local fixes would keep every future no-record action responsible for rediscovering the same Livewire transport edge case and would leave no static guard against recurrence.
|
||||
- **Approval class**: Core Enterprise.
|
||||
- **Red flags triggered**: new abstraction and enum family, static guard, cross-cutting action-contract change. **Defense**: the abstraction is required for RBAC, tenant/workspace isolation, queue/job legitimacy, and operator trust; scope is limited to no-record scoped actions first and preserves record-backed paths.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve.
|
||||
|
||||
## Candidate Source And Completed-Spec Guardrail
|
||||
|
||||
- **Candidate source**:
|
||||
- direct user-provided Spec 363 draft in `pasted-text.txt`
|
||||
- Spec 362 root-cause audit artifact under `specs/362-sync-capture-backup-operation-semantics/artifacts/action-context-root-cause-audit.md`
|
||||
- repo-verified current action patterns under `apps/platform/app/Filament` and `apps/platform/app/Support/Rbac`
|
||||
- **Queue boundary**: `docs/product/spec-candidates.md` states that no safe automatic next-best-prep target remains in the active queue. This package is an intentional manual promotion from direct user input, not an auto-selected backlog item.
|
||||
- **Completed-spec check result**:
|
||||
- no `specs/363-*` package existed before this prep
|
||||
- no `363-*` branch existed before this prep
|
||||
- runtime implementation must run from `feat/363-explicit-uiactioncontext-contract` based on `dev`; the current prep branch is not the final PR branch shape
|
||||
- Spec 362 is treated as implemented/root-cause context and is not modified
|
||||
- Specs 338, 340, 358, 359, 360, 361, and 362 are dependency/history context only and must not be rewritten or normalized here
|
||||
- completed implementation close-out, validation, smoke, and completed task markers in related specs remain historical evidence
|
||||
- **Close alternatives deferred**:
|
||||
- `OperationRun Start Authorization Contract Hardening` is deferred unless implementation proves execution authorization is weaker than the UI gate across more than this context class
|
||||
- full migration of every record-backed row/table/detail action is deferred because record-derived scope is a different, currently valid context source
|
||||
- customer-review, governance-inbox, promotion, commercial-entitlement, support-desk, and AI runtime productization remain future consumers of this contract, not hidden scope
|
||||
- **Smallest viable implementation slice**: explicit scoped-action context contract, representative no-record action retrofit, reusable tests, and static recurrence guard.
|
||||
|
||||
## Summary
|
||||
|
||||
This feature makes product scope explicit for workspace- and environment-scoped no-record Filament actions.
|
||||
|
||||
It does not replace Filament tenancy or record-backed authorization. It says that a no-record action that depends on workspace/environment scope must be given that scope explicitly at render, modal mount, and submit time. Missing context is a distinct fail-closed state, not an ordinary "you are not a member" or "you lack permission" state.
|
||||
|
||||
## Business/Product Value
|
||||
|
||||
- Prevents trust-damaging admin UX failures such as first-click modal misses, mysteriously disabled actions, and context-dependent action state drift.
|
||||
- Makes high-impact sync, evidence, review, restore, diagnostics, and support actions safer before broader productization work adds more modal-first workflows.
|
||||
- Reduces repeated implementation drift by making explicit action context a shared contract and a testable guardrail.
|
||||
|
||||
## Primary Users / Operators
|
||||
|
||||
- Tenant/MSP operators who start sync, evidence, review, restore, diagnostics, and support workflows from Filament admin surfaces.
|
||||
- Workspace owners/managers who expect capability-gated actions to be stable and honest.
|
||||
- Platform/support operators who need missing-context states to be diagnosable without exposing low-level transport details in the UI.
|
||||
|
||||
## Roadmap Relationship
|
||||
|
||||
This is governance and architecture hardening for the current sellable platform path.
|
||||
|
||||
It directly protects future features that will add modal-first and OperationRun-starting actions:
|
||||
|
||||
- Customer Review Workspace completion
|
||||
- Decision-Based Governance Inbox
|
||||
- Cross-Tenant Compare and Promotion
|
||||
- Commercial entitlement and billing-state gates
|
||||
- External Support Desk / PSA Handoff
|
||||
- governed AI execution surfaces
|
||||
|
||||
Those features should build on explicit action context rather than repeating the current local resolver pattern.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view plus workspace/environment action scope
|
||||
- **Primary Routes**:
|
||||
- existing `/admin/...` workspace-owned and environment-scoped Filament pages that expose no-record header/page/empty-state actions
|
||||
- existing environment resource pages for Inventory, Policies, Entra Groups, Evidence Snapshots, Review Packs, Environment Reviews, Restore Runs, Environment Diagnostics, and Environment Dashboard
|
||||
- **Data Ownership**:
|
||||
- no new table, column, or persisted truth
|
||||
- `Workspace` remains primary SaaS context
|
||||
- `ManagedEnvironment` remains managed target context inside a workspace
|
||||
- existing `OperationRun`, audit, evidence, review, backup, restore, and support records keep their current ownership
|
||||
- **RBAC**:
|
||||
- UI visibility/disabled state continues to use `UiEnforcement` or `WorkspaceUiEnforcement`
|
||||
- execution handlers must reauthorize server-side using the explicit workspace/environment context
|
||||
- non-members and wrong-scope actors remain deny-as-not-found where policy/route semantics require it
|
||||
- readonly and entitlement states must use the same explicit context used by modal and submit paths
|
||||
|
||||
For canonical-view specs:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: no-record action scope must come from an explicit page, workspace, environment, or record context resolver. It must not silently use remembered environment state or `Filament::getTenant()` as product scope truth.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: every submitted action must resolve a workspace/environment context that belongs to the current workspace and actor entitlement before creating runs, dispatching jobs, generating artifacts, or mutating records.
|
||||
|
||||
## 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
|
||||
- [ ] New modal/drawer/wizard/action added
|
||||
- [ ] 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 *(mandatory when UI Surface Impact is not "No UI surface impact")*
|
||||
|
||||
- **Route/page/surface**:
|
||||
- `App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems`
|
||||
- `App\Filament\Resources\PolicyResource`
|
||||
- `App\Filament\Resources\PolicyResource\Pages\ListPolicies`
|
||||
- `App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups`
|
||||
- `App\Filament\Resources\EvidenceSnapshotResource` and `Pages\ListEvidenceSnapshots`
|
||||
- `App\Filament\Resources\ReviewPackResource`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
- `App\Filament\Pages\EnvironmentDiagnostics`
|
||||
- `App\Filament\Resources\RestoreRunResource`
|
||||
- `App\Filament\Pages\EnvironmentDashboard`
|
||||
- **Current or new page archetype**: existing native Filament resource/list/page/action surfaces
|
||||
- **Design depth**: Domain Pattern Surface
|
||||
- **Repo-truth level**: repo-verified
|
||||
- **Existing pattern reused**: `UiEnforcement`, `WorkspaceUiEnforcement`, `ResolvesPanelTenantContext`, existing Filament action/modal testing patterns, existing OperationRun UX helpers
|
||||
- **New pattern required**: small action-context contract and static guard; no new page pattern or visual redesign
|
||||
- **Screenshot required**: no by default; one bounded Browser smoke only if implementation changes visible hierarchy or missing-context copy beyond existing tooltips/notifications
|
||||
- **Page audit required**: no new page-report identity is required by default
|
||||
- **Customer-safe review required**: no; affected surfaces are operator/admin surfaces
|
||||
- **Dangerous-action review required**: yes for high-impact and destructive-adjacent actions. Existing destructive actions must keep `->action(...)`, `->requiresConfirmation()`, server-side authorization, audit logging, and tests. Modal open must not create `OperationRun` records or dispatch jobs.
|
||||
- **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 - existing reachable UI surface families only`
|
||||
- **No-impact rationale when applicable**: N/A. This feature changes existing action behavior and workspace/environment context presentation, but does not add a new reachable route/page family.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: header actions, page actions, empty-state actions, modal actions, OperationRun-starting actions, support/diagnostic actions, status/tooltip copy
|
||||
- **Systems touched**:
|
||||
- `App\Support\Rbac\UiEnforcement`
|
||||
- `App\Support\Rbac\WorkspaceUiEnforcement` as comparison context only unless implementation chooses to share a small value object
|
||||
- `App\Filament\Concerns\ResolvesPanelTenantContext`
|
||||
- existing Filament resource/page action definitions
|
||||
- existing action tests and guard tests
|
||||
- existing `OperationRun` creation/dispatch paths only through current handlers
|
||||
- **Existing pattern(s) to extend**: `UiEnforcement`, `WorkspaceUiEnforcement`, current page context resolvers, current Filament action tests
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `UiEnforcement` and page/record resolvers; no presenter or renderer is introduced
|
||||
- **Why the existing shared path is sufficient or insufficient**: the shared path exists, but `UiEnforcement` still allows ambiguous no-record scoped actions to fall back to implicit Filament tenant context. The contract must make scoped action context explicit.
|
||||
- **Allowed deviation and why**: a small new `App\Support\Rbac\Actions` namespace is allowed if it keeps action context isolated and avoids bloating `UiEnforcement` with unrelated UI framework semantics.
|
||||
- **Consistency impact**: missing-context handling, readonly/capability disabled state, modal mount behavior, OperationRun dispatch timing, and server reauthorization must stay consistent across all retrofitted actions.
|
||||
- **Review focus**: no hidden `Filament::getTenant()` fallback for no-record scoped actions, no modal-open side effects, no compatibility shim, no broad record-backed rewrite.
|
||||
|
||||
## OperationRun UX Impact *(mandatory)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, start semantics only
|
||||
- **Shared OperationRun UX contract/layer reused**: existing `OperationRunService`, action handlers, `OperationRunLinks`, `OperationUxPresenter`, and `OpsUxBrowserEvents`
|
||||
- **Delegated start/completion UX behaviors**: queued toast, `Open operation` / `View run` links, run-enqueued browser events, and terminal notification paths remain unchanged
|
||||
- **Local surface-owned behavior that remains**: action form inputs, confirmation wording, and missing-context notification/tooltip copy
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception required?**: none. If execution authorization is found weaker than the UI gate beyond this context class, stop and create a follow-up instead of widening this spec.
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: no new provider boundary is introduced
|
||||
- **Boundary classification**: platform-core UI/RBAC action context over current provider-backed operations
|
||||
- **Seams affected**: Filament action scope, workspace/environment context, run/job dispatch inputs, support/diagnostic mutation inputs
|
||||
- **Neutral platform terms preserved or introduced**: `workspace`, `environment`, `record`, `system`, `product scope`, `action context`
|
||||
- **Provider-specific semantics retained and why**: Microsoft/Graph semantics remain inside existing job/service handlers; this spec does not add provider-specific endpoints or contracts
|
||||
- **Why this does not deepen provider coupling accidentally**: the new contract names workspace/environment product scope rather than Microsoft tenant transport state, and it forbids treating Filament tenant context as platform truth for no-record scoped actions
|
||||
- **Follow-up path**: only `OperationRun Start Authorization Contract Hardening` if broader execution weakness is repo-verified during implementation
|
||||
|
||||
## UI / Surface Guardrail Impact
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Scoped no-record header/page actions | yes | Native Filament actions | header/page/modal action family | page, modal, Livewire action mount | no | existing pages only |
|
||||
| Missing action context state | yes | Native disabled/hidden/notification affordances | RBAC UX family | page, action state | no | copy must remain enterprise-safe |
|
||||
| OperationRun-starting action submit path | yes | Existing services/jobs | OperationRun start family | action submit, queue/job dispatch | no | completion UX unchanged |
|
||||
|
||||
## Decision-First Surface Role
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Scoped action button/modal | Primary Action Surface | Operator decides whether to start sync, generate artifact, create review, repair diagnostics, restore, or request support | action enabled/disabled state and confirmation/modal availability | existing form fields, modal text, run link after submit | primary because it is where work begins | keeps scope decision attached to the action | avoids "button does nothing" and wrong permission interpretation |
|
||||
| Missing-context tooltip/notification | Secondary Explanation Surface | Operator needs to understand why a scoped action is unavailable | concise "environment context unavailable" explanation | none by default | secondary because it explains a blocked action | prevents false permission framing | avoids support/debug reconstruction |
|
||||
| OperationRun link after submit | Secondary Follow-through Surface | Operator inspects queued work | existing run link/notification | existing operation detail diagnostics | unchanged existing pattern | same OperationRun workflow | avoids duplicating result truth in modal |
|
||||
|
||||
## Audience-Aware Disclosure
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Scoped action button/modal | operator-MSP, support-platform | action state, form, confirmation, safe context wording | context source only in tests/log-safe diagnostics unless already appropriate | no raw Livewire/request detail in UI | run/submit the action or reload/select environment | technical transport details such as `getTenant null` | modal state and submit handler use one explicit context |
|
||||
| Missing-context state | operator-MSP, support-platform | "Environment context unavailable" style copy | no default raw IDs | raw request/referer data remains outside UI | reload workspace or select environment | low-level Livewire/Filament internals | missing context is distinct from permission denial |
|
||||
|
||||
## UI/UX Surface Classification
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Existing resource/page header actions | Action / Modal / Workbench | High-impact no-record action | open modal, confirm, submit, or reload/select context | action/modal | N/A | existing page actions | existing confirmation/modal patterns | existing route family | existing route family | workspace/environment action context | Action / Operation | whether context and capability allow the action | none |
|
||||
| Existing empty-state actions | Action / Empty State | No-record generation action | create first snapshot/pack/review where allowed | action/modal | N/A | empty state | existing confirmation/modal patterns | existing route family | existing route family | explicit environment context | Action / Artifact | context and authorization are consistent | none |
|
||||
|
||||
## Operator Surface Contract
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Scoped no-record actions | Tenant operator / MSP operator | Start or generate environment/workspace work safely | existing Filament action/modal | Can I run this action for this exact workspace/environment now? | enabled/disabled/missing-context state and modal inputs | context source and technical request details only in tests/log-safe diagnostics | context present, membership, capability, entitlement, readonly | TenantPilot and queued provider work depending action | sync, generate, create, repair, request support | restore, diagnostics repair, delete/expire-adjacent actions keep confirmation and server authorization |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no persisted source of truth. `UiActionContext` is a request-time product-scope contract.
|
||||
- **New persisted entity/table/artifact?**: no.
|
||||
- **New abstraction?**: yes, a narrow action-context value object/helper contract.
|
||||
- **New enum/state/reason family?**: yes, small request-time scope/source enums and missing-context reason classification.
|
||||
- **New cross-domain UI framework/taxonomy?**: no.
|
||||
- **Current operator problem**: trusted admin actions can silently fail, hide, disable, or mount inconsistently because product scope is implicit on Livewire update requests.
|
||||
- **Existing structure is insufficient because**: `UiEnforcement` accepts nullable no-record context and can fall back to `Filament::getTenant()`, while workspace-first admin surfaces need page/record/workspace/environment product context.
|
||||
- **Narrowest correct implementation**: make scoped no-record action context explicit, preserve valid record-backed context resolution, and guard only the risky class first.
|
||||
- **Ownership cost**: one reusable contract, focused action retrofits, static guard maintenance, and action lifecycle tests for high-impact no-record actions.
|
||||
- **Alternative intentionally rejected**: keep adding local page resolver closures without a central contract. Rejected because it leaves recurrence likely and missing context indistinguishable from authorization denial.
|
||||
- **Release truth**: current-release truth. The issue is repo-verified and already required local fixes.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope. No compatibility fallback is allowed for scoped no-record actions that should use explicit context.
|
||||
|
||||
Record-backed actions are not legacy exceptions; they are a different valid context source.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit for context value objects and `UiEnforcement` context handling; Feature/Filament-Livewire for action visibility, first-click modal mount, submit reauthorization, and no-run/no-job-on-modal-open; Architecture/Feature guard for static source scanning; Browser optional smoke only if visible hierarchy/copy changes materially.
|
||||
- **Validation lane(s)**: fast-feedback, confidence, browser optional
|
||||
- **Why this classification and these lanes are sufficient**: the risk is request-time action context and Filament/Livewire lifecycle behavior, not schema or PostgreSQL-specific persistence.
|
||||
- **New or expanded test families**: one explicit Spec 363 Unit family, one explicit Spec 363 Feature/Architecture guard, and targeted extensions to existing action tests.
|
||||
- **Fixture / helper cost impact**: existing workspace/environment/user/capability factories plus current queue fakes; no new global seed or heavy default context.
|
||||
- **Heavy-family visibility / justification**: no heavy-governance family by default; Browser only if UI copy/hierarchy changes require visual smoke.
|
||||
- **Special surface test profile**: standard-native-filament plus action-lifecycle-contract.
|
||||
- **Standard-native relief or required special coverage**: use Filament action testing helpers for pages/actions. Header actions use `mountAction()` / `callAction()` and table header actions may use `Filament\Actions\Testing\TestAction::make(...)->table()` where appropriate.
|
||||
- **Reviewer handoff**: reviewers must confirm no modal-open side effects, no implicit `Filament::getTenant()` product-scope fallback for scoped no-record actions, no compatibility shim, and no broad record-backed churn.
|
||||
- **Budget / baseline / trend impact**: none expected beyond targeted action lifecycle tests.
|
||||
- **Escalation needed**: document-in-feature if a single false positive needs a bounded static-guard exception; follow-up-spec if execution authorization is broadly weaker than UI gating.
|
||||
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Rbac/Actions tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Architecture/ScopedUiActionContextContractTest.php tests/Feature/Filament/InventoryItemResourceTest.php tests/Feature/PolicySyncStartSurfaceTest.php tests/Feature/RunStartAuthorizationTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupAdminScopeTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Explicit Context Contract (Priority: P1)
|
||||
|
||||
As a tenant operator, I need scoped actions to evaluate against the same workspace/environment context during render, first click, and submit, so that action state is reliable and not dependent on Livewire transport details.
|
||||
|
||||
**Why this priority**: This is the foundation that prevents first-click modal failures and render/submit drift across all retrofitted actions.
|
||||
|
||||
**Independent Test**: Unit tests prove `UiActionContext` can represent workspace, environment, record, system, and missing context, and `UiEnforcement` fails closed for scoped no-record actions without explicit context while preserving record-backed behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a no-record environment-scoped action with explicit environment context, **When** Livewire evaluates visibility and disabled state, **Then** it uses that context instead of implicit Filament tenant fallback.
|
||||
2. **Given** a no-record environment-scoped action with missing context, **When** action state is evaluated, **Then** the action fails closed with missing-context reason distinct from non-membership and missing capability.
|
||||
3. **Given** a record-backed table/detail action, **When** action state is evaluated, **Then** the existing record-derived scope remains valid without broad retrofit churn.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Stable First-Click Modal Lifecycle (Priority: P1)
|
||||
|
||||
As an operator starting sync, evidence, review, restore, diagnostics, or support work, I need the modal or confirmation to open on first click without creating a run, dispatching a job, or mutating records until I submit.
|
||||
|
||||
**Why this priority**: The repo-verified failure mode is user-visible trust damage: buttons appear broken or create inconsistent action lifecycle behavior.
|
||||
|
||||
**Independent Test**: Filament/Livewire action tests mount each representative no-record action under workspace-scoped Livewire referer context, assert the action is mounted/open, and assert no `OperationRun`, queued provider job, audit side effect, or domain mutation happens on modal open unless explicitly intended and tested.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized operator on the Inventory list with no Filament tenant on the Livewire update request, **When** they first click Run Inventory Sync, **Then** the modal mounts and no `inventory.sync` run or job is created.
|
||||
2. **Given** an authorized operator on Evidence Snapshot, Review Pack, Environment Review, Restore, Diagnostics, or Environment Dashboard surfaces, **When** they first click the scoped action, **Then** modal/confirmation state uses explicit action context and creates no run/job/mutation until submit.
|
||||
3. **Given** a readonly or entitlement-blocked operator, **When** action state is evaluated and submitted, **Then** disabled state and server-side denial use the same explicit context and no work is queued.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Recurrence Guard For Risky Actions (Priority: P1)
|
||||
|
||||
As a reviewer, I need a static guard that flags risky no-record scoped actions without explicit context so future feature work cannot reintroduce the same pattern.
|
||||
|
||||
**Why this priority**: Without a guard, the next modal-first or OperationRun-starting feature can repeat the bug even if current actions are fixed.
|
||||
|
||||
**Independent Test**: A Pest architecture/guard test scans `apps/platform/app/Filament` for risky scoped no-record action patterns and fails with actionable output when a candidate lacks `UiActionContext` / `forScopedAction` / canonical resolver markers.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new no-record scoped action that uses `UiEnforcement::forAction()` and can dispatch work, open a modal, or create an `OperationRun`, **When** it lacks explicit context, **Then** the guard fails with file/action/fix guidance.
|
||||
2. **Given** a record-backed row/table/detail action, **When** it safely derives scope from the record, **Then** the guard does not require unnecessary `UiActionContext` churn.
|
||||
3. **Given** an unavoidable false positive, **When** a bounded exception is needed, **Then** it must be documented in this feature or a follow-up with a specific reason, not hidden in a permanent legacy allowlist.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Operator-Safe Missing Context Copy (Priority: P2)
|
||||
|
||||
As an operator, I need missing context to be explained as context unavailability, not as a false permission denial or low-level Livewire/Filament error.
|
||||
|
||||
**Why this priority**: The primary trust issue is silent or misleading UX. Copy does not need a full localization project, but it must be localizable later and not expose transport internals.
|
||||
|
||||
**Independent Test**: Feature tests assert missing-context state produces a distinct reason and, where visible, enterprise-safe copy such as "Environment context unavailable."
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an action should remain visible but context cannot be resolved, **When** the operator sees the disabled state, **Then** the tooltip/notification says the environment/workspace context is unavailable rather than "you lack permission."
|
||||
2. **Given** missing context is logged or asserted in tests, **When** diagnostics are inspected, **Then** no secret, token, raw Graph payload, or unsupported request internals are exposed in UI copy.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-363-001**: The implementation MUST introduce an explicit action-context contract that can represent workspace, environment, record, system, and missing scoped action context without persistence.
|
||||
- **FR-363-002**: The implementation MUST support a distinct missing-context reason such as `context_missing`, `workspace_missing`, or `environment_missing`.
|
||||
- **FR-363-003**: `UiEnforcement` MUST accept explicit scoped action context for no-record workspace/environment actions.
|
||||
- **FR-363-004**: Scoped no-record actions MUST NOT silently rely on `Filament::getTenant()` as product scope truth.
|
||||
- **FR-363-005**: Record-backed actions MAY continue to resolve context from their record when that record is the product scope source.
|
||||
- **FR-363-006**: Missing context MUST fail closed and MUST be distinguishable from non-membership and missing capability in tests.
|
||||
- **FR-363-007**: First-click modal/confirmation mount MUST NOT create an `OperationRun`, dispatch provider/write jobs, or mutate domain state unless a specific action intentionally audits open and tests that behavior.
|
||||
- **FR-363-008**: Submit/execute handlers MUST reauthorize server-side using the explicit context before creating runs, dispatching jobs, generating artifacts, or mutating data.
|
||||
- **FR-363-009**: Representative no-record actions MUST be retrofitted: `run_inventory_sync`, Policy Sync, `sync_groups`, `create_snapshot`, `create_first_snapshot`, `generate_pack`, `generate_first`, `create_review`, `bootstrapOwner`, `mergeDuplicateMemberships`, Restore create entry, and Environment Dashboard support actions.
|
||||
- **FR-363-010**: A static guard MUST flag risky scoped no-record action patterns without explicit context and provide actionable failure output.
|
||||
- **FR-363-011**: The implementation MUST keep destructive and high-impact actions on `Action::make(...)->action(...)` with `->requiresConfirmation()` where currently required.
|
||||
- **FR-363-012**: The implementation MUST NOT add new capabilities, operation types, routes, panels, assets, migrations, or global-search behavior.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **NFR-363-001**: Filament remains v5 and Livewire remains v4.0+ compliant.
|
||||
- **NFR-363-002**: Laravel 12 panel providers remain registered in `apps/platform/bootstrap/providers.php`; no provider registration change is planned.
|
||||
- **NFR-363-003**: No Graph call may occur during render, action visibility, disabled-state evaluation, or modal mount.
|
||||
- **NFR-363-004**: Tests must stay focused: Unit + Feature/Filament-Livewire + static guard, with Browser only if visible behavior changes materially.
|
||||
- **NFR-363-005**: Missing-context copy must be enterprise-safe and localizable later; it must not expose low-level Livewire request state.
|
||||
- **NFR-363-006**: Static guard false positives must be resolved by narrowing the guard or documenting bounded exceptions, not by a broad legacy allowlist.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- OperationRun status/outcome redesign.
|
||||
- New persisted context/audit table.
|
||||
- Full action-surface discovery framework rewrite.
|
||||
- Full migration of every record-backed action.
|
||||
- UI redesign or navigation changes.
|
||||
- Customer-facing portal/review productization.
|
||||
- Commercial billing-state implementation.
|
||||
- Cross-tenant promotion execution.
|
||||
- Support desk/PSA integration.
|
||||
- AI execution governance implementation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `UiActionContext` or an equivalent explicit request-time context contract exists and is covered by Unit tests.
|
||||
- `UiEnforcement` supports explicit scoped action context and no scoped no-record action silently relies on `Filament::getTenant()` as product scope truth.
|
||||
- Missing workspace/environment context fails closed and is test-observable as missing context, not only as non-membership or missing capability.
|
||||
- Representative no-record actions listed in this spec use explicit context or have documented repo-verified reasons for deferral.
|
||||
- First-click modal/confirmation mount tests prove no `OperationRun`, provider/write job, or domain mutation occurs before submit.
|
||||
- Submit/execute tests prove server-side reauthorization with explicit context.
|
||||
- A static guard fails future risky scoped no-record actions without explicit context and reports actionable fix guidance.
|
||||
- No migrations, assets, panel/provider changes, route/navigation changes, capability strings, operation types, or global-search changes are introduced.
|
||||
|
||||
## Data And Truth Source Requirements
|
||||
|
||||
- `UiActionContext` is request-time derived truth, not persistence.
|
||||
- Workspace/environment product scope is authoritative for no-record scoped actions.
|
||||
- Filament tenant context remains a framework transport/detail and may be used only when explicitly resolved into product context by a repo-approved resolver.
|
||||
- `OperationRun` remains the run/dispatch truth after submit.
|
||||
- Existing audit logs remain action-side-effect truth where handlers already audit.
|
||||
|
||||
## Auditability And Observability Requirements
|
||||
|
||||
- Existing audit events for support, diagnostics, restore, generation, sync, and run-start actions must remain.
|
||||
- Modal open must not create audit records unless the existing action intentionally records a safe open/read event and tests it.
|
||||
- OperationRun creation must include explicit workspace/environment scope as it does today through the handler's run-start path.
|
||||
- Static guard failures must name the file/action/reason/fix so reviewers can act without reverse-engineering the scan.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Current application versions are PHP 8.4.15, Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, PostgreSQL via Sail.
|
||||
- The product remains pre-production, so no legacy compatibility shim is required for old scoped no-record behavior.
|
||||
- `ManagedEnvironment` is still the environment/tenant model used by current runtime code.
|
||||
- Existing policies, capability resolvers, and domain services remain the server-side authorization owners.
|
||||
|
||||
## Risks
|
||||
|
||||
- The static guard may initially produce false positives for record-backed or safe URL-only actions.
|
||||
- Retrofitting many action surfaces can conflict with active feature work if done as one broad PR.
|
||||
- Missing-context copy could become noisy if visible for actions that should be hidden entirely.
|
||||
- If implementation reveals broader execution authorization weakness, that must become a follow-up spec rather than expanding this package silently.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking preparation. During implementation, exact action names and file names must be reverified against current repo truth before edits.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Representative no-record scoped actions mount reliably on first click under workspace-scoped Livewire requests.
|
||||
- Missing context is test-observable and distinct from permission denial.
|
||||
- Modal open creates no run/job/mutation for retrofitted actions.
|
||||
- Submit paths reauthorize and use explicit context.
|
||||
- Static guard catches new risky scoped no-record actions without explicit context.
|
||||
- No migrations, assets, panel provider changes, or global-search changes are introduced.
|
||||
|
||||
## Follow-Up Spec Candidates
|
||||
|
||||
Only create a follow-up if implementation discovers repo-verified broader weakness:
|
||||
|
||||
- `OperationRun Start Authorization Contract Hardening`: required only if execution-level authorization is inconsistent beyond UI action context.
|
||||
186
specs/363-explicit-uiactioncontext-contract/tasks.md
Normal file
186
specs/363-explicit-uiactioncontext-contract/tasks.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Tasks: Spec 363 - Explicit UiActionContext Contract for Scoped No-Record Actions
|
||||
|
||||
**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/363-explicit-uiactioncontext-contract/spec.md`, `plan.md`, and `checklists/requirements.md`
|
||||
**Prerequisites**: `spec.md` and `plan.md`
|
||||
**Tests**: REQUIRED (Pest). Use Unit + Feature/Filament-Livewire + Architecture/guard. Browser is optional only if visible copy/hierarchy changes materially.
|
||||
**Operations**: Modal open must not create `OperationRun` records, dispatch provider/write jobs, or mutate records. Submit/execute must reauthorize with explicit context.
|
||||
**RBAC**: Reuse existing policies/gates, `UiEnforcement`, `WorkspaceUiEnforcement`, capability resolvers, and deny-as-not-found semantics. No new capability strings.
|
||||
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration stays in `apps/platform/bootstrap/providers.php`. No new panel, global-search change, or asset strategy is allowed.
|
||||
**Organization**: Tasks are ordered by dependency. Tests and guardrails come before or alongside runtime edits.
|
||||
|
||||
## Repo Baseline At Prep Time
|
||||
|
||||
- **Branch before Spec Kit creation**: `platform-dev`
|
||||
- **HEAD before Spec Kit creation**: `548a37c8 feat: implement sync capture backup operation semantics (#433)`
|
||||
- **`git status --short --branch` before Spec Kit creation**: clean
|
||||
- **Spec Kit branch created**: `363-explicit-uiactioncontext-contract`
|
||||
- **Implementation PR branch target**: `feat/363-explicit-uiactioncontext-contract` from `dev`; reconcile branch shape before runtime edits
|
||||
- **Candidate source**: direct user-provided Spec 363 draft in `/Users/ahmeddarrazi/.codex/attachments/36f3aea8-0303-4548-a83c-9f1cdd15f527/pasted-text.txt`
|
||||
- **Completed-spec context only**: Specs 338, 340, 358, 359, 360, 361, and 362 are dependency/history context and must not be reopened during Spec 363 implementation
|
||||
- **Scope guardrail**: no migrations, no panel/provider changes, no assets, no global-search changes, no OperationRun redesign, no broad record-backed action migration, and no product-feature expansion
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment remains explicit and narrowest sufficient (Unit + Feature/Filament-Livewire + Architecture/guard; Browser optional only when visible UI changes require it).
|
||||
- [x] Shared helpers keep workspace/environment/member/capability setup opt-in and do not create expensive defaults.
|
||||
- [x] Tests prove business truth: context resolution, RBAC affordance, server reauthorization, no-run/no-job on modal open, and wrong-scope fail-closed behavior.
|
||||
- [x] Static guard failures are actionable and do not rely on broad permanent legacy allowlists.
|
||||
- [x] Final close-out records no-migration, no-asset, no-panel-provider, no-global-search, and no-compatibility-shim status.
|
||||
|
||||
## Implementation Close-Out Notes
|
||||
|
||||
- Runtime implementation ran on `feat/363-explicit-uiactioncontext-contract` from `dev`.
|
||||
- Repo-truth names differ from the draft: `TenantReviewResource`, `TenantDiagnostics`, and `TenantDashboard` are the current environment review/diagnostics/dashboard surfaces.
|
||||
- `apps/platform/tests/Support/Filament/ScopedActionAssertions.php` was not added because the final focused tests did not duplicate enough setup to justify a new helper.
|
||||
- Browser smoke was not run because the implementation does not materially change visible hierarchy or add new visible page/modal copy beyond the existing disabled-context tooltip path.
|
||||
- No migrations, assets, panel/provider registration changes, global-search changes, new capabilities, new operation types, or compatibility shim were introduced.
|
||||
|
||||
## Phase 1: Setup and Repo Truth Inventory
|
||||
|
||||
**Purpose**: confirm exact current branch, callsites, action names, and related completed-spec context before runtime edits.
|
||||
|
||||
- [x] T001 Re-read `spec.md`, `plan.md`, `checklists/requirements.md`, `.specify/memory/constitution.md`, `docs/ai-coding-rules.md`, `docs/architecture-guidelines.md`, `docs/security-guidelines.md`, `docs/testing-guidelines.md`, `docs/filament-guidelines.md`, `docs/research/filament-v5-notes.md`, and `specs/362-sync-capture-backup-operation-semantics/artifacts/action-context-root-cause-audit.md`.
|
||||
- [x] T002 Confirm current branch, working tree, and baseline commit with `git status --short --branch` and `git log -1 --oneline`; before runtime edits, ensure implementation runs on `feat/363-explicit-uiactioncontext-contract` from `dev` or document the branch reconciliation in close-out.
|
||||
- [x] T003 [P] Re-verify current `UiEnforcement` and `WorkspaceUiEnforcement` behavior in `apps/platform/app/Support/Rbac/UiEnforcement.php` and `apps/platform/app/Support/Rbac/WorkspaceUiEnforcement.php`.
|
||||
- [x] T004 [P] Re-verify Livewire referer context behavior in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`; the draft-named `tests/Unit/Filament/ResolvesPanelTenantContextLivewireRefererTest.php` is not present on this branch, so resolver behavior is covered through `ResolvesUiActionContextTest` and admin resolver guard tests.
|
||||
- [x] T005 [P] Re-inventory scoped no-record action callsites in `apps/platform/app/Filament` and record the exact current action names for Inventory, Policy, Entra Groups, Evidence Snapshot, Review Pack, Environment Review, Environment Diagnostics, Restore Run, and Environment Dashboard.
|
||||
- [x] T006 Confirm no application implementation from Specs 338/340/358/359/360/361/362 needs to be rewritten; use those packages as context only.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Contract Unit Tests
|
||||
|
||||
**Purpose**: define the request-time action-context contract before changing action code.
|
||||
|
||||
- [x] T007 [P] Add failing Unit tests in `apps/platform/tests/Unit/Support/Rbac/Actions/UiActionContextTest.php` for workspace context, environment context, record context, system context, missing context, `requireWorkspace()`, and `requireEnvironment()`.
|
||||
- [x] T008 [P] Add failing Unit tests in `apps/platform/tests/Unit/Support/Rbac/Actions/ResolvesUiActionContextTest.php` or equivalent to prove page resolver, record resolver, workspace resolver, and missing-context behavior against actual repo relation names.
|
||||
- [x] T009 [P] Add failing Unit tests in `apps/platform/tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php` proving scoped no-record actions require explicit context and missing context is distinct from non-membership/missing capability.
|
||||
- [x] T010 [P] Add regression tests proving record-backed actions can still resolve scope from records without mandatory no-record context plumbing.
|
||||
|
||||
**Checkpoint**: tests describe the contract and fail for the current implicit fallback behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Add Narrow Action Context Contract
|
||||
|
||||
**Purpose**: add the smallest support-layer API needed for scoped action context.
|
||||
|
||||
- [x] T011 Add `apps/platform/app/Support/Rbac/Actions/UiActionScope.php` with workspace, environment, record, and system scope values.
|
||||
- [x] T012 Add `apps/platform/app/Support/Rbac/Actions/UiActionContextSource.php` with explicit, page resolver, workspace context, record, Livewire referer, and missing source values where currently justified.
|
||||
- [x] T013 Add `apps/platform/app/Support/Rbac/Actions/UiActionContext.php` as a readonly request-time value object with `forWorkspace()`, `forEnvironment()`, `forRecord()`, `forSystem()`, `missing()`, `isMissing()`, missing-reason accessors, `requireWorkspace()`, and `requireEnvironment()` behavior or equivalent constructors/accessors.
|
||||
- [x] T014 Add `apps/platform/app/Support/Rbac/Actions/ResolvesUiActionContext.php` or an equivalent helper that wraps existing `ResolvesPanelTenantContext` and actual model relation names without inventing fake relations.
|
||||
- [x] T015 Confirm no new persistence, migration, capability, operation type, panel/provider, route, asset, or global-search behavior was introduced.
|
||||
|
||||
**Checkpoint**: the new contract is narrow, request-time only, and covered by Unit tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Harden UiEnforcement For Scoped No-Record Actions
|
||||
|
||||
**Purpose**: make the shared RBAC UI path enforce explicit scoped context without breaking record-backed actions.
|
||||
|
||||
- [x] T016 Add an explicit scoped-action entrypoint such as `UiEnforcement::forScopedAction(Action $action, UiActionContext|Closure $context)` or a narrow compatible signature change that cannot be confused with nullable no-record context.
|
||||
- [x] T017 Update context resolution inside `UiEnforcement` so scoped no-record actions do not silently fall back to `Filament::getTenant()` as product scope truth.
|
||||
- [x] T018 Add internal missing-context classification so tests can distinguish context-missing from non-member and missing-capability states.
|
||||
- [x] T019 Preserve existing record/action/table/bulk behavior where product scope is derived from a record or selected records.
|
||||
- [x] T020 Keep destructive/high-impact action confirmation behavior intact: `Action::make(...)->action(...)`, `->requiresConfirmation()` where applicable, server authorization, audit, and notification paths.
|
||||
- [x] T021 Run the core Unit gate:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Rbac/Actions tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php tests/Unit/Support/Rbac/UiEnforcementTest.php`
|
||||
|
||||
**Checkpoint**: `UiEnforcement` supports explicit context, fails closed for missing context, and does not break existing record-backed tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Retrofitted Action Tests First
|
||||
|
||||
**Purpose**: protect representative action lifecycle behavior before or during runtime retrofit.
|
||||
|
||||
- [x] T022 [P] Extend inventory start-surface coverage in `apps/platform/tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`; `apps/platform/tests/Feature/Filament/InventoryItemResourceTest.php` was also run as the existing resource file.
|
||||
- [x] T023 [P] Extend `apps/platform/tests/Feature/PolicySyncStartSurfaceTest.php` to keep first-click modal mount, no `OperationRun`, and no queued job on modal open under Livewire referer context.
|
||||
- [x] T024 [P] Extend `apps/platform/tests/Feature/RunStartAuthorizationTest.php` / directory group coverage for `sync_groups` no-record action: readonly and no run/job until submit; shared missing-context behavior is covered in Unit because the page itself aborts before mounting without environment context.
|
||||
- [x] T025 [P] Extend `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` for `create_snapshot` first-click modal/no-run behavior; `create_first_snapshot` is covered by the static guard and existing empty-state coverage because it has no modal-open lifecycle.
|
||||
- [x] T026 [P] Extend `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php` for `generate_pack` first-click modal/no-run behavior; `generate_first` stays covered through the shared action factory/static guard and existing readonly/entitlement empty-state assertions.
|
||||
- [x] T027 [P] Extend Tenant Review coverage for repo-current `TenantReviewResource::makeCreateReviewAction()` / `create_review` in existing tenant review UI tests and static guard.
|
||||
- [x] T028 [P] Extend Environment Diagnostics coverage for repo-current `TenantDiagnostics` actions `bootstrapOwner` and `mergeDuplicateMemberships` through existing diagnostics tests plus static guard.
|
||||
- [x] T029 [P] Extend restore UI/enforcement coverage for `RestoreRunResource::makeCreateAction()` and restore create/wizard through existing restore tests and static guard.
|
||||
- [x] T030 [P] Extend Environment Dashboard support action coverage for repo-current `TenantDashboard` support request and support diagnostics tests; support diagnostics retains its intentional audit-on-open behavior.
|
||||
|
||||
**Checkpoint**: tests capture representative action behavior and fail until actions use explicit context consistently.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Retrofit Representative Actions
|
||||
|
||||
**Purpose**: move known latent-risk no-record scoped actions to explicit `UiActionContext`.
|
||||
|
||||
- [x] T031 Update `apps/platform/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` so `run_inventory_sync` uses explicit action context and keeps handler reauthorization/no-run-on-modal-open behavior.
|
||||
- [x] T032 Update `apps/platform/app/Filament/Resources/PolicyResource.php` and `apps/platform/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php` so Policy Sync uses explicit action context and keeps existing queued OperationRun UX.
|
||||
- [x] T033 Update `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` so `sync_groups` uses explicit environment action context and no longer depends on implicit `Filament::getTenant()` for scoped no-record state.
|
||||
- [x] T034 Update `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php` so `create_snapshot` and `create_first_snapshot` use explicit environment action context.
|
||||
- [x] T035 Update `apps/platform/app/Filament/Resources/ReviewPackResource.php` so `generate_pack` and `generate_first` use explicit environment action context and entitlement/readonly checks use the same context.
|
||||
- [x] T036 Update repo-current `apps/platform/app/Filament/Resources/TenantReviewResource.php` so `create_review` uses explicit environment action context and submit still validates capability/access before creating review/compose work.
|
||||
- [x] T037 Update repo-current `apps/platform/app/Filament/Pages/TenantDiagnostics.php` so `bootstrapOwner` and `mergeDuplicateMemberships` use explicit environment action context and modal open remains non-mutating.
|
||||
- [x] T038 Update `apps/platform/app/Filament/Resources/RestoreRunResource.php` so Restore create entry/wizard context is explicit and restore/write gates remain separate.
|
||||
- [x] T039 Update repo-current `apps/platform/app/Filament/Pages/TenantDashboard.php` so support request and support diagnostics actions use explicit environment action context.
|
||||
- [x] T040 For any listed action whose current repo name differs from the draft, document the actual action name in the active feature close-out rather than inventing aliases.
|
||||
|
||||
**Checkpoint**: representative latent-risk actions use explicit context and tests prove first-click/modal/submit lifecycle behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Test Helper and Static Guard
|
||||
|
||||
**Purpose**: prevent recurrence without creating a broad framework.
|
||||
|
||||
- [x] T041 Add reusable test helper `apps/platform/tests/Support/Filament/ScopedActionAssertions.php` only if it reduces duplication across at least two retrofitted action tests; evaluated and not added.
|
||||
- [x] T042 Helper assertion requirements are satisfied inline in focused tests where needed; no helper was introduced.
|
||||
- [x] T043 Add `apps/platform/tests/Feature/Architecture/ScopedUiActionContextContractTest.php` to scan risky scoped no-record action patterns under `apps/platform/app/Filament`.
|
||||
- [x] T044 The guard flags guarded no-record action names, scoped-action wrapper markers, and regression to `UiEnforcement::forAction`.
|
||||
- [x] T045 Tune the guard to avoid record-backed row/table/detail false positives without hiding known risky actions.
|
||||
- [x] T046 Ensure guard failure output includes file, action or nearest action name, reason, and a fix hint such as using explicit `UiActionContext` / scoped action resolver.
|
||||
- [x] T047 Do not add a permanent legacy allowlist for old no-record scoped action patterns.
|
||||
|
||||
**Checkpoint**: future risky no-record scoped actions fail fast unless they declare explicit context.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Validation
|
||||
|
||||
- [x] T048 Run the core context and enforcement Unit gate:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Rbac/Actions tests/Unit/Support/Rbac/UiEnforcementScopedActionContextTest.php tests/Unit/Support/Rbac/UiEnforcementTest.php`
|
||||
- [x] T049 Run the primary action lifecycle and guard gate:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Architecture/ScopedUiActionContextContractTest.php tests/Feature/Filament/InventoryItemResourceTest.php tests/Feature/PolicySyncStartSurfaceTest.php tests/Feature/RunStartAuthorizationTest.php`
|
||||
- [x] T050 Run the representative contextual action tests:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupAdminScopeTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
- [x] T051 Run additional focused tests for repo-current Tenant Review, Tenant Diagnostics, Restore create, and Tenant Dashboard support files after implementation names the exact existing or new test files, including the relevant existing SupportDiagnostics/SupportRequests action tests when those surfaces are touched.
|
||||
- [x] T052 Browser smoke not run; implementation changed existing action context behavior but not visible hierarchy/copy materially enough to require a browser pass under this spec's rule.
|
||||
- [x] T053 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- [x] T054 Run `git diff --check`.
|
||||
- [x] T055 Record final close-out: changed files, new contract classes, retrofitted actions, intentionally untouched actions, static guard result, tests run, no legacy fallback retained for scoped no-record actions, and no migrations/assets/panel/global-search changes.
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- Phase 1 must complete first.
|
||||
- Phase 2 and Phase 3 establish the contract and should precede broad action retrofit.
|
||||
- Phase 4 blocks action retrofit because callsites need the final `UiEnforcement` API.
|
||||
- Phase 5 tests should be written before or beside Phase 6 runtime changes.
|
||||
- Phase 7 guard can be developed after the first retrofits clarify the marker pattern, but must pass before close-out.
|
||||
- Phase 8 closes the package.
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
- T003, T004, and T005 can run in parallel.
|
||||
- T007, T008, T009, and T010 can run in parallel.
|
||||
- T022 through T030 can be split by action family after the helper approach is decided.
|
||||
- T031 through T039 can be split by action family after `UiEnforcement` API stabilizes.
|
||||
|
||||
## Explicit Non-Goals For Implementers
|
||||
|
||||
- Do not modify completed spec artifacts outside Spec 363.
|
||||
- Do not add migrations, tables, persisted status, or action-context audit tables.
|
||||
- Do not add new capabilities or operation types.
|
||||
- Do not change Filament panel provider registration or panel paths.
|
||||
- Do not enable or alter global search.
|
||||
- Do not add assets or require `filament:assets` because of this spec.
|
||||
- Do not rewrite record-backed actions unless the guard proves they are actually no-record scoped risk.
|
||||
- Do not turn this into support desk, billing, promotion, governance inbox, restore redesign, or AI runtime implementation.
|
||||
Loading…
Reference in New Issue
Block a user