feat: harden livewire trusted state boundaries

This commit is contained in:
Ahmed Darrazi 2026-03-19 00:00:32 +01:00
parent ec71c2d4e7
commit ae2d6a8942
39 changed files with 3572 additions and 119 deletions

View File

@ -89,6 +89,7 @@ ## Active Technologies
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -108,8 +109,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
- 151-findings-workflow-backstop: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
- 150-tenant-owned-query-canon-and-wrong-tenant-guards: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4
- 149-queued-execution-reauthorization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -29,6 +29,11 @@ public static function panelTenantContext(): ?Tenant
return static::resolveTenantContextForCurrentPanel();
}
public static function trustedPanelTenantContext(): ?Tenant
{
return static::panelTenantContext();
}
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
{
$tenant = static::resolveTenantContextForCurrentPanel();
@ -39,4 +44,9 @@ protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
return $tenant;
}
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{
return static::resolveTenantContextForCurrentPanelOrFail();
}
}

View File

@ -12,6 +12,8 @@
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page
{
@ -41,7 +43,8 @@ class TenantRequiredPermissions extends Page
*/
public array $viewModel = [];
public ?Tenant $scopedTenant = null;
#[Locked]
public ?int $scopedTenantId = null;
public static function canAccess(): bool
{
@ -50,7 +53,7 @@ public static function canAccess(): bool
public function currentTenant(): ?Tenant
{
return $this->scopedTenant;
return $this->trustedScopedTenant();
}
public function mount(): void
@ -61,7 +64,7 @@ public function mount(): void
abort(404);
}
$this->scopedTenant = $tenant;
$this->scopedTenantId = (int) $tenant->getKey();
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
@ -143,7 +146,7 @@ public function resetFilters(): void
private function refreshViewModel(): void
{
$tenant = $this->scopedTenant;
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
@ -172,7 +175,7 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): string
{
$tenant = $this->scopedTenant;
$tenant = $this->trustedScopedTenant();
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
@ -183,7 +186,7 @@ public function reRunVerificationUrl(): string
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->scopedTenant;
$tenant = $this->trustedScopedTenant();
if (! $tenant instanceof Tenant) {
return null;
@ -234,4 +237,47 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
return $user->canAccessTenant($tenant);
}
private function trustedScopedTenant(): ?Tenant
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$workspaceContext = app(WorkspaceContext::class);
try {
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
} catch (NotFoundHttpException) {
return null;
}
$routeTenant = static::resolveScopedTenant();
if ($routeTenant instanceof Tenant) {
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
if ($this->scopedTenantId === null) {
return null;
}
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
if (! $tenant instanceof Tenant) {
return null;
}
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
}

View File

@ -39,6 +39,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Livewire\TrustedState\TrustedStateResolver;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\Onboarding\OnboardingLifecycleState;
@ -88,6 +89,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -123,8 +125,14 @@ protected function getLayoutData(): array
public ?Tenant $managedTenant = null;
#[Locked]
public ?int $managedTenantId = null;
public ?TenantOnboardingSession $onboardingSession = null;
#[Locked]
public ?int $onboardingSessionId = null;
public ?int $onboardingSessionVersion = null;
public ?int $selectedProviderConnectionId = null;
@ -151,6 +159,8 @@ protected function getLayoutData(): array
protected function getHeaderActions(): array
{
$actions = [];
$draft = $this->currentOnboardingSessionRecord();
$tenant = $this->currentManagedTenantRecord();
if (isset($this->workspace)) {
$actions[] = Action::make('back_to_workspace')
@ -170,10 +180,10 @@ protected function getHeaderActions(): array
$actions[] = Action::make('view_linked_tenant')
->label($this->linkedTenantActionLabel())
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
}
if ($this->canResumeDraft($this->onboardingSession)) {
if ($this->canResumeDraft($draft)) {
$actions[] = Action::make('cancel_onboarding_draft')
->label('Cancel draft')
->color('danger')
@ -184,7 +194,7 @@ protected function getHeaderActions(): array
->action(fn () => $this->cancelOnboardingDraft());
}
if ($this->canDeleteDraft($this->onboardingSession)) {
if ($this->canDeleteDraft($draft)) {
$actions[] = Action::make('delete_onboarding_draft_header')
->label('Delete draft')
->color('danger')
@ -202,17 +212,18 @@ protected function getHeaderActions(): array
private function canViewLinkedTenant(): bool
{
$user = auth()->user();
$tenant = $this->currentManagedTenantRecord();
if (! $user instanceof User || ! $this->managedTenant instanceof Tenant) {
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return false;
}
if (! $user->canAccessTenant($this->managedTenant)) {
if (! $user->canAccessTenant($tenant)) {
return false;
}
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $this->managedTenant,
tenant: $tenant,
question: TenantOperabilityQuestion::TenantBoundViewability,
actor: $user,
workspaceId: (int) $this->workspace->getKey(),
@ -222,13 +233,15 @@ private function canViewLinkedTenant(): bool
private function linkedTenantActionLabel(): string
{
if (! $this->managedTenant instanceof Tenant) {
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
return 'View tenant';
}
return sprintf(
'View tenant (%s)',
TenantLifecyclePresentation::fromTenant($this->managedTenant)->label,
TenantLifecyclePresentation::fromTenant($tenant)->label,
);
}
@ -712,7 +725,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
$tenant = $draft->tenant;
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
$this->managedTenant = $tenant;
$this->setManagedTenant($tenant);
}
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
@ -801,7 +814,9 @@ private function draftPickerSchema(): array
*/
private function resumeContextSchema(): array
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return [];
}
@ -814,19 +829,19 @@ private function resumeContextSchema(): array
->schema([
Text::make('Tenant')
->color('gray'),
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft))
->weight(FontWeight::SemiBold),
Text::make('Current stage')
->color('gray'),
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
Text::make(fn () => $this->draftStageLabel($this->currentOnboardingSessionRecord() ?? $draft))
->badge()
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
->color(fn () => $this->draftStageColor($this->currentOnboardingSessionRecord() ?? $draft)),
Text::make('Started by')
->color('gray'),
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->startedByUser?->name ?? 'Unknown'),
Text::make('Last updated by')
->color('gray'),
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->updatedByUser?->name ?? 'Unknown'),
]),
];
}
@ -836,11 +851,13 @@ private function resumeContextSchema(): array
*/
private function nonResumableSummarySchema(): array
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return [];
}
$statusLabel = $this->onboardingSession->status()->label();
$statusLabel = $draft->status()->label();
return [
Callout::make("This onboarding draft is {$statusLabel}.")
@ -855,16 +872,16 @@ private function nonResumableSummarySchema(): array
->color('gray'),
Text::make(fn (): string => $statusLabel)
->badge()
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
->color(fn () => $this->draftStatusColor($this->currentOnboardingSessionRecord() ?? $draft)),
Text::make('Primary domain')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['primary_domain'] ?? null) ?: '—')),
Text::make('Environment')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['environment'] ?? null) ?: '—')),
Text::make('Notes')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['notes'] ?? null) ?: '—')),
]),
SchemaActions::make([
Action::make('back_to_workspace_summary')
@ -882,7 +899,7 @@ private function nonResumableSummarySchema(): array
->modalHeading('Delete onboarding draft')
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
->modalSubmitActionLabel('Delete draft')
->visible(fn (): bool => $this->canDeleteDraft($this->onboardingSession))
->visible(fn (): bool => $this->canDeleteDraft($this->currentOnboardingSessionRecord() ?? $draft))
->action(fn () => $this->deleteOnboardingDraft()),
]),
];
@ -892,7 +909,7 @@ private function startNewOnboardingDraft(): void
{
$this->showDraftPicker = false;
$this->showStartState = true;
$this->managedTenant = null;
$this->setManagedTenant(null);
$this->setOnboardingSession(null);
$this->selectedProviderConnectionId = null;
$this->selectedBootstrapOperationTypes = [];
@ -944,9 +961,20 @@ private function cancelOnboardingDraft(): void
abort(404);
}
$this->authorize('cancel', $this->onboardingSession);
$this->authorizeWorkspaceMember($user);
if (! $this->canResumeDraft($this->onboardingSession)) {
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
);
$this->setOnboardingSession($draft);
$this->authorize('cancel', $draft);
if (! $this->canResumeDraft($draft)) {
Notification::make()
->title('Draft is not resumable')
->warning()
@ -1007,8 +1035,7 @@ private function cancelOnboardingDraft(): void
],
);
$this->managedTenant = $normalizedTenant;
$this->onboardingSession->setRelation('tenant', $normalizedTenant);
$this->setManagedTenant($normalizedTenant);
}
Notification::make()
@ -1031,9 +1058,20 @@ private function deleteOnboardingDraft(): void
abort(404);
}
$this->authorize('cancel', $this->onboardingSession);
$this->authorizeWorkspaceMember($user);
if (! $this->canDeleteDraft($this->onboardingSession)) {
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
);
$this->setOnboardingSession($draft);
$this->authorize('cancel', $draft);
if (! $this->canDeleteDraft($draft)) {
Notification::make()
->title('Draft cannot be deleted')
->warning()
@ -1042,7 +1080,6 @@ private function deleteOnboardingDraft(): void
return;
}
$draft = $this->onboardingSession;
$draftId = (int) $draft->getKey();
$draftTitle = $this->draftTitle($draft);
$draftStatus = $draft->status()->value;
@ -1070,7 +1107,7 @@ private function deleteOnboardingDraft(): void
targetLabel: $draftTitle,
);
$this->managedTenant = null;
$this->setManagedTenant(null);
$this->setOnboardingSession(null);
Notification::make()
@ -1083,8 +1120,10 @@ private function deleteOnboardingDraft(): void
private function showsNonResumableSummary(): bool
{
return $this->onboardingSession instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($this->onboardingSession);
$draft = $this->currentOnboardingSessionRecord();
return $draft instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($draft);
}
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
@ -1117,11 +1156,13 @@ private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App
private function shouldShowDraftLandingAction(): bool
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return false;
}
if (! $this->canResumeDraft($this->onboardingSession)) {
if (! $this->canResumeDraft($draft)) {
return false;
}
@ -1219,14 +1260,95 @@ private function expectedDraftVersion(): ?int
private function setOnboardingSession(?TenantOnboardingSession $draft): void
{
$this->onboardingSession = $draft;
$this->onboardingSessionId = $draft instanceof TenantOnboardingSession
? (int) $draft->getKey()
: null;
$this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession
? $draft->expectedVersion()
: null;
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
$this->setManagedTenant($draft->tenant);
return;
}
if ($draft instanceof TenantOnboardingSession && $draft->tenant_id !== null) {
$this->managedTenantId = (int) $draft->tenant_id;
return;
}
$this->setManagedTenant(null);
}
private function setManagedTenant(?Tenant $tenant): void
{
$this->managedTenant = $tenant;
$this->managedTenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: null;
if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof Tenant) {
$this->onboardingSession->setRelation('tenant', $tenant);
}
}
private function currentOnboardingSessionRecord(): ?TenantOnboardingSession
{
if ($this->onboardingSession instanceof TenantOnboardingSession
&& $this->onboardingSessionId !== null
&& (int) $this->onboardingSession->getKey() === $this->onboardingSessionId) {
return $this->onboardingSession;
}
if ($this->onboardingSessionId === null) {
return $this->onboardingSession;
}
$query = TenantOnboardingSession::query()
->with(['tenant', 'startedByUser', 'updatedByUser'])
->whereKey($this->onboardingSessionId);
if (isset($this->workspace)) {
$query->where('workspace_id', (int) $this->workspace->getKey());
}
return $query->first();
}
private function currentManagedTenantRecord(): ?Tenant
{
$draft = $this->currentOnboardingSessionRecord();
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
return $draft->tenant;
}
if ($this->managedTenant instanceof Tenant
&& $this->managedTenantId !== null
&& (int) $this->managedTenant->getKey() === $this->managedTenantId) {
return $this->managedTenant;
}
if ($this->managedTenantId === null) {
return $this->managedTenant;
}
$query = Tenant::query()->withTrashed()->whereKey($this->managedTenantId);
if (isset($this->workspace)) {
$query->where('workspace_id', (int) $this->workspace->getKey());
}
return $query->first();
}
private function refreshOnboardingDraftFromBackend(): void
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return;
}
@ -1237,15 +1359,11 @@ private function refreshOnboardingDraftFromBackend(): void
}
$this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve(
$this->onboardingSession,
$draft,
$user,
$this->workspace,
));
if ($this->onboardingSession->tenant instanceof Tenant) {
$this->managedTenant = $this->onboardingSession->tenant;
}
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
$this->initializeWizardData();
@ -1275,11 +1393,13 @@ private function handleImmutableDraft(string $title = 'This onboarding draft is
private function lifecycleState(): OnboardingLifecycleState
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return OnboardingLifecycleState::Draft;
}
return $this->lifecycleService()->snapshot($this->onboardingSession)['lifecycle_state'];
return $this->lifecycleService()->snapshot($draft)['lifecycle_state'];
}
private function lifecycleStateLabel(): string
@ -1302,30 +1422,38 @@ private function lifecycleStateColor(): string
private function currentCheckpointLabel(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return OnboardingCheckpoint::Identify->label();
}
return ($this->lifecycleService()->snapshot($this->onboardingSession)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
return ($this->lifecycleService()->snapshot($draft)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
?? OnboardingCheckpoint::Identify->label();
}
public function shouldPollCheckpointLifecycle(): bool
{
return $this->onboardingSession instanceof TenantOnboardingSession
&& $this->lifecycleService()->hasActiveCheckpoint($this->onboardingSession);
$draft = $this->currentOnboardingSessionRecord();
return $draft instanceof TenantOnboardingSession
&& $this->lifecycleService()->hasActiveCheckpoint($draft);
}
public function refreshCheckpointLifecycle(): void
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return;
}
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($this->onboardingSession));
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($draft));
if ($this->managedTenant instanceof Tenant) {
$this->managedTenant->refresh();
$tenant = $this->currentManagedTenantRecord();
if ($tenant instanceof Tenant) {
$this->setManagedTenant($tenant->fresh());
}
$this->initializeWizardData();
@ -1351,20 +1479,24 @@ private function initializeWizardData(): void
$this->data['new_connection']['is_default'] ??= true;
}
if ($this->managedTenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
$this->data['name'] ??= (string) $this->managedTenant->name;
$this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? '');
$tenant = $this->currentManagedTenantRecord();
$notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null;
if ($tenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
$this->data['name'] ??= (string) $tenant->name;
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
$notes = is_array($tenant->metadata) ? ($tenant->metadata['notes'] ?? null) : null;
if (is_string($notes) && trim($notes) !== '') {
$this->data['notes'] ??= trim($notes);
}
}
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
$draft = $this->currentOnboardingSessionRecord();
if ($draft instanceof TenantOnboardingSession) {
$state = is_array($draft->state) ? $draft->state : [];
if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') {
$this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']);
@ -1394,13 +1526,13 @@ private function initializeWizardData(): void
}
}
$providerConnectionId = $this->resolvePersistedProviderConnectionId($this->onboardingSession->state['provider_connection_id'] ?? null);
$providerConnectionId = $this->resolvePersistedProviderConnectionId($draft->state['provider_connection_id'] ?? null);
if ($providerConnectionId !== null) {
$this->data['provider_connection_id'] = $providerConnectionId;
$this->selectedProviderConnectionId = $providerConnectionId;
}
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
$types = $draft->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
}
@ -1418,7 +1550,7 @@ private function initializeWizardData(): void
private function computeWizardStartStep(): int
{
return app(OnboardingDraftStageResolver::class)
->resolve($this->onboardingSession)
->resolve($this->currentOnboardingSessionRecord())
->wizardStep();
}
@ -1427,13 +1559,15 @@ private function computeWizardStartStep(): int
*/
private function providerConnectionOptions(): array
{
if (! $this->managedTenant instanceof Tenant) {
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
return [];
}
return ProviderConnection::query()
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $this->managedTenant->getKey())
->where('tenant_id', $tenant->getKey())
->orderByDesc('is_default')
->orderBy('display_name')
->pluck('display_name', 'id')
@ -1450,11 +1584,13 @@ private function verificationStatusLabel(): string
private function verificationStatus(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
return 'not_started';
}
return $this->lifecycleService()->verificationStatus($this->onboardingSession, $this->selectedProviderConnectionId);
return $this->lifecycleService()->verificationStatus($draft, $this->selectedProviderConnectionId);
}
private function verificationStatusFromRunOutcome(OperationRun $run): string
@ -1951,6 +2087,19 @@ private function authorizeEditableDraft(User $user): void
return;
}
$expectedVersion = $this->expectedDraftVersion();
$this->setOnboardingSession(app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
));
if ($expectedVersion !== null) {
$this->onboardingSessionVersion = $expectedVersion;
}
$this->authorize('update', $this->onboardingSession);
if (! $this->canResumeDraft($this->onboardingSession)) {
@ -1958,6 +2107,27 @@ private function authorizeEditableDraft(User $user): void
}
}
private function trustedManagedTenantForUser(User $user): Tenant
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = $tenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = app(WorkspaceContext::class)->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
$this->setManagedTenant($tenant);
return $tenant;
}
private function canResumeDraft(?TenantOnboardingSession $draft): bool
{
if (! $draft instanceof TenantOnboardingSession) {
@ -1982,9 +2152,11 @@ private function canResumeDraft(?TenantOnboardingSession $draft): bool
private function authorizeWorkspaceMember(User $user): void
{
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
abort(404);
}
$this->workspace = app(TrustedStateResolver::class)->currentWorkspaceForMember(
$user,
app(WorkspaceContext::class),
request(),
);
}
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
@ -2181,7 +2353,7 @@ public function identifyManagedTenant(array $data): void
resourceId: (string) $tenant->getKey(),
);
$this->managedTenant = $tenant;
$this->setManagedTenant($tenant);
$this->setOnboardingSession($session);
});
} catch (OnboardingDraftConflictException) {
@ -2220,13 +2392,11 @@ public function selectProviderConnection(int $providerConnectionId): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->trustedManagedTenantForUser($user);
$connection = ProviderConnection::query()
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $this->managedTenant->getKey())
->where('tenant_id', (int) $tenant->getKey())
->whereKey($providerConnectionId)
->first();
@ -2272,7 +2442,7 @@ public function selectProviderConnection(int $providerConnectionId): void
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
@ -2305,11 +2475,7 @@ public function createProviderConnection(array $data): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
@ -2498,7 +2664,7 @@ public function createProviderConnection(array $data): void
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
@ -2540,7 +2706,9 @@ public function startVerification(): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
try {
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
} catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
Notification::make()
->title('Identify a managed tenant first')
->warning()
@ -2549,8 +2717,6 @@ public function startVerification(): void
return;
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
@ -2751,6 +2917,15 @@ public function startVerification(): void
public function refreshVerificationStatus(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMember($user);
$this->authorizeEditableDraft($user);
$this->refreshCheckpointLifecycle();
Notification::make()
@ -2773,11 +2948,7 @@ public function startBootstrap(array $operationTypes): void
$this->authorizeWorkspaceMember($user);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
@ -3112,23 +3283,27 @@ private function canCompleteOnboarding(): bool
private function completionSummaryTenantLine(): string
{
if (! $this->managedTenant instanceof Tenant) {
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
return '—';
}
$name = $this->managedTenant->name ?? '—';
$tenantId = $this->managedTenant->graphTenantId();
$name = $tenant->name ?? '—';
$tenantId = $tenant->graphTenantId();
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
}
private function completionSummaryConnectionLabel(): string
{
if (! $this->managedTenant instanceof Tenant) {
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
return '—';
}
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
$connection = $this->resolveSelectedProviderConnection($tenant);
if (! $connection instanceof ProviderConnection) {
return 'Not configured';
@ -3256,16 +3431,14 @@ public function completeOnboarding(): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
$tenant = $this->trustedManagedTenantForUser($user);
$completionOutcome = app(TenantOperabilityService::class)->outcomeFor(
tenant: $this->managedTenant,
tenant: $tenant,
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
actor: $user,
workspaceId: (int) $this->workspace->getKey(),
@ -3316,7 +3489,7 @@ public function completeOnboarding(): void
}
}
$tenant = $this->managedTenant->fresh();
$tenant = $tenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);

View File

@ -107,10 +107,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-magnifying-glass')
->form($this->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
$this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId;
@ -142,9 +139,7 @@ protected function getHeaderActions(): array
]);
}
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
: FindingsLifecycleBackfillScope::allTenants();
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
$user = auth('platform')->user();
@ -286,4 +281,34 @@ private function lastRunForType(string $type): ?OperationRun
->latest('id')
->first();
}
/**
* @param array<string, mixed> $data
*/
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
if (! $scope->isSingleTenant()) {
return $scope;
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
return FindingsLifecycleBackfillScope::allTenants();
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
}

View File

@ -10,6 +10,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class OnboardingDraftMutationService
{
@ -100,6 +101,25 @@ public function mutate(
});
}
public function lockForTrustedMutation(TenantOnboardingSession|int|string $draft, Workspace $workspace): TenantOnboardingSession
{
$draftId = $draft instanceof TenantOnboardingSession
? (int) $draft->getKey()
: (int) $draft;
$lockedDraft = TenantOnboardingSession::query()
->whereKey($draftId)
->where('workspace_id', (int) $workspace->getKey())
->lockForUpdate()
->first();
if (! $lockedDraft instanceof TenantOnboardingSession) {
throw new NotFoundHttpException;
}
return $lockedDraft;
}
private function resolveDraftForIdentity(
Workspace $workspace,
string $entraTenantId,

View File

@ -71,6 +71,15 @@ public function resolve(TenantOnboardingSession|int|string $draft, User $user, W
return $resolvedDraft;
}
/**
* @throws AuthorizationException
* @throws NotFoundHttpException
*/
public function resolveForTrustedAction(TenantOnboardingSession|int|string $draft, User $user, Workspace $workspace): TenantOnboardingSession
{
return $this->resolve($draft, $user, $workspace);
}
/**
* @return Collection<int, TenantOnboardingSession>
*/

View File

@ -33,4 +33,42 @@ public function ensureAllowed(Tenant $tenant): void
'tenant_id' => 'This tenant is not eligible for System runbooks.',
]);
}
public function resolveAllowed(int|string|null $tenantId): ?Tenant
{
if (! is_numeric($tenantId)) {
return null;
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant) {
return null;
}
$this->ensureAllowed($tenant);
return $tenant;
}
public function resolveAllowedOrFail(int|string|null $tenantId): Tenant
{
if (! is_numeric($tenantId) || (int) $tenantId <= 0) {
throw ValidationException::withMessages([
'tenant_id' => 'Select a tenant.',
]);
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant) {
throw ValidationException::withMessages([
'tenant_id' => 'Select a valid tenant.',
]);
}
$this->ensureAllowed($tenant);
return $tenant;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Support\Livewire\TrustedState;
enum TrustedStateClass: string
{
case Presentation = 'presentation';
case LockedIdentity = 'locked_identity';
case ServerDerivedAuthority = 'server_derived_authority';
public function allowsClientMutation(): bool
{
return $this === self::Presentation;
}
public function requiresServerRevalidation(): bool
{
return $this !== self::Presentation;
}
}

View File

@ -0,0 +1,472 @@
<?php
declare(strict_types=1);
namespace App\Support\Livewire\TrustedState;
use InvalidArgumentException;
final class TrustedStatePolicy
{
public const MANAGED_TENANT_ONBOARDING_WIZARD = 'managed_tenant_onboarding_wizard';
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
public const SYSTEM_RUNBOOKS = 'system_runbooks';
/**
* @return array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }
*/
private function field(
string $name,
TrustedStateClass $stateClass,
string $phpType,
string $sourceOfTruth,
bool $usedForProtectedAction,
bool $revalidationRequired,
array $implementationMarkers,
string $notes,
): array {
return [
'name' => $name,
'state_class' => $stateClass->value,
'php_type' => $phpType,
'source_of_truth' => $sourceOfTruth,
'used_for_protected_action' => $usedForProtectedAction,
'revalidation_required' => $revalidationRequired,
'implementation_markers' => $implementationMarkers,
'notes' => $notes,
];
}
/**
* @return array<string, array{
* component_name: string,
* plane: string,
* route_anchor: string|null,
* authority_sources: list<string>,
* locked_identities: list<string>,
* locked_identity_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* mutable_selectors: list<string>,
* mutable_selector_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* server_derived_authority_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* forbidden_public_authority_fields: list<string>
* }>
*/
public function firstSlice(): array
{
return [
self::MANAGED_TENANT_ONBOARDING_WIZARD => [
'component_name' => 'Managed tenant onboarding wizard',
'plane' => 'admin_workspace',
'route_anchor' => 'onboarding_draft',
'authority_sources' => [
'route_binding',
'workspace_context',
'persisted_onboarding_draft',
'explicit_scoped_query',
],
'locked_identities' => [
'workspace_id',
'managed_tenant_id',
'onboarding_session_id',
],
'locked_identity_fields' => [
$this->field(
name: 'managedTenantId',
stateClass: TrustedStateClass::LockedIdentity,
phpType: '?int',
sourceOfTruth: 'persisted_onboarding_draft',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
"#[Locked]\n public ?int \$managedTenantId = null;",
'public ?int $managedTenantId = null;',
'currentManagedTenantRecord()',
],
notes: 'Continuity-only tenant identity; protected actions must re-resolve the tenant record before use.',
),
$this->field(
name: 'onboardingSessionId',
stateClass: TrustedStateClass::LockedIdentity,
phpType: '?int',
sourceOfTruth: 'persisted_onboarding_draft',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
"#[Locked]\n public ?int \$onboardingSessionId = null;",
'public ?int $onboardingSessionId = null;',
'currentOnboardingSessionRecord()',
],
notes: 'Continuity-only draft identity; protected actions re-resolve canonical draft truth from the persisted session.',
),
],
'mutable_selectors' => [
'selected_provider_connection_id',
'selected_bootstrap_operation_types',
],
'mutable_selector_fields' => [
$this->field(
name: 'selectedProviderConnectionId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'explicit_scoped_query',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?int $selectedProviderConnectionId = null;',
'selectedProviderConnectionId',
'resolveOnboardingDraft(',
],
notes: 'Provider selection is a mutable proposal and must be validated against the canonical draft and workspace before use.',
),
$this->field(
name: 'selectedBootstrapOperationTypes',
stateClass: TrustedStateClass::Presentation,
phpType: 'array<int, string>',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public array $selectedBootstrapOperationTypes = [];',
],
notes: 'Wizard UI choice only; it does not define tenant or workspace authority.',
),
],
'server_derived_authority_fields' => [
$this->field(
name: 'workspace',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: 'Workspace',
sourceOfTruth: 'workspace_context',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public Workspace $workspace;',
'currentWorkspaceForMember(',
],
notes: 'Workspace membership and current workspace context outrank any client-submitted state.',
),
$this->field(
name: 'onboardingSession',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: '?TenantOnboardingSession',
sourceOfTruth: 'persisted_onboarding_draft',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?TenantOnboardingSession $onboardingSession = null;',
'resolveOnboardingDraft(',
'currentOnboardingSessionRecord()',
],
notes: 'Draft model instances remain convenience state only and are refreshed from canonical persisted draft truth.',
),
$this->field(
name: 'managedTenant',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: '?Tenant',
sourceOfTruth: 'explicit_scoped_query',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?Tenant $managedTenant = null;',
'currentManagedTenantRecord()',
],
notes: 'Tenant model instances are display helpers only and must be re-derived from draft or scoped tenant queries.',
),
],
'forbidden_public_authority_fields' => [
'workspace',
'managedTenant',
'onboardingSession',
],
],
self::TENANT_REQUIRED_PERMISSIONS => [
'component_name' => 'Tenant required permissions',
'plane' => 'admin_tenant',
'route_anchor' => 'tenant',
'authority_sources' => [
'route_binding',
'workspace_context',
'explicit_scoped_query',
],
'locked_identities' => [
'scoped_tenant_id',
],
'locked_identity_fields' => [
$this->field(
name: 'scopedTenantId',
stateClass: TrustedStateClass::LockedIdentity,
phpType: '?int',
sourceOfTruth: 'route_binding',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
"#[Locked]\n public ?int \$scopedTenantId = null;",
'public ?int $scopedTenantId = null;',
'trustedScopedTenant()',
],
notes: 'Route-derived tenant identity stays locked for continuity and is re-scoped against workspace context before use.',
),
],
'mutable_selectors' => [
'status',
'type',
'features',
'search',
],
'mutable_selector_fields' => [
$this->field(
name: 'status',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
"public string \$status = 'missing';",
],
notes: 'Filter-only state for the permissions view model.',
),
$this->field(
name: 'type',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
"public string \$type = 'all';",
],
notes: 'Filter-only state for the permissions view model.',
),
$this->field(
name: 'features',
stateClass: TrustedStateClass::Presentation,
phpType: 'array<int, string>',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public array $features = [];',
],
notes: 'Filter-only state for the permissions view model.',
),
$this->field(
name: 'search',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
"public string \$search = '';",
],
notes: 'Filter-only state for the permissions view model.',
),
],
'server_derived_authority_fields' => [
$this->field(
name: 'scopedTenant',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: '?Tenant',
sourceOfTruth: 'explicit_scoped_query',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'resolveScopedTenant()',
'trustedScopedTenant()',
'currentWorkspaceId(request())',
],
notes: 'Tenant scope remains route- and workspace-derived even when mutable filters change.',
),
],
'forbidden_public_authority_fields' => [
'scopedTenant',
],
],
self::SYSTEM_RUNBOOKS => [
'component_name' => 'System runbooks',
'plane' => 'system_platform',
'route_anchor' => null,
'authority_sources' => [
'allowed_tenant_universe',
'explicit_scoped_query',
],
'locked_identities' => [],
'locked_identity_fields' => [],
'mutable_selectors' => [
'findingsTenantId',
'tenantId',
'findingsScopeMode',
'scopeMode',
],
'mutable_selector_fields' => [
$this->field(
name: 'findingsTenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?int $findingsTenantId = null;',
'resolveAllowedOrFail($this->findingsTenantId)',
],
notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.',
),
$this->field(
name: 'tenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public ?int $tenantId = null;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
$this->field(
name: 'findingsScopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
'trustedFindingsScopeFromState(',
],
notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.',
),
$this->field(
name: 'scopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
],
'server_derived_authority_fields' => [
$this->field(
name: 'findingsScope',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: 'FindingsLifecycleBackfillScope',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'trustedFindingsScopeFromFormData(',
'trustedFindingsScopeFromState(',
'resolveAllowedOrFail(',
],
notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.',
),
],
'forbidden_public_authority_fields' => [],
],
];
}
/**
* @return array{
* component_name: string,
* plane: string,
* route_anchor: string|null,
* authority_sources: list<string>,
* locked_identities: list<string>,
* locked_identity_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* mutable_selectors: list<string>,
* mutable_selector_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* server_derived_authority_fields: list<array{
* name: string,
* state_class: string,
* php_type: string,
* source_of_truth: string,
* used_for_protected_action: bool,
* revalidation_required: bool,
* implementation_markers: list<string>,
* notes: string
* }>,
* forbidden_public_authority_fields: list<string>
* }
*/
public function forComponent(string $component): array
{
$policy = $this->firstSlice()[$component] ?? null;
if ($policy === null) {
throw new InvalidArgumentException("Unknown trusted-state component [{$component}].");
}
return $policy;
}
/**
* @return list<string>
*/
public function components(): array
{
return array_keys($this->firstSlice());
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support\Livewire\TrustedState;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\System\AllowedTenantUniverse;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
final class TrustedStateResolver
{
/**
* @return array<string>
*/
public function requiredAuthoritySources(string $component, TrustedStatePolicy $policy): array
{
return $policy->forComponent($component)['authority_sources'];
}
public function currentWorkspaceForMember(User $user, WorkspaceContext $workspaceContext, ?Request $request = null): Workspace
{
return $workspaceContext->currentWorkspaceForMemberOrFail($user, $request);
}
public function resolveOnboardingDraft(
TenantOnboardingSession|int|string $draft,
User $user,
Workspace $workspace,
OnboardingDraftResolver $resolver,
): TenantOnboardingSession {
return $resolver->resolveForTrustedAction($draft, $user, $workspace);
}
public function resolveAllowedTenantProposal(
int|string|null $tenantId,
AllowedTenantUniverse $allowedTenantUniverse,
): ?Tenant {
return $allowedTenantUniverse->resolveAllowed($tenantId);
}
public function resolveAllowedTenantProposalOrFail(
int|string|null $tenantId,
AllowedTenantUniverse $allowedTenantUniverse,
): Tenant {
return $allowedTenantUniverse->resolveAllowedOrFail($tenantId);
}
}

View File

@ -10,6 +10,7 @@
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class WorkspaceContext
{
@ -273,6 +274,28 @@ public function isMember(User $user, Workspace $workspace): bool
->exists();
}
public function currentWorkspaceForMemberOrFail(User $user, ?Request $request = null): Workspace
{
$workspace = $this->currentWorkspace($request);
if (! $workspace instanceof Workspace || ! $this->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
return $workspace;
}
public function ensureTenantAccessibleInCurrentWorkspace(Tenant $tenant, User $user, ?Request $request = null): Tenant
{
$workspace = $this->currentWorkspaceForMemberOrFail($user, $request);
if ((int) $tenant->workspace_id !== (int) $workspace->getKey() || ! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
return $tenant;
}
private function isWorkspaceSelectable(Workspace $workspace): bool
{
return empty($workspace->archived_at);

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Livewire Context Locking and Trusted-State Reduction
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation pass completed on 2026-03-18.
- The spec is intentionally bounded to tier-1 stateful surfaces for the first slice: onboarding wizard, one tenant-context page, and one system page.
- Existing Filament v5 and Livewire v4 foundations are treated as compatibility constraints, while the requirements themselves stay framed around trust-boundary outcomes.

View File

@ -0,0 +1,139 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/trusted-state-guard.schema.json",
"title": "Trusted State Guard Policy",
"type": "object",
"additionalProperties": false,
"required": [
"component",
"plane",
"locked_identities",
"locked_identity_fields",
"server_derived_authority",
"server_derived_authority_fields",
"mutable_selectors",
"mutable_selector_fields",
"forbidden_public_authority_fields"
],
"properties": {
"component": {
"type": "string",
"minLength": 1
},
"plane": {
"type": "string",
"enum": [
"admin_workspace",
"admin_tenant",
"system_platform"
]
},
"locked_identities": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"locked_identity_fields": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"server_derived_authority": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"server_derived_authority_fields": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"mutable_selectors": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"mutable_selector_fields": {
"type": "array",
"items": {
"$ref": "#/$defs/trustedField"
}
},
"forbidden_public_authority_fields": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
},
"$defs": {
"trustedField": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"state_class",
"php_type",
"source_of_truth",
"used_for_protected_action",
"revalidation_required",
"implementation_markers",
"notes"
],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"state_class": {
"type": "string",
"enum": [
"presentation",
"locked_identity",
"server_derived_authority"
]
},
"php_type": {
"type": "string",
"minLength": 1
},
"source_of_truth": {
"type": "string",
"enum": [
"route_binding",
"workspace_context",
"tenant_panel_context",
"persisted_onboarding_draft",
"allowed_tenant_universe",
"explicit_scoped_query",
"presentation_only"
]
},
"used_for_protected_action": {
"type": "boolean"
},
"revalidation_required": {
"type": "boolean"
},
"implementation_markers": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"notes": {
"type": "string",
"minLength": 1
}
}
}
}
}

View File

@ -0,0 +1,198 @@
openapi: 3.1.0
info:
title: Trusted State Hardening Logical Contract
version: 0.1.0
summary: Internal logical contract for protected actions on stateful Livewire and Filament surfaces
description: |
This contract documents the server-side trust boundary for covered stateful
surfaces. It is semantic, not transport-prescriptive. Existing Filament and
Livewire handlers may satisfy this contract without adding public HTTP endpoints.
The first slice distinguishes presentation-only selector proposals, locked
scalar continuity identities, and server-derived authority that must be
re-resolved before every protected action.
servers:
- url: /admin
- url: /system
paths:
/onboarding/{onboardingDraft}/verify-access:
post:
summary: Start or rerun verify access from a trusted onboarding draft context
operationId: trustedOnboardingVerifyAccess
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
selected_provider_connection_id:
type: integer
nullable: true
description: Mutable selector proposal that must be revalidated within the current draft scope.
responses:
'202':
description: Request accepted against canonical draft and provider scope.
content:
application/json:
schema:
$ref: '#/components/schemas/TrustedActionAccepted'
'403':
description: Actor is in scope but lacks the required capability.
'404':
description: Draft or provider selection is out of scope or not entitled.
/onboarding/{onboardingDraft}/activate:
post:
summary: Activate a trusted onboarding draft
operationId: trustedOnboardingActivate
parameters:
- $ref: '#/components/parameters/OnboardingDraftId'
responses:
'200':
description: Activation executed against canonical draft truth.
'403':
description: Actor is in scope but lacks activation authority.
'404':
description: Draft is missing, stale, or foreign to the current workspace or tenant scope.
/tenants/{tenant}/required-permissions:
get:
summary: Read required permissions from a route-derived tenant scope
operationId: trustedTenantRequiredPermissionsRead
parameters:
- $ref: '#/components/parameters/TenantRouteKey'
- in: query
name: status
schema:
type: string
- in: query
name: type
schema:
type: string
- in: query
name: features[]
schema:
type: array
items:
type: string
- in: query
name: search
schema:
type: string
responses:
'200':
description: Tenant-scoped page rendered from canonical route and workspace context.
'404':
description: Tenant is outside the current workspace or tenant entitlement scope.
/ops/runbooks/findings-lifecycle/preflight:
post:
summary: Preflight a system runbook with validated selector scope
operationId: trustedRunbookPreflight
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RunbookScopeProposal'
responses:
'200':
description: Preflight completed for an allowed scope.
content:
application/json:
schema:
$ref: '#/components/schemas/RunbookPreflightAccepted'
'403':
description: Platform actor lacks the required runbook capability.
'404':
description: Selected tenant is outside the actor's allowed tenant universe.
components:
parameters:
OnboardingDraftId:
name: onboardingDraft
in: path
required: true
schema:
type: integer
TenantRouteKey:
name: tenant
in: path
required: true
schema:
type: string
schemas:
TrustedActionAccepted:
type: object
additionalProperties: false
required:
- authority_source
- target_scope
properties:
authority_source:
type: string
enum:
- route_binding
- persisted_onboarding_draft
- workspace_context
- explicit_scoped_query
target_scope:
type: object
additionalProperties: false
required:
- workspace_id
properties:
workspace_id:
type: integer
tenant_id:
type: integer
nullable: true
provider_connection_id:
type: integer
nullable: true
trusted_state_class:
type: string
enum:
- locked_identity
- server_derived_authority
RunbookScopeProposal:
type: object
additionalProperties: false
required:
- mode
properties:
mode:
type: string
enum:
- all_tenants
- single_tenant
tenant_id:
type: integer
nullable: true
RunbookPreflightAccepted:
type: object
additionalProperties: false
required:
- resolved_scope
properties:
resolved_scope:
type: object
additionalProperties: false
required:
- mode
properties:
mode:
type: string
enum:
- all_tenants
- single_tenant
tenant_id:
type: integer
nullable: true
trusted_state_class:
type: string
enum:
- server_derived_authority

View File

@ -0,0 +1,189 @@
# Data Model: Livewire Context Locking and Trusted-State Reduction
This feature does not introduce new database tables in the first slice. The data-model work formalizes trust-boundary entities and field classes that are already implicit in existing Livewire and Filament components.
## 1. Trusted State Class
### Purpose
Defines how a given piece of component state may be stored and used.
### Allowed values
- `presentation`
- `locked_identity`
- `server_derived_authority`
### Rules
- `presentation` state may remain public and mutable.
- `locked_identity` state may remain public only when it is client-immutable and still re-resolved before protected actions.
- `server_derived_authority` state must not rely on client mutability at all and is derived from route, session, resolver, or persisted workflow truth.
## 2. Component Trusted-State Policy
### Purpose
Represents the trust contract for one stateful Livewire or Filament surface.
### Fields
- `component_name`: human-readable identifier for the surface
- `plane`: `admin_tenant`, `admin_workspace`, or `system_platform`
- `route_anchor`: route-bound record or context source, if any
- `authority_sources`: canonical sources used to re-derive protected targets
- `locked_identities`: list of public scalar IDs allowed to persist for continuity
- `mutable_selectors`: list of user-controlled selectors that remain proposals only
- `forbidden_public_authority_fields`: list of model objects or mutable IDs disallowed as final authority
### Relationships
- One component policy has many trusted fields.
- One component policy has many forged-state regression cases.
## 3. Trusted Field
### Purpose
Represents one public property or equivalent state slot on a covered component.
### Fields
- `field_name`
- `state_class`
- `php_type`
- `source_of_truth`
- `used_for_protected_action`: boolean
- `revalidation_required`: boolean
- `notes`
### Validation rules
- If `state_class = presentation`, then `used_for_protected_action` must be false.
- If `used_for_protected_action = true`, then `state_class` must be `locked_identity` or `server_derived_authority`.
- If `php_type` is an Eloquent model and the field is ownership-relevant, it is a migration target and should be phased toward locked scalar or server-derived access.
## 4. Authority Source
### Purpose
Represents the canonical seam used to re-derive truth on the server.
### First-slice source types
- `route_binding`
- `workspace_context`
- `tenant_panel_context`
- `persisted_onboarding_draft`
- `allowed_tenant_universe`
- `explicit_scoped_query`
### Example mappings
- Onboarding draft identity: route binding + persisted onboarding draft
- Current workspace: `WorkspaceContext`
- Tenant-context page scope: route binding or `ResolvesPanelTenantContext`
- System runbook tenant selector: `AllowedTenantUniverse`
## 5. Selector Proposal
### Purpose
Represents mutable client-submitted selection state that is allowed to change but must be validated before use.
### Fields
- `selector_name`
- `target_model`
- `scope_rules`
- `null_allowed`: boolean
- `validation_outcome`
### Validation outcomes
- `accepted`
- `rejected_not_found`
- `rejected_forbidden`
- `reset_required`
### Rules
- A selector proposal never grants authority by itself.
- A selector proposal must be re-scoped to the current workspace, tenant, or allowed-universe before action execution.
## 6. Forged-State Regression Case
### Purpose
Represents a reproducible test scenario where client state is mutated to challenge the trust boundary.
### Fields
- `component_name`
- `mutation_type`: `foreign_id`, `stale_id`, `null_forced`, `cross_workspace`, `cross_plane`
- `entry_point`: page load, action call, modal submit, or rerun path
- `expected_outcome`: `404`, `403`, or no-op fail-closed
- `must_preserve_data_integrity`: boolean
### State transitions
- `pending_design``covered_by_test`
- `covered_by_test``guarded_in_ci`
## 7. First-Slice Surface Inventory
### Managed Tenant Onboarding Wizard
- `route_anchor`: onboarding draft route parameter
- Locked identities:
- `managedTenantId` (`?int`) backed by persisted onboarding draft identity and re-resolved through `currentManagedTenantRecord()`
- `onboardingSessionId` (`?int`) backed by persisted onboarding draft identity and re-resolved through `currentOnboardingSessionRecord()`
- Mutable selector proposals:
- `selectedProviderConnectionId` (`?int`) revalidated against canonical draft and scoped provider queries before verify/bootstrap paths
- `selectedBootstrapOperationTypes` (`array<int, string>`) remains presentation-only wizard state
- Server-derived authority fields:
- public `Workspace $workspace` refreshed from `WorkspaceContext`
- public `?Tenant $managedTenant` treated as display convenience only; canonical tenant truth comes from draft/scoped query
- public `?TenantOnboardingSession $onboardingSession` treated as display convenience only; canonical draft truth comes from resolver-backed persisted session lookup
- Canonical authority sources:
- `WorkspaceContext`
- onboarding draft resolver
- persisted draft state
- scoped provider-connection query
### Tenant Required Permissions Page
- `route_anchor`: tenant route parameter
- Locked identities:
- `scopedTenantId` (`?int`) derived from the route tenant and revalidated through `trustedScopedTenant()`
- Mutable selector proposals:
- `status`, `type`, `features`, and `search` remain presentation-only filters
- Server-derived authority fields:
- canonical tenant scope is derived through `resolveScopedTenant()`, `WorkspaceContext`, and `trustedScopedTenant()`
- Canonical authority sources:
- route-bound tenant resolution
- workspace context
### System Runbooks Page
- `route_anchor`: none
- Locked identities:
- none in the first slice; platform selector state remains proposal-only
- Public selector proposals:
- `findingsTenantId` (`?int`) is revalidated through `AllowedTenantUniverse::resolveAllowedOrFail()`
- `tenantId` (`?int`) is mirrored display state for the last trusted preflight result
- `findingsScopeMode` and `scopeMode` remain mutable UI state that must normalize into a trusted scope DTO before action execution
- Server-derived authority fields:
- trusted scope derives from `trustedFindingsScopeFromFormData()` / `trustedFindingsScopeFromState()` and the allowed tenant universe
- Canonical authority sources:
- authenticated platform user
- allowed tenant universe
- runbook scope DTO
## 8. Out-of-Scope but Related Fields
- Public widget record models such as tenant widgets storing `public ?Tenant $record`
- Resource page state handled primarily through Filament route model binding
- Non-stateful controller endpoints and queued job payloads
These remain rollout inventory candidates after the first slice proves the trusted-state pattern.

View File

@ -0,0 +1,158 @@
# Implementation Plan: Livewire Context Locking and Trusted-State Reduction
**Branch**: `152-livewire-context-locking` | **Date**: 2026-03-18 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/152-livewire-context-locking/spec.md`
**Input**: Feature specification from `/specs/152-livewire-context-locking/spec.md`
## Summary
Harden the repo's most stateful Livewire and Filament surfaces so public component state remains useful for continuity and UX, but never becomes authority for protected actions. The implementation will establish a three-lane trusted-state model, replace or constrain ownership-relevant public state on the first-slice surfaces, reuse existing canonical resolver seams for workspace, tenant, draft, and selector validation, and back the rollout with forged-state regression tests plus a lightweight guard extension.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
**Storage**: PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice
**Testing**: Pest feature tests, Livewire component tests, existing architectural guard tests, optional browser coverage where onboarding refresh semantics are already exercised
**Target Platform**: Laravel web application running locally via Sail and deployed via Dokploy
**Project Type**: Web application
**Performance Goals**: No material regression to first-slice page render paths; protected actions remain DB-only until they intentionally reuse existing operation starts; no extra broad resolver queries per request beyond canonical scope re-resolution
**Constraints**: Preserve 404 vs 403 semantics, keep `/admin` and `/system` plane separation intact, do not add new panels/routes/assets, avoid noisy repo-wide refactors in the first slice, and keep onboarding resume UX stable
**Scale/Scope**: First slice limited to three representative stateful families: onboarding wizard, one tenant-context page, and one system page, plus shared guard seams for future expansion
### Filament v5 Implementation Notes
- **Livewire v4.0+ compliance**: Maintained. This feature hardens Livewire public state and action resolution patterns within the existing Filament v5 + Livewire v4 stack.
- **Provider registration location**: No new panel is introduced. Existing panel providers remain registered in `bootstrap/providers.php`.
- **Global search rule**: No new globally searchable resource is added or modified. Existing global search rules remain unchanged.
- **Destructive actions**: Existing destructive actions in covered surfaces, such as onboarding draft cancellation and runbook execution confirmation, remain explicit and confirmed; this plan changes only how targets are resolved before execution.
- **Asset strategy**: No new shared or on-demand assets are planned. Deployment remains unchanged, including `php artisan filament:assets` where already part of the deploy flow.
- **Testing plan**: Add focused Pest feature and Livewire coverage for forged-state attempts, stale identifier paths, selector revalidation, and guard regressions on the first-slice surfaces.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. This feature does not alter inventory or snapshot ownership; it hardens UI state trust boundaries.
- Read/write separation: PASS. Existing writes remain explicit, confirmation-gated where required, auditable, and test-covered.
- Graph contract path: PASS. No new Graph calls or contract-registry changes are introduced.
- Deterministic capabilities: PASS. Existing capability registries and authorization helpers remain canonical.
- RBAC-UX plane separation: PASS. The first slice explicitly spans `/admin` and `/system` while preserving deny-as-not-found between planes.
- Workspace isolation: PASS. Workspace context remains the canonical source for admin-plane scope and must outrank client-submitted state.
- Tenant isolation: PASS. Tenant resolution remains route- or resolver-backed and is rechecked before protected actions.
- Destructive confirmation standard: PASS. Existing destructive-like actions keep `->requiresConfirmation()` and server-side authorization.
- Global search tenant safety: PASS. No global-search behavior changes.
- Run observability: PASS. The feature does not introduce new `OperationRun` types; existing operation-backed actions continue to use their established flow after trusted target re-resolution.
- Data minimization: PASS WITH WORK. Moving away from public Eloquent model state reduces exposed class and relationship metadata on covered surfaces.
- BADGE-001: PASS. No badge taxonomy changes.
- UI-NAMING-001: PASS. No new operator-facing vocabulary beyond existing domain labels.
- Filament Action Surface Contract: PASS. Existing action surfaces remain intact; only target-resolution internals change.
- Filament UX-001: PASS. No new screens or information architecture changes.
**Phase 0 Gate Result**: PASS
- The feature is bounded to state trust and authorization-safe target resolution.
- No new route, panel, Graph, or asset path is introduced.
- Existing authorization and audit rules remain in force and are reinforced by the trust-boundary model.
## Project Structure
### Documentation (this feature)
```text
specs/152-livewire-context-locking/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── trusted-state-logical.openapi.yaml
│ └── trusted-state-guard.schema.json
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Concerns/
│ ├── Pages/
│ │ ├── Workspaces/
│ │ └── Monitoring/
│ ├── Resources/
│ └── System/
│ └── Pages/
├── Services/
│ ├── Onboarding/
│ └── Auth/
├── Support/
│ ├── Workspaces/
│ ├── OperateHub/
│ └── Rbac/
└── Models/
tests/
├── Feature/
│ ├── Onboarding/
│ ├── Guards/
│ ├── Rbac/
│ └── System/
└── Unit/
```
**Structure Decision**: Use the existing Laravel web application structure. The first slice is centered in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `app/Filament/Pages/TenantRequiredPermissions.php`, `app/Filament/System/Pages/Ops/Runbooks.php`, shared resolver seams in `app/Filament/Concerns`, `app/Support/Workspaces`, and focused Pest coverage under `tests/Feature`.
## Phase 0 — Research (complete)
- Output: [specs/152-livewire-context-locking/research.md](research.md)
- Resolved key decisions:
- Use a three-lane trusted-state model: presentation, locked identity, server-derived authority.
- Prefer locked scalar IDs and resolver-backed model access over public Eloquent models for ownership-relevant continuity.
- Reuse existing canonical seams such as `WorkspaceContext`, `ResolvesPanelTenantContext`, onboarding draft resolvers, and `AllowedTenantUniverse`.
- First slice covers onboarding wizard, one tenant-context page, and one system page.
- Extend existing forged-state and architectural guard tests instead of inventing a parallel guard system.
## Phase 1 — Design & Contracts (complete)
- Output: [data-model.md](./data-model.md) defines trusted-state classes, component policy records, selector proposals, authority sources, and forged-state regression cases.
- Output: [contracts/trusted-state-logical.openapi.yaml](./contracts/trusted-state-logical.openapi.yaml) captures the semantic request/response contract for trusted action handling on covered surfaces.
- Output: [contracts/trusted-state-guard.schema.json](./contracts/trusted-state-guard.schema.json) defines the policy shape used by guard-oriented validation or documentation.
- Output: [quickstart.md](./quickstart.md) captures implementation sequence and focused validation commands.
### Post-design Constitution Re-check
- PASS: The design keeps Filament v5 + Livewire v4 and introduces no new panel or route.
- PASS: No Graph, queue, or storage expansion is introduced.
- PASS: Existing RBAC-UX semantics are preserved and strengthened through per-action re-resolution.
- PASS WITH WORK: Some covered components currently store public model objects. The implementation must migrate or neutralize those fields in the first slice without breaking legitimate resume or filter UX.
- PASS WITH WORK: Guard coverage must stay high-signal and scoped to the documented first-slice component families.
## Phase 2 — Implementation Planning
`tasks.md` should cover:
- Classifying first-slice public properties by trusted-state lane.
- Replacing or constraining public ownership-relevant model properties on the onboarding wizard.
- Normalizing onboarding protected actions so draft, tenant, workspace, and provider records are re-resolved on every action.
- Keeping `TenantRequiredPermissions` tenant authority route-derived while preserving mutable filters.
- Enforcing `AllowedTenantUniverse` validation for system runbook tenant selectors on every protected action path.
- Extending existing forged foreign-tenant and resolver guard tests for onboarding, tenant-context, and system surfaces.
- Adding one lightweight guard for forbidden ownership-relevant public authority fields on covered surfaces.
### Contract Implementation Note
- The OpenAPI contract is logical rather than transport-prescriptive. It documents the expected server behavior for existing Filament and Livewire handlers.
- The guard schema is documentation-first and may be enforced through Pest guards, curated config arrays, or test fixtures rather than a production runtime parser in the first slice.
- The preferred implementation approach is to push model access behind resolver-backed methods or computed accessors instead of public model state.
### Deployment Sequencing Note
- No migration is expected in the first slice.
- No asset publish change is expected.
- The rollout should start with onboarding, then tenant-context, then system-page hardening, because the onboarding wizard has the densest authority state and the clearest existing regression tests.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | Not applicable | Not applicable |

View File

@ -0,0 +1,68 @@
# Quickstart: Livewire Context Locking and Trusted-State Reduction
## Goal
Harden tier-1 Livewire and Filament surfaces so that public component state supports continuity and UX, but never becomes authority for protected actions.
## Implementation Order
1. Inventory the first-slice component fields and classify them as presentation, locked identity, or server-derived authority.
2. Replace ownership-relevant public model objects on the onboarding wizard with locked scalar IDs or resolver-backed access.
3. Normalize onboarding action methods so each protected action re-resolves draft, tenant, workspace, and selected provider connection before use.
4. Tighten the tenant required permissions page so route-derived tenant scope remains authoritative and filter state remains presentation-only.
5. Tighten the system runbooks page so selected tenant IDs remain validated proposals and cannot bypass `AllowedTenantUniverse`.
6. Extend existing forged-state and resolver guard tests instead of introducing a parallel guard suite.
7. Add or update one lightweight architectural guard for covered public authority fields, implementation markers, and first-slice action-surface status.
8. Add automated non-regression assertions for onboarding continuity and runbook selector query boundaries.
9. Run focused Pest coverage and format changed files with Pint.
## Suggested Code Touches
```text
app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
app/Filament/Pages/TenantRequiredPermissions.php
app/Filament/System/Pages/Ops/Runbooks.php
app/Filament/Concerns/ResolvesPanelTenantContext.php
app/Support/Workspaces/WorkspaceContext.php
app/Services/Onboarding/*
tests/Feature/Onboarding/*
tests/Feature/Guards/*
tests/Feature/Rbac/*
```
## Validation Flow
Run the minimum focused suites first:
```bash
vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php
vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/LivewireTrustedStateGuardTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php
vendor/bin/sail artisan test --compact tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php
vendor/bin/sail bin pint --dirty --format agent
```
The focused onboarding and runbook suites should include automated assertions that trusted-state hardening does not add broad resolver-query fan-out or break legitimate render and continuity paths.
If the first slice touches additional guard files, run those focused tests before expanding coverage.
## Manual Smoke Checklist
1. Open `/admin/onboarding/{onboardingDraft}` for a valid draft and verify normal resume behavior still works.
2. Change provider connection through the intended UI and confirm verification still uses the selected in-scope connection.
3. Attempt a forged or stale target in a Livewire test or browser devtools scenario and confirm the request fails closed.
4. Open `/admin/tenants/{tenant}/required-permissions` and confirm filters remain usable while tenant scope stays fixed.
5. Open `/system/ops/runbooks`, switch between all-tenant and single-tenant scope, and confirm unauthorized tenant selections are rejected.
6. Re-run the trusted-state and guard suites after any future component adopts this pattern, and update the first-slice policy inventory before expanding exemptions.
## Exit Criteria
1. Tier-1 components no longer depend on mutable public authority state for protected actions.
2. Forged-state regression coverage exists for onboarding, tenant-context, and system-page slices.
3. Existing operator UX and legitimate refresh or resume behavior remain intact.
4. Automated non-regression assertions cover onboarding continuity and runbook selector query boundaries.
5. No new panel, route, asset, or Graph contract change was introduced.

View File

@ -0,0 +1,82 @@
# Research: Livewire Context Locking and Trusted-State Reduction
## Decision 1: Use a three-lane trusted-state model for stateful Livewire surfaces
- Decision: Classify component state into three explicit lanes: presentation state, locked identity state, and server-derived authority state.
- Rationale: The current repo already mixes harmless UI state with ownership-sensitive context. The problem is not that all public state is wrong; it is that authority-sensitive state is not separated from convenience state. A three-lane model keeps UX flexible while making trust boundaries explicit.
- Alternatives considered:
- Lock every public property: rejected because filters, search terms, and step-local form state must stay mutable for normal UX.
- Derive everything on every render with no persisted public identity: rejected because multi-step flows such as onboarding still need continuity across requests.
- Keep the current model-object-heavy pattern and rely on authorization only: rejected because Livewire public properties are still untrusted input and expose model metadata to the browser.
## Decision 2: Prefer locked scalar IDs over public Eloquent models for ownership-relevant continuity
- Decision: Route-bound or continuity-relevant identities may stay public only as locked scalar IDs. Public Eloquent models should be replaced or treated as temporary compatibility exceptions in covered surfaces.
- Rationale: Livewire v4 supports `#[Locked]` for model IDs and documents that public properties are client-mutable by default. Public Eloquent models keep IDs safer than raw mutable integers, but they still expose class and relationship metadata to the browser and can preserve stale assumptions. Locked scalar IDs plus server-side re-resolution are the clearer long-term pattern for trust-sensitive flows.
- Alternatives considered:
- Rely on public Eloquent model auto-locking everywhere: rejected because it still exposes model class names and can encourage stale-query trust.
- Use protected properties for sensitive state: rejected because Livewire does not persist protected properties across requests.
- Store foreign IDs as normal public integers and only authorize later: rejected because this recreates the exact forged-state class of bug the spec is trying to prevent.
## Decision 3: Server-derived authority must outrank component state on every protected action
- Decision: Protected actions must re-resolve tenant, workspace, onboarding draft, and selected target records from canonical server seams before reading or mutating data.
- Rationale: Livewire documents that public properties should be treated like request input. The repo already has good canonical seams: `WorkspaceContext`, `OperateHubShell`, `ResolvesPanelTenantContext`, `TenantRequiredPermissions::resolveScopedTenant()`, onboarding draft resolvers, and `AllowedTenantUniverse` for platform selectors. Reusing these seams is safer than inventing a parallel trust layer.
- Alternatives considered:
- Validate only in `mount()`: rejected because stale tabs and forged payloads happen after mount.
- Add a broad Livewire middleware abstraction first: rejected for the first slice because the repo already has concrete per-surface resolvers to reuse.
- Route all state through hidden inputs or Alpine-only state: rejected because it hides the problem rather than solving the authority boundary.
## Decision 4: First slice covers onboarding wizard, one tenant-context page, and one system page
- Decision: The first slice should harden three representative families: `ManagedTenantOnboardingWizard`, `TenantRequiredPermissions`, and `System\Pages\Ops\Runbooks`.
- Rationale: This gives one high-risk multi-step workflow, one tenant-context admin page, and one platform/system selector page. That is enough surface diversity to establish a reusable standard without turning the first implementation into an unbounded repo-wide cleanup.
- Alternatives considered:
- Onboarding wizard only: rejected because that would not establish the cross-plane pattern the spec promises.
- All Livewire and Filament pages at once: rejected because it would create high churn and low signal in the first slice.
- Only system pages: rejected because the trust boundary was first identified in admin onboarding and tenant-context flows.
## Decision 5: Legitimate selector state may remain mutable if the server treats it as a proposal
- Decision: Values such as selected provider connection IDs, selected tenant IDs in system runbooks, and filter/search state may remain public and mutable if every protected action re-validates them inside the current scope before use.
- Rationale: Not every mutable value is authority. Some values are legitimate user choices. The correct boundary is whether the server treats the value as final truth or as input to validate.
- Alternatives considered:
- Lock selected provider connection IDs after mount: rejected because onboarding legitimately changes provider selection during the flow.
- Make all system-page selectors read-only after first load: rejected because that would break the intended operator workflow.
- Store selectors only in the query string: rejected because query strings are just as untrusted unless revalidated and would not simplify the trust problem.
## Decision 6: Extend existing forged-state and guard patterns instead of creating a parallel guard system
- Decision: Reuse and extend the current guard test family for admin tenant resolver safety and the existing forged foreign-tenant action tests already used in backup schedules, findings, and other RBAC slices.
- Rationale: The repo already enforces resolver patterns and deny semantics through targeted Pest guards. Extending those tests keeps the hardening style consistent and avoids a second, overlapping architectural-guard system.
- Alternatives considered:
- Introduce a new generic Livewire state linter: rejected for the first slice because it would likely be noisy and less grounded in real trust failures.
- Rely only on feature tests with no guards: rejected because guard tests are the cheapest way to stop recurrence.
- Add browser-only forged-state coverage: rejected because Livewire component tests and guard tests are a faster and more deterministic first layer.
## Decision 7: Prefer computed or resolver-backed model access over persisted public model objects
- Decision: Covered components should expose model-backed data to views through computed accessors, local method resolution, or server-backed helper methods instead of storing those models as authoritative public state.
- Rationale: Livewire warns that public models are dehydrated to JSON, expose system information, and lose query constraints. Computed accessors or resolver-backed methods reduce stale-model assumptions while keeping the UI expressive.
- Alternatives considered:
- Keep model objects public and rely on Livewire auto-locking only: rejected because auto-locking protects ID tampering but not stale assumptions or metadata exposure.
- Re-query models ad hoc in Blade views: rejected because it hides data access in templates and creates consistency problems.
- Serialize custom DTOs for every surface immediately: rejected as overdesign for the first slice.
## Decision 8: Fail-closed semantics remain route- and plane-aware
- Decision: Wrong-scope or non-member forged-state paths resolve as `404`, while in-scope actors missing capability resolve as `403`. Platform-plane selectors must apply the same principle through allowed-universe validation.
- Rationale: This matches the constitution and existing RBAC-UX behavior. Trusted-state hardening should reinforce, not reinterpret, deny semantics.
- Alternatives considered:
- Return validation errors for forged target IDs: rejected because that leaks too much about scope boundaries.
- Always throw a generic locked-property exception to the user: rejected because not all state should be locked, and server-derived failures still need policy-consistent deny semantics.
- Normalize all failures to `403`: rejected because it breaks deny-as-not-found isolation semantics.
## Decision 9: Reusable helper semantics should stay executable, not prose-only
- Decision: The first-slice contract should expose reusable helper semantics through executable APIs on `TrustedStateClass` and `TrustedStateResolver`, with guard tests asserting those semantics.
- Rationale: The pattern needs to be teachable to future component work without relying on a human re-reading this research note. Enum helpers such as `allowsClientMutation()` / `requiresServerRevalidation()` and resolver accessors for required authority sources make the trust model mechanically checkable in CI.
- Alternatives considered:
- Keep helper guidance only in spec prose: rejected because architectural drift would be detected too late.
- Add a large generic Livewire analyzer: rejected because the first slice only needs explicit semantics for the approved pattern.
- Encode helper semantics solely in component-local methods: rejected because that does not create a reusable repository standard.

View File

@ -0,0 +1,204 @@
# Feature Specification: Livewire Context Locking and Trusted-State Reduction
**Feature Branch**: `152-livewire-context-locking`
**Created**: 2026-03-18
**Status**: Draft
**Input**: User description: "Livewire Context Locking and Trusted-State Reduction"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace + tenant + canonical-view
- **Primary Routes**:
- `/admin/onboarding`
- `/admin/onboarding/{onboardingDraft}`
- `/admin/tenants/{tenant}/required-permissions`
- System runbook surfaces under `/system/ops/runbooks`
- Related multi-step or stateful Filament and Livewire surfaces that keep ownership-relevant context in public component state
- **Data Ownership**:
- Workspace-scoped onboarding drafts remain workspace-owned workflow records that may reference a tenant and provider connection
- Tenant-bound pages and widgets remain tenant-scoped views whose entitlement must continue to come from canonical tenant resolution rather than mutable Livewire state
- System-panel runbook pages remain platform-scoped and may reference a tenant as a scoped target, but the tenant reference remains selector state rather than permission truth
- This feature introduces no new domain records; it hardens how existing records are exposed to Livewire state and re-resolved during actions
- **RBAC**:
- Admin `/admin` workspace and tenant-context surfaces remain governed by workspace membership, tenant entitlement, and canonical capability checks
- Platform `/system` surfaces remain governed by platform capabilities and permitted tenant-universe rules
- Non-members, wrong-workspace actors, wrong-tenant actors, and cross-plane access attempts remain deny-as-not-found
- In-scope actors missing the required capability remain forbidden
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical viewers may prefill filters from the current tenant context only as a convenience. Filter state must never become the authority for which workspace, tenant, or record the action is allowed to affect.
- **Explicit entitlement checks preventing cross-tenant leakage**: Every action that uses a tenant, workspace, onboarding draft, provider connection, or selected target from component state must re-resolve that record from canonical server context before reading or mutating anything. Foreign or stale identifiers must fail closed without leaking whether the target exists.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Trust ownership-sensitive wizard actions (Priority: P1)
As an onboarding operator, I want ownership-sensitive onboarding actions to derive truth from locked or server-resolved context instead of mutable public Livewire state, so that refreshes, forged client payloads, or stale tabs cannot steer the wizard toward the wrong tenant, workspace, or provider record.
**Why this priority**: The onboarding wizard is a tier-1 trust surface with the highest concentration of workspace, tenant, draft, and provider context in one Livewire component.
**Independent Test**: Can be fully tested by opening the onboarding wizard, mutating ownership-relevant public state in Livewire tests or browser-forged payloads, and confirming that view, verify, bootstrap, cancel, and activation paths still resolve against canonical server truth.
**Acceptance Scenarios**:
1. **Given** an operator is on `/admin/onboarding/{onboardingDraft}`, **When** a forged payload changes the draft, tenant, or workspace identity in public component state, **Then** the next action resolves against the canonical route-bound and workspace-bound draft instead of the forged value.
2. **Given** a mutable selection such as provider connection changes legitimately through the UI, **When** the operator executes verification or bootstrap, **Then** the action re-resolves the selected record within the current draft and current authority boundary before side effects begin.
3. **Given** a stale or foreign identifier is submitted from the client, **When** the component evaluates the action, **Then** the request fails closed with deny-as-not-found for wrong-scope targets or forbidden for in-scope users missing the required capability.
---
### User Story 2 - Keep non-wizard stateful pages safe under forged state (Priority: P1)
As an operator using tenant-context or system pages with stateful selectors and filters, I want those pages to treat public Livewire state as untrusted input, so that query selectors, filters, and target IDs cannot silently become permission truth.
**Why this priority**: The hardening must be repo-wide enough to prevent the wizard from becoming a special case. At least one tenant/admin page and one platform/system page need the same standard.
**Independent Test**: Can be fully tested by mutating public selector properties on a tenant-context page and a system runbook page, then verifying that refresh, preflight, and action paths still use canonical route, session, membership, and allowed-universe resolution.
**Acceptance Scenarios**:
1. **Given** a tenant-context page has a route-bound tenant and public filter state, **When** the client submits a forged tenant-like value, **Then** the page continues to use the route or canonical resolver as the ownership source of truth.
2. **Given** a system runbook page exposes a tenant selector as public state, **When** the client submits a tenant outside the allowed platform operator universe, **Then** the request fails closed and no runbook action executes for that tenant.
3. **Given** a page exposes only search, sort, tab, or filter state, **When** those values change, **Then** the page remains fully usable because non-ownership state may stay mutable.
---
### User Story 3 - Apply one reusable trusted-state standard to future components (Priority: P2)
As a maintainer, I want one explicit decision model for which Livewire state may remain public, which state must be locked, and which state must be derived on the server, so that new components do not reintroduce trust-boundary drift by convention.
**Why this priority**: The long-term value of this feature is the reusable pattern and regression matrix, not only the first round of fixes.
**Independent Test**: Can be fully tested by applying the standard to multiple representative component families and by guard tests that fail when ownership-relevant model objects or mutable foreign identifiers are introduced without the approved pattern.
**Acceptance Scenarios**:
1. **Given** a new Livewire or Filament component introduces ownership-relevant context, **When** it follows the standard, **Then** the component either stores only locked scalar identity or derives the identity server-side on every action.
2. **Given** a component only needs presentation or filter state, **When** it follows the standard, **Then** that state may remain public and mutable because it does not determine authorization-sensitive truth.
3. **Given** a future contributor reintroduces public ownership-relevant model objects or mutable foreign IDs on a covered surface, **When** guard tests run, **Then** CI fails with a clear reason.
### Edge Cases
- A route-bound draft is valid, but public component state carries a different draft or tenant from a stale browser tab.
- A provider connection is legitimately changed in the UI after the component first mounted; the new value is in-scope and should be allowed only after server re-resolution.
- A tenant-context page has mutable search and filter state that should stay interactive even though the underlying tenant must remain server-derived.
- A platform operator submits a tenant ID on a system page that is syntactically valid but outside the operator's allowed tenant universe.
- A component carries a public Eloquent model for convenience and the serialized model becomes stale or cross-scope after the backing record changes.
- A forged payload removes or nulls a locked identifier and tries to force the component onto a fallback context.
- A browser refresh restores a legitimate component with locked identity while leaving non-secret filter and form state functional.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls and no new long-running work. It is a trust-boundary hardening feature for stateful Livewire and Filament components. It defines which ownership-relevant values may remain public but locked, which must disappear from public state entirely, and which must be re-derived on the server for every action. Security-relevant failures remain deny-first and auditable through existing action or operation audit coverage where a mutation is attempted.
**Constitution alignment (OPS-UX):** This feature does not create a new `OperationRun` type. Existing operation-backed onboarding and system runbook flows continue to use their current Ops-UX contract. The hardening here is that start, refresh, rerun, preflight, and mutation actions must resolve target truth from canonical context before they reuse those existing run surfaces.
**Constitution alignment (RBAC-UX):** This feature applies to both tenant/admin `/admin` surfaces and platform `/system` surfaces. Cross-plane access remains deny-as-not-found. Non-members or actors outside workspace or tenant entitlement remain `404`. In-scope actors lacking the required capability remain `403`. Authorization must continue to be enforced server-side through existing policies, capability resolvers, tenant-universe rules, and page-specific guards. Global search is not expanded by this feature. Existing destructive actions such as onboarding draft cancellation remain explicit, confirmation-gated, and capability-checked.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. This feature does not change `/auth/*` behavior.
**Constitution alignment (BADGE-001):** Existing status and lifecycle badges remain centralized. This feature must not introduce local badge semantics for trust or lock state.
**Constitution alignment (UI-NAMING-001):** Operator-facing wording remains domain-first. Use terms such as `Onboarding draft`, `Verify access`, `Bootstrap`, `Required permissions`, `Preflight`, and `Run`. Implementation-first terms such as `locked property`, `hydration`, or `forged payload` must not become primary operator-facing copy; they belong only in tests and maintainers' guidance.
**Constitution alignment (Filament Action Surfaces):** The feature modifies existing Filament and Livewire pages rather than adding new resources. The Action Surface Contract remains satisfied because action labels and availability remain mostly unchanged; the hardening is in how target truth is resolved before those actions execute.
**Constitution alignment (UX-001 — Layout & Information Architecture):** No new Filament screen is added. Existing onboarding, tenant page, and system page layouts remain intact. This feature may add or refine hidden trust-handling behavior and validation feedback but must not redesign the information architecture.
**Constitution alignment (Filament v5 / Livewire v4):** This feature remains compliant with Filament v5 and Livewire v4.0+. No new panel is introduced, so provider registration remains unchanged in `bootstrap/providers.php`. No globally searchable resource is added or modified. Existing destructive actions in covered surfaces remain `->requiresConfirmation()` and server-authorized.
**Constitution alignment (assets and deploy):** No new global or on-demand assets are required. Existing deployment behavior for `php artisan filament:assets` remains unchanged.
### Functional Requirements
- **FR-152-001**: The system MUST define one repo-wide trusted-state decision model for stateful Livewire and Filament components that distinguishes between non-sensitive presentation state, locked identity state, and server-derived authority state.
- **FR-152-002**: Ownership-relevant context MUST NOT rely on mutable public Livewire state for authorization-sensitive decisions.
- **FR-152-003**: Ownership-relevant context includes at least workspace identity, tenant identity, onboarding draft identity, provider connection identity when used to start protected actions, and system-runbook target tenant identity.
- **FR-152-004**: Tier-1 first-slice component families MUST include the managed tenant onboarding wizard, at least one tenant-context stateful page, and at least one platform/system stateful page.
- **FR-152-005**: In tier-1 components, route-bound record identity may remain public only when it is locked and the action path still re-resolves the record from canonical server scope before use.
- **FR-152-006**: In tier-1 components, public Eloquent model objects that represent workspace, tenant, onboarding draft, or provider context MUST NOT be trusted as the final source of truth for protected actions.
- **FR-152-007**: When a value is needed only for presentation, search, sort, tab selection, or other non-ownership behavior, the value MAY remain public and mutable.
- **FR-152-008**: When a value determines which tenant, workspace, draft, provider connection, or foreign record is acted upon, the component MUST either store it as locked scalar identity or derive it from route, session, resolver, or persisted draft state on every action.
- **FR-152-009**: Mutable foreign identifiers submitted from the client MUST be treated as proposals to validate, not as trusted truth.
- **FR-152-010**: Covered actions MUST re-resolve the proposed target record within the current actor's canonical scope before reading or mutating anything.
- **FR-152-011**: A wrong-workspace, wrong-tenant, stale, or foreign identifier in a covered action MUST fail closed without leaking whether the foreign target exists.
- **FR-152-012**: Deny semantics MUST remain consistent: wrong-scope or non-member requests resolve as `404`, while in-scope actors missing the required capability resolve as `403`.
- **FR-152-013**: The managed tenant onboarding wizard MUST no longer depend on mutable public tenant, workspace, or onboarding-draft state for verification, bootstrap, cancellation, deletion, or activation decisions.
- **FR-152-014**: Legitimate mutable onboarding selections such as a chosen provider connection or selected bootstrap options MAY remain interactive, but every protected action using those selections MUST re-validate them within the current draft and current authority scope.
- **FR-152-015**: The tenant required permissions page MUST keep the scoped tenant derived from canonical route or server context rather than allowing filter state or stale public tenant data to redefine the scope.
- **FR-152-016**: The system runbooks page MUST treat selected tenant IDs as untrusted selector input and MUST validate them against the platform operator's allowed tenant universe before preflight or run execution.
- **FR-152-017**: The feature MUST define explicit guidance for when `#[Locked]` is the correct pattern, when server derivation is required instead, and when a value should not be public state at all.
- **FR-152-018**: The feature MUST define that route identity, persisted workflow identity, session-selected workspace, and canonical tenant resolver outputs outrank client-submitted component state.
- **FR-152-019**: The feature MUST NOT weaken existing resume, refresh, or wizard continuity behavior that depends on legitimate public presentation state.
- **FR-152-020**: The feature MUST add forged-state and mutated-identifier regression coverage for each tier-1 component family in scope.
- **FR-152-021**: The feature MUST add at least one positive-path and one negative forged-state authorization test for each first-slice family.
- **FR-152-022**: The feature MUST add a lightweight guard strategy that fails when covered surfaces introduce ownership-relevant public model objects or mutable foreign identifiers without the approved pattern.
- **FR-152-023**: The feature MUST document the first-slice exceptions, if any, where a value stays public for usability but is still safe because the server never treats it as final authority.
- **FR-152-024**: This feature MUST NOT introduce new routes, new capabilities, a new panel, or new global search behavior.
- **FR-152-025**: Existing audit and operation history for covered actions MUST remain intact; trust-boundary hardening must not bypass or suppress the audit trail already required by those actions.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Managed tenant onboarding wizard | `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}` | Existing `Back to workspace`, landing, tenant, and lifecycle actions | Wizard stepper and status callouts | None | None | Existing onboarding start state | Existing lifecycle actions | Existing wizard step navigation and action buttons | Yes | No new operator actions. Hardening applies to route-bound draft, linked tenant, and provider selection handling. Existing destructive actions remain confirmed. |
| Required permissions page | `/admin/tenants/{tenant}/required-permissions` | Existing tenant and provider-navigation actions only | Page-level filtered view | None | None | Existing empty state or reset-filters path | Not applicable | Filter controls only | No new audit event | Trust hardening is on scoped tenant derivation and filter safety, not on new actions. |
| System runbooks page | `/system/ops/runbooks` | Existing `Preflight`, `Run…` | Page-level summaries and last-run links | None | None | None | Not applicable | Modal submit / cancel in existing actions | Yes | `Run…` remains destructive-like and confirmed. Tenant selector stays mutable UI state but must never bypass allowed-universe validation. |
### Key Entities *(include if feature involves data)*
- **Trusted State Class**: The classification of component state as presentation-only, locked identity, or server-derived authority.
- **Locked Identity**: A public scalar identifier that may remain on the component only to preserve continuity, while remaining immutable from the client and still subject to server re-resolution.
- **Server-Derived Authority Context**: The authoritative workspace, tenant, draft, or target identity derived from route binding, session, canonical resolvers, or persisted workflow state.
- **Forged-State Path**: A request path where the client submits altered public component state to try to read or mutate data outside the canonical authority scope.
- **Tier-1 Stateful Surface**: A Livewire or Filament component family chosen for first-slice hardening because it combines public state with ownership-sensitive actions or selectors.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-152-001**: In first-slice regression coverage, 100% of covered forged-state attempts on onboarding actions fail closed and do not execute against a foreign or stale target.
- **SC-152-002**: In first-slice regression coverage, 100% of covered tenant-context and system selector mutations re-resolve against canonical server scope before protected actions execute.
- **SC-152-003**: Covered tier-1 components retain their existing legitimate refresh and resume behavior while removing mutable public authority state from decision-making.
- **SC-152-004**: CI contains at least one guard that fails when a covered surface introduces ownership-relevant public model state or mutable foreign identifiers without the approved locking or derivation pattern.
- **SC-152-005**: The repo gains one documented trusted-state standard that maintainers can apply to future Livewire components without reopening the same trust-boundary design questions.
## Non-Goals
- Rewriting all Livewire components in one pass
- Eliminating all public state from all components
- Replacing existing route models, workspace selectors, or tenant context flows with a new routing model
- Introducing WebSocket, session-locking, or collaborative editing infrastructure
- Redesigning onboarding, required permissions, or system runbook UX beyond trust-boundary needs
- Hardening queued jobs; that remains covered by Spec 149
- Hardening tenant-owned query canon; that remains covered by Spec 150
## Assumptions
- Public component state in Livewire is untrusted by default, consistent with the audit constitution.
- The onboarding wizard remains the highest-risk first-slice surface because it combines route-bound workflow identity, linked tenant state, provider selection, and operation-backed actions.
- Not every public value is risky; filters, search terms, and other presentation state can remain mutable when they do not determine authority.
- Existing workspace and tenant resolver seams introduced by Specs 138, 140, 143, 149, and 150 are sufficient foundations for a trusted-state hardening pass.
- The first implementation slice should prefer bounded high-signal fixes plus regression guards over a sweeping repo-wide refactor.
## Dependencies
- Spec 138 - Managed Tenant Onboarding Draft Identity & Resume Semantics
- Spec 140 - Onboarding Lifecycle, Operation Checkpoints & Concurrency MVP
- Spec 143 - Tenant Lifecycle, Operability, and Context Semantics Foundation
- Spec 149 - Queued Execution Reauthorization and Scope Continuity
- Spec 150 - Tenant-Owned Query Canon and Wrong-Tenant Guards
- Existing Livewire and browser testing infrastructure for onboarding and stateful admin pages
## Risks
- Overusing locked state where server derivation would be cleaner could preserve too much accidental public authority shape.
- Overcorrecting by removing all mutable state could degrade legitimate UX for filters, search, and step continuity.
- Some existing components may rely on public model objects for convenience, making the first pass noisy unless tier-1 scope stays bounded.
- System-panel selectors may need careful differentiation between allowed UI state and forbidden authority state to avoid unnecessary operator friction.
## Final Direction
This feature establishes a simple rule: public Livewire state may support continuity and presentation, but it must not become authority. The first slice hardens the onboarding wizard plus representative tenant/admin and system surfaces, defines when to use locked scalar identity versus server derivation, and adds forged-state regression tests so future components cannot drift back toward trusting mutable client-visible context.

View File

@ -0,0 +1,211 @@
# Tasks: Livewire Context Locking and Trusted-State Reduction
**Input**: Design documents from `/specs/152-livewire-context-locking/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
**Tests**: Tests are REQUIRED for this feature because it changes runtime authorization, Livewire trusted-state handling, tenant and workspace isolation, and forged-state fail-closed behavior in a Laravel/Pest codebase.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Establish the shared trusted-state scaffolding and guard baseline used by all stories.
- [x] T001 Create the first-slice trusted-state file skeleton and initial policy inventory stubs in `app/Support/Livewire/TrustedState/TrustedStateClass.php`, `app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, and `app/Support/Livewire/TrustedState/TrustedStateResolver.php`
- [x] T002 [P] Create the Livewire trusted-state architectural guard test harness and first-slice fixture list in `tests/Feature/Guards/LivewireTrustedStateGuardTest.php`
- [x] T003 [P] Add shared Pest helpers `mutateTrustedStatePayload()` and `assertScopedSelectorRejected()` in `tests/Pest.php` for reuse by onboarding, tenant required permissions, and runbook selector suites
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Put the reusable trusted-state and resolver rules in place before component-specific work starts.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [x] T004 Implement the shared trusted-state lane and resolver contract in `app/Support/Livewire/TrustedState/TrustedStateClass.php`, `app/Support/Livewire/TrustedState/TrustedStatePolicy.php`, and `app/Support/Livewire/TrustedState/TrustedStateResolver.php`
- [x] T005 [P] Wire shared workspace, tenant, and onboarding authority re-resolution into `app/Support/Workspaces/WorkspaceContext.php`, `app/Filament/Concerns/ResolvesPanelTenantContext.php`, `app/Services/Onboarding/OnboardingDraftResolver.php`, and `app/Services/Onboarding/OnboardingDraftMutationService.php`
- [x] T006 [P] Wire shared platform selector validation into `app/Services/System/AllowedTenantUniverse.php` and `app/Filament/System/Pages/Ops/Runbooks.php`
- [x] T007 Update the architectural guard allowlists for the first-slice surfaces in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` and `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`
**Checkpoint**: The trusted-state contract, canonical resolver seams, and guard baseline exist, so user story work can proceed in parallel.
---
## Phase 3: User Story 1 - Trust ownership-sensitive wizard actions (Priority: P1) 🎯 MVP
**Goal**: Ensure onboarding wizard actions derive draft, workspace, tenant, and provider truth from locked or server-resolved state instead of mutable public Livewire authority.
**Independent Test**: A user can resume and operate a valid onboarding draft normally, while forged or stale draft, workspace, tenant, or provider values fail closed and execute no protected action.
### Tests for User Story 1
- [x] T008 [P] [US1] Extend forged-draft and stale-workspace coverage in `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`, `tests/Feature/Onboarding/OnboardingDraftAccessTest.php`, and `tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php`
- [x] T009 [P] [US1] Extend forged provider-selection and stale-target coverage in `tests/Feature/Onboarding/OnboardingProviderConnectionTest.php`, `tests/Feature/Onboarding/OnboardingActivationTest.php`, and `tests/Feature/Onboarding/OnboardingVerificationTest.php`
- [x] T010 [P] [US1] Extend onboarding 404 versus 403 parity coverage for trusted-state failures in `tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php` and `tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php`
- [x] T011 [P] [US1] Preserve onboarding audit-log and operation-history coverage during trusted-state hardening in `tests/Feature/Onboarding/OnboardingActivationTest.php`, `tests/Feature/Onboarding/OnboardingVerificationAssistTest.php`, and `tests/Feature/Onboarding/OnboardingVerificationTest.php`
### Implementation for User Story 1
- [x] T012 [US1] Replace ownership-relevant public model authority in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` with locked scalar identity or resolver-backed access while preserving wizard continuity
- [x] T013 [US1] Rework protected onboarding actions in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingDraftMutationService.php` to re-resolve draft, workspace, and tenant truth before verify, bootstrap, cancel, delete, and activate paths
- [x] T014 [US1] Re-scope mutable provider selection through canonical draft and tenant validation in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `app/Services/Onboarding/OnboardingDraftResolver.php`
- [x] T015 [US1] Preserve resume and display behavior through computed or resolver-backed model access in `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and `resources/views/filament/pages/workspaces/managed-tenant-onboarding-wizard.blade.php`
**Checkpoint**: The onboarding wizard is independently safe against forged state and remains fully usable as the MVP slice.
---
## Phase 4: User Story 2 - Keep non-wizard stateful pages safe under forged state (Priority: P1)
**Goal**: Ensure tenant-context and system pages treat public selectors and filters as untrusted input while keeping legitimate UX intact.
**Independent Test**: A route-bound tenant page and a system runbook page continue to work with normal filters and selectors, but forged tenant-like or runbook target state cannot redefine authority or execute against unauthorized targets.
### Tests for User Story 2
- [x] T016 [P] [US2] Add tenant-context trusted-state coverage in `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php`
- [x] T017 [P] [US2] Extend system runbook selector forged-state coverage in `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`, `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php`, and `tests/Feature/System/Spec113/AllowedTenantUniverseTest.php`
- [x] T018 [P] [US2] Add explicit positive-path continuity coverage for normal tenant filters and allowed runbook selections in `tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`
- [x] T019 [P] [US2] Extend cross-plane and capability-parity coverage for covered non-wizard surfaces in `tests/Feature/System/Spec113/AuthorizationSemanticsTest.php` and `tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php`
- [x] T020 [P] [US2] Preserve system runbook audit-log and operation-history coverage during trusted-state hardening in `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php` and `tests/Feature/System/OpsRunbooks/OpsUxStartSurfaceContractTest.php`
### Implementation for User Story 2
- [x] T021 [US2] Convert tenant authority on `app/Filament/Pages/TenantRequiredPermissions.php` to route- or resolver-derived scope while keeping `status`, `type`, `features`, and `search` presentation-only
- [x] T022 [US2] Re-validate runbook tenant selectors against the platform operator universe on every protected path in `app/Filament/System/Pages/Ops/Runbooks.php` and `app/Services/System/AllowedTenantUniverse.php`
- [x] T023 [US2] Normalize deny-as-not-found versus forbidden semantics for covered non-wizard stateful flows in `app/Filament/Pages/TenantRequiredPermissions.php`, `app/Filament/System/Pages/Ops/Runbooks.php`, and `app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php`
**Checkpoint**: Covered tenant-context and system pages are independently safe against forged selector state without relying on the onboarding wizard changes.
---
## Phase 5: User Story 3 - Apply one reusable trusted-state standard to future components (Priority: P2)
**Goal**: Make the trusted-state model reusable and enforceable so future Livewire components do not reintroduce mutable authority by convention.
**Independent Test**: The repo contains a reusable guard and first-slice field inventory that fail when ownership-relevant public model state or mutable foreign identifiers reappear on covered surfaces without the approved pattern.
### Tests for User Story 3
- [x] T024 [P] [US3] Implement the trusted-state architectural guard assertions in `tests/Feature/Guards/LivewireTrustedStateGuardTest.php`
- [x] T025 [P] [US3] Extend existing resolver and action-surface guard coverage for the first-slice surfaces in `tests/Feature/Guards/AdminTenantResolverGuardTest.php`, `tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php`
### Implementation for User Story 3
- [x] T026 [US3] Finalize the reusable first-slice field inventory and trusted-state policy map in `app/Support/Livewire/TrustedState/TrustedStatePolicy.php` and `specs/152-livewire-context-locking/data-model.md`
- [x] T027 [US3] Encode reusable locked-versus-derived helper usage in `app/Support/Livewire/TrustedState/TrustedStateResolver.php`, `app/Support/Livewire/TrustedState/TrustedStateClass.php`, and `specs/152-livewire-context-locking/research.md`
- [x] T028 [US3] Align the logical contract and rollout checklist with the implemented patterns in `specs/152-livewire-context-locking/contracts/trusted-state-logical.openapi.yaml`, `specs/152-livewire-context-locking/contracts/trusted-state-guard.schema.json`, and `specs/152-livewire-context-locking/quickstart.md`
**Checkpoint**: The trusted-state pattern is reusable, documented, and guarded in CI for future component work.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate the rollout, keep the branch releasable, and confirm the spec's manual verification paths.
- [x] T029 [P] Run the focused Pest validation suite from `specs/152-livewire-context-locking/quickstart.md`
- [x] T030 [P] Add automated non-regression assertions for first-slice render continuity and canonical resolver-query boundaries in `tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php` and `tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php`
- [x] T031 Run formatting with `vendor/bin/sail bin pint --dirty --format agent`
- [x] T032 [P] Validate the manual smoke checklist in `specs/152-livewire-context-locking/quickstart.md` against `/admin/onboarding/{onboardingDraft}`, `/admin/tenants/{tenant}/required-permissions`, and `/system/ops/runbooks`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies, can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
- **User Story 1 (Phase 3)**: Starts after Foundational completion.
- **User Story 2 (Phase 4)**: Starts after Foundational completion and can proceed in parallel with US1.
- **User Story 3 (Phase 5)**: Starts after Foundational completion and should land after at least one first-slice surface has adopted the pattern.
- **Polish (Phase 6)**: Runs after the desired user stories are complete.
### User Story Dependencies
- **US1**: No dependency on other stories. This is the recommended MVP slice.
- **US2**: Depends only on the foundational trusted-state and resolver layer, not on US1 completion.
- **US3**: Depends on the foundational layer and benefits from US1 and US2 landing first so the guard inventory reflects real adoption.
### Within Each User Story
- Tests MUST be written and fail before implementation.
- Shared resolver and trusted-state seams must exist before surface-specific rewrites begin.
- Protected action re-resolution must land before cleanup of public model authority is considered complete.
- Guard updates should happen after at least one representative implementation proves the pattern.
### Parallel Opportunities
- T002 and T003 can run in parallel.
- T005 and T006 can run in parallel.
- US1 test tasks T008, T009, T010, and T011 can run in parallel.
- US2 test tasks T016, T017, T018, T019, and T020 can run in parallel.
- US3 test tasks T024 and T025 can run in parallel.
- Polish tasks T029, T030, and T032 can run in parallel after implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Launch the onboarding forged-state regressions together:
Task: "Extend forged-draft and stale-workspace coverage in tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php, tests/Feature/Onboarding/OnboardingDraftAccessTest.php, and tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php"
Task: "Extend forged provider-selection and stale-target coverage in tests/Feature/Onboarding/OnboardingProviderConnectionTest.php, tests/Feature/Onboarding/OnboardingActivationTest.php, and tests/Feature/Onboarding/OnboardingVerificationTest.php"
Task: "Extend onboarding 404 versus 403 parity coverage for trusted-state failures in tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php and tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php"
# Then land the wizard implementation in sequence:
Task: "Replace ownership-relevant public model authority in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php with locked scalar identity or resolver-backed access while preserving wizard continuity"
Task: "Rework protected onboarding actions in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and app/Services/Onboarding/OnboardingDraftMutationService.php to re-resolve draft, workspace, and tenant truth before verify, bootstrap, cancel, delete, and activate paths"
```
---
## Parallel Example: User Story 2
```bash
# Launch the tenant-context and system-page regressions together:
Task: "Add tenant-context trusted-state coverage in tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php and tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php"
Task: "Extend system runbook selector forged-state coverage in tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillPreflightTest.php, tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php, and tests/Feature/System/Spec113/AllowedTenantUniverseTest.php"
Task: "Extend cross-plane and capability-parity coverage for covered non-wizard surfaces in tests/Feature/System/Spec113/AuthorizationSemanticsTest.php and tests/Feature/System/Spec113/TenantPlaneCannotAccessSystemTest.php"
```
---
## Parallel Example: User Story 3
```bash
# Launch the reusable guard work together:
Task: "Implement the trusted-state architectural guard in tests/Feature/Guards/LivewireTrustedStateGuardTest.php"
Task: "Extend existing resolver and action-surface guard coverage for the first-slice surfaces in tests/Feature/Guards/AdminTenantResolverGuardTest.php, tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php, and tests/Feature/Guards/ActionSurfaceContractTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate onboarding resume, verification, bootstrap, and activation behavior against forged-state regressions.
### Incremental Delivery
1. Land the shared trusted-state and resolver layer.
2. Harden onboarding as the MVP trust surface.
3. Add tenant-context and system-page selector hardening.
4. Finish with the reusable guard and contract alignment so future components inherit the pattern.
### Parallel Team Strategy
1. One developer lands the foundational trusted-state scaffolding.
2. A second developer can harden the onboarding wizard while another works on tenant-context and system-page regressions.
3. A final pass lands the reusable guard and rollout-inventory alignment after the first-slice surfaces are proven.
## Notes
- [P] tasks are limited to work on different files with no incomplete dependency overlap.
- US1 is the recommended MVP because it closes the highest-risk Livewire trust boundary first.
- US2 proves the pattern is not wizard-specific by covering both admin tenant-context and system platform surfaces.
- US3 turns the first-slice implementation into a reusable, CI-enforced repository standard.

View File

@ -222,6 +222,19 @@
}
});
it('keeps first-slice trusted-state page action-surface status explicit', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline();
expect($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toContain('dedicated tests');
expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests');
expect(method_exists(\App\Filament\System\Pages\Ops\Runbooks::class, 'actionSurfaceDeclaration'))->toBeFalse()
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
});
it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void {
$declaration = AlertDeliveryResource::actionSurfaceDeclaration();

View File

@ -29,6 +29,8 @@ function adminTenantResolverGuardedFiles(): array
'app/Filament/Resources/AlertDeliveryResource.php',
'app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php',
'app/Filament/Pages/Monitoring/AuditLog.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php',
'app/Filament/Resources/RestoreRunResource.php',
'app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php',
@ -94,3 +96,11 @@ function adminTenantResolverExceptionFiles(): array
->and($contents)->not->toContain('activeEntitledTenant(request())')
->and($contents)->not->toContain('Tenant::current()');
});
it('keeps first-slice admin trusted-state surfaces inside the canonical admin resolver guard inventory', function (): void {
$guardedFiles = adminTenantResolverGuardedFiles();
expect($guardedFiles)
->toContain('app/Filament/Pages/TenantRequiredPermissions.php')
->toContain('app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php');
});

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
use App\Support\Livewire\TrustedState\TrustedStateClass;
use App\Support\Livewire\TrustedState\TrustedStatePolicy;
use App\Support\Livewire\TrustedState\TrustedStateResolver;
use App\Support\Workspaces\WorkspaceContext;
function livewireTrustedStateFirstSliceFixtures(): array
{
return [
TrustedStatePolicy::MANAGED_TENANT_ONBOARDING_WIZARD => 'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
TrustedStatePolicy::TENANT_REQUIRED_PERMISSIONS => 'app/Filament/Pages/TenantRequiredPermissions.php',
TrustedStatePolicy::SYSTEM_RUNBOOKS => 'app/Filament/System/Pages/Ops/Runbooks.php',
];
}
function livewireTrustedStateFieldGroups(array $componentPolicy): array
{
return [
'locked_identity_fields' => $componentPolicy['locked_identity_fields'],
'mutable_selector_fields' => $componentPolicy['mutable_selector_fields'],
'server_derived_authority_fields' => $componentPolicy['server_derived_authority_fields'],
];
}
it('documents a first-slice trusted-state policy for every guarded surface', function (): void {
$policy = app(TrustedStatePolicy::class);
expect($policy->components())
->toBe(array_keys(livewireTrustedStateFirstSliceFixtures()));
foreach (livewireTrustedStateFirstSliceFixtures() as $component => $relativePath) {
expect(file_exists(base_path($relativePath)))->toBeTrue();
expect($policy->forComponent($component)['component_name'])->not->toBe('');
}
});
it('keeps first-slice policies explicit about state lanes and authority sources', function (): void {
$policy = app(TrustedStatePolicy::class);
$validClasses = array_map(
static fn (TrustedStateClass $class): string => $class->value,
TrustedStateClass::cases(),
);
expect($validClasses)->toContain('presentation', 'locked_identity', 'server_derived_authority');
foreach ($policy->firstSlice() as $componentPolicy) {
expect($componentPolicy['plane'])->not->toBe('')
->and($componentPolicy['authority_sources'])->not->toBeEmpty()
->and($componentPolicy['mutable_selectors'])->toBeArray()
->and($componentPolicy['locked_identities'])->toBeArray()
->and($componentPolicy['locked_identity_fields'])->toBeArray()
->and($componentPolicy['mutable_selector_fields'])->toBeArray()
->and($componentPolicy['server_derived_authority_fields'])->toBeArray()
->and($componentPolicy['forbidden_public_authority_fields'])->toBeArray();
}
});
it('keeps the first-slice trusted-state inventory implementation-backed', function (): void {
$policy = app(TrustedStatePolicy::class);
foreach (livewireTrustedStateFirstSliceFixtures() as $component => $relativePath) {
$componentPolicy = $policy->forComponent($component);
$contents = file_get_contents(base_path($relativePath));
expect($contents)->not->toBeFalse();
foreach (livewireTrustedStateFieldGroups($componentPolicy) as $groupName => $fields) {
foreach ($fields as $field) {
expect($field['notes'])->not->toBe('');
expect($field['implementation_markers'])->not->toBeEmpty();
$expectedStateClass = match ($groupName) {
'locked_identity_fields' => TrustedStateClass::LockedIdentity->value,
'mutable_selector_fields' => TrustedStateClass::Presentation->value,
'server_derived_authority_fields' => TrustedStateClass::ServerDerivedAuthority->value,
default => throw new InvalidArgumentException('Unknown trusted-state group.'),
};
expect($field['state_class'])->toBe($expectedStateClass);
foreach ($field['implementation_markers'] as $marker) {
if (str_contains($marker, PHP_EOL)) {
$pattern = '/'.str_replace(
['\\ '.preg_quote(PHP_EOL, '/').'\\ ', '\\ '.preg_quote(PHP_EOL, '/'), preg_quote(PHP_EOL, '/').'\\ '],
['\\s+', '\\s+', '\\s+'],
preg_quote($marker, '/')
).'/s';
expect(preg_match($pattern, (string) $contents))
->toBe(1, "Missing implementation marker [{$marker}] for {$component}.{$field['name']}");
continue;
}
expect(str_contains((string) $contents, $marker))
->toBeTrue("Missing implementation marker [{$marker}] for {$component}.{$field['name']}");
}
}
}
}
});
it('documents first-slice trusted-state helper semantics through reusable enum and resolver APIs', function (): void {
$policy = app(TrustedStatePolicy::class);
$resolver = app(TrustedStateResolver::class);
expect(TrustedStateClass::Presentation->allowsClientMutation())->toBeTrue()
->and(TrustedStateClass::Presentation->requiresServerRevalidation())->toBeFalse()
->and(TrustedStateClass::LockedIdentity->allowsClientMutation())->toBeFalse()
->and(TrustedStateClass::LockedIdentity->requiresServerRevalidation())->toBeTrue()
->and(TrustedStateClass::ServerDerivedAuthority->allowsClientMutation())->toBeFalse()
->and(TrustedStateClass::ServerDerivedAuthority->requiresServerRevalidation())->toBeTrue();
foreach ($policy->components() as $component) {
expect($resolver->requiredAuthoritySources($component, $policy))
->toBe($policy->forComponent($component)['authority_sources']);
}
});
it('binds the shared trusted-state resolver through the container', function (): void {
expect(app(TrustedStateResolver::class))->toBeInstanceOf(TrustedStateResolver::class)
->and(app(WorkspaceContext::class))->toBeInstanceOf(WorkspaceContext::class);
});

View File

@ -2,6 +2,15 @@
use Illuminate\Support\Collection;
function trustedStateFirstSliceSurfaces(): array
{
return [
'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/System/Pages/Ops/Runbooks.php',
];
}
it('does not introduce ad-hoc authorization patterns in app/Filament (allowlist-driven)', function () {
$root = base_path();
$self = realpath(__FILE__);
@ -137,3 +146,29 @@
}
}
});
it('keeps first-slice trusted-state surfaces inside the standard Filament auth scan', function (): void {
$root = base_path();
foreach (trustedStateFirstSliceSurfaces() as $relativePath) {
expect(file_exists($root.'/'.$relativePath))->toBeTrue();
}
});
it('keeps first-slice trusted-state surfaces free of broad ad-hoc authorization shortcuts', function (): void {
$forbiddenPatterns = [
'/\\bGate::\\b/',
'/\\babort_(?:if|unless)\\b/',
];
foreach (trustedStateFirstSliceSurfaces() as $relativePath) {
$contents = file_get_contents(base_path($relativePath));
expect($contents)->not->toBeFalse();
foreach ($forbiddenPatterns as $pattern) {
expect(preg_match($pattern, (string) $contents))
->toBe(0, "First-slice trusted-state surface should stay free of broad ad-hoc auth shortcuts: {$relativePath}");
}
}
});

View File

@ -155,7 +155,11 @@
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'managed_tenant_onboarding.activation')
->exists())->toBeTrue();
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'managed_tenant_onboarding.activation_override_used')
->exists())->toBeTrue();
});
it('requires an override when the stored verification report is blocked even if the run outcome says succeeded', function (): void {
@ -252,3 +256,92 @@
$tenant->refresh();
expect($tenant->status)->toBe(Tenant::STATUS_ACTIVE);
});
it('does not activate or write activation audit history when the workspace changes after mount', function (): void {
Queue::fake();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
'status' => 'connected',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider' => 'microsoft',
'module' => 'health_check',
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
],
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspaceA,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
$component->call('completeOnboarding')->assertNotFound();
$tenant->refresh();
expect($tenant->status)->toBe(Tenant::STATUS_ONBOARDING)
->and(AuditLog::query()
->where('workspace_id', (int) $workspaceA->getKey())
->whereIn('action', [
'managed_tenant_onboarding.activation',
'managed_tenant_onboarding.activation_override_used',
])
->exists())->toBeFalse();
});

View File

@ -2,11 +2,14 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('returns 404 when the requested onboarding draft does not exist', function (): void {
$workspace = Workspace::factory()->create();
@ -144,3 +147,166 @@
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertForbidden();
});
it('mounts the requested draft with canonical persisted continuity state even when other drafts exist', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'tenant_id' => '23232323-2323-2323-2323-232323232323',
'name' => 'Canonical Continuity Tenant',
]);
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Other Draft Tenant',
]);
$user = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$user->tenants()->syncWithoutDetaching([
$otherTenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Canonical continuity connection',
'is_default' => true,
]);
createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $otherTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'identify',
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'bootstrap',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'environment' => 'prod',
'notes' => 'Canonical persisted note',
'provider_connection_id' => (int) $connection->getKey(),
'bootstrap_operation_types' => ['inventory.sync'],
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
])
->assertSet('onboardingSessionId', (int) $draft->getKey())
->assertSet('showDraftPicker', false)
->assertSet('showStartState', false)
->assertSet('selectedProviderConnectionId', (int) $connection->getKey())
->assertSet('selectedBootstrapOperationTypes', ['inventory.sync'])
->assertSet('data.provider_connection_id', (int) $connection->getKey())
->assertSet('data.name', (string) $tenant->name);
});
it('refreshes continuity state from the requested draft even when public draft state is forged before refresh', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'tenant_id' => '24242424-2424-2424-2424-242424242424',
'name' => 'Requested Draft Tenant',
]);
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'tenant_id' => '25252525-2525-2525-2525-252525252525',
'name' => 'Forged Draft Tenant',
]);
$user = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$user->tenants()->syncWithoutDetaching([
$otherTenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Requested connection',
'is_default' => true,
]);
$otherConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'display_name' => 'Forged connection',
'is_default' => true,
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$otherDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $otherTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'tenant_name' => (string) $otherTenant->name,
'provider_connection_id' => (int) $otherConnection->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
])
->set('onboardingSession', $otherDraft)
->set('managedTenant', $otherTenant)
->call('refreshCheckpointLifecycle')
->assertSet('onboardingSessionId', (int) $draft->getKey())
->assertSet('managedTenantId', (int) $tenant->getKey())
->assertSet('selectedProviderConnectionId', (int) $connection->getKey())
->assertSet('data.provider_connection_id', (int) $connection->getKey())
->assertSet('data.name', (string) $tenant->name);
});

View File

@ -107,6 +107,125 @@
expect($draft->fresh()->isCancelled())->toBeTrue();
});
it('cancels the route-bound draft even when public onboarding draft state is forged', function (): void {
$workspace = Workspace::factory()->create();
$primaryTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$secondaryTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$user = User::factory()->create();
createUserWithTenant(
tenant: $primaryTenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$user->tenants()->syncWithoutDetaching([
$secondaryTenant->getKey() => ['role' => 'owner'],
]);
$primaryDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $primaryTenant,
'started_by' => $user,
'updated_by' => $user,
]);
$secondaryDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $secondaryTenant,
'started_by' => $user,
'updated_by' => $user,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $primaryDraft->getKey(),
])
->set('onboardingSession', $secondaryDraft)
->set('managedTenant', $secondaryTenant)
->mountAction('cancel_onboarding_draft')
->callMountedAction()
->assertNotified('Onboarding draft cancelled');
expect($primaryDraft->fresh()->isCancelled())->toBeTrue()
->and($secondaryDraft->fresh()?->isCancelled())->toBeFalse();
});
it('keeps rendering the route-bound draft after forged continuity state is refreshed', function (): void {
$workspace = Workspace::factory()->create();
$primaryTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Primary Continuity Tenant',
'tenant_id' => '35353535-3535-3535-3535-353535353535',
]);
$secondaryTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Secondary Continuity Tenant',
'tenant_id' => '36363636-3636-3636-3636-363636363636',
]);
$user = User::factory()->create(['name' => 'Continuity Owner']);
createUserWithTenant(
tenant: $primaryTenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$user->tenants()->syncWithoutDetaching([
$secondaryTenant->getKey() => ['role' => 'owner'],
]);
$primaryDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $primaryTenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $primaryTenant->tenant_id,
'tenant_name' => (string) $primaryTenant->name,
],
]);
$secondaryDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $secondaryTenant,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => (string) $secondaryTenant->tenant_id,
'tenant_name' => (string) $secondaryTenant->name,
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $primaryDraft->getKey(),
])
->set('onboardingSession', $secondaryDraft)
->set('managedTenant', $secondaryTenant)
->call('refreshCheckpointLifecycle')
->assertSet('onboardingSessionId', (int) $primaryDraft->getKey())
->assertSet('managedTenantId', (int) $primaryTenant->getKey())
->assertSee('Primary Continuity Tenant')
->assertDontSee('Secondary Continuity Tenant');
});
it('resolves the cancel draft header action as enabled for managers with cancel capability', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([

View File

@ -71,9 +71,8 @@
$secondTab
->call('selectProviderConnection', (int) $firstConnection->getKey())
->assertSet('onboardingSession.version', 2)
->assertSet('onboardingSession.state.provider_connection_id', (int) $secondConnection->getKey())
->assertSet('selectedProviderConnectionId', (int) $secondConnection->getKey());
->assertSet('onboardingSessionId', (int) $draft->getKey())
->assertSet('onboardingSessionVersion', 2);
$draft->refresh();

View File

@ -105,3 +105,70 @@
->call('selectProviderConnection', (int) $otherConnection->getKey())
->assertStatus(404);
});
it('ignores forged onboarding session and managed tenant state when selecting a provider connection', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user);
$entraTenantId = '64646464-6464-6464-6464-646464646464';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Primary Tenant',
]);
$primarySession = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('entra_tenant_id', $entraTenantId)
->firstOrFail();
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '65656565-6565-6565-6565-656565656565',
'status' => Tenant::STATUS_ONBOARDING,
]);
$otherDraft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'current_step' => 'connection',
'state' => [],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$otherConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'display_name' => 'Forged tenant connection',
'is_default' => true,
]);
$component
->set('onboardingSession', $otherDraft)
->set('managedTenant', $otherTenant)
->call('selectProviderConnection', (int) $otherConnection->getKey())
->assertStatus(404);
$primarySession->refresh();
$otherDraft->refresh();
expect($primarySession->state['provider_connection_id'] ?? null)->toBeNull()
->and($otherDraft->state['provider_connection_id'] ?? null)->toBeNull();
});

View File

@ -3,6 +3,8 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
@ -40,3 +42,73 @@
->test(ManagedTenantOnboardingWizard::class)
->assertForbidden();
});
it('returns 404 for trusted actions when the selected workspace changes after mount', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$firstConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'First connection',
'is_default' => true,
]);
$secondConnection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'dummy',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Second connection',
'is_default' => false,
]);
$draft = createOnboardingDraft([
'workspace' => $workspaceA,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $firstConnection->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
$component
->call('selectProviderConnection', (int) $secondConnection->getKey())
->assertNotFound();
expect(($draft->fresh()->state['provider_connection_id'] ?? null))->toBe((int) $firstConnection->getKey());
});

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
@ -222,6 +223,9 @@ function createVerificationAssistDraft(
it('opens and closes the assist slideover without changing the verify step', function (): void {
[$user, , $draft] = createVerificationAssistDraft('blocked');
$existingRunCount = OperationRun::query()->count();
$existingAuditCount = AuditLog::query()->count();
session()->put(WorkspaceContext::SESSION_KEY, (int) $user->last_workspace_id);
Livewire::actingAs($user)
@ -231,7 +235,9 @@ function createVerificationAssistDraft(
->assertMountedActionModalSee('Open full page')
->unmountAction();
expect($draft->fresh()?->current_step)->toBe('verify');
expect($draft->fresh()?->current_step)->toBe('verify')
->and(OperationRun::query()->count())->toBe($existingRunCount)
->and(AuditLog::query()->count())->toBe($existingAuditCount);
});
it('renders summary metadata and missing application permissions in the assist slideover', function (): void {

View File

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
@ -90,6 +91,15 @@
->firstOrFail();
expect($session->state['verification_operation_run_id'] ?? null)->toBe($runId);
expect(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'managed_tenant_onboarding.verification_start')
->exists())->toBeTrue()
->and(AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', 'managed_tenant_onboarding.verification_persisted')
->exists())->toBeTrue();
});
it('stores a blocked verification report and canonical link when onboarding verification cannot proceed', function (): void {
@ -159,6 +169,78 @@
Queue::assertNothingPushed();
});
it('does not start verification or write audit history when the workspace changes after mount', function (): void {
Queue::fake();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verified connection',
'is_default' => true,
'status' => 'connected',
]);
$draft = createOnboardingDraft([
'workspace' => $workspaceA,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceA->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $draft->getKey(),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
$component->call('startVerification')->assertNotFound();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->exists())->toBeFalse()
->and(AuditLog::query()
->where('workspace_id', (int) $workspaceA->getKey())
->whereIn('action', [
'managed_tenant_onboarding.verification_start',
'managed_tenant_onboarding.verification_persisted',
])
->exists())->toBeFalse();
});
it('renders stored verification findings in the wizard report section', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
@ -303,6 +385,76 @@
expect($session->state['verification_operation_run_id'] ?? null)->toBeNull();
});
it('ignores forged onboarding session and managed tenant state when starting verification', function (): void {
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '17171717-1717-1717-1717-171717171717';
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
$component->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Primary Tenant',
]);
$primaryTenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '18181818-1818-1818-1818-181818181818',
'status' => Tenant::STATUS_ONBOARDING,
]);
$otherConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'display_name' => 'Forged verification connection',
'is_default' => true,
'status' => 'connected',
]);
$otherDraft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $otherTenant->getKey(),
'entra_tenant_id' => (string) $otherTenant->tenant_id,
'current_step' => 'connection',
'state' => [
'provider_connection_id' => (int) $otherConnection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component
->set('onboardingSession', $otherDraft)
->set('managedTenant', $otherTenant)
->set('selectedProviderConnectionId', (int) $otherConnection->getKey())
->call('startVerification');
expect(OperationRun::query()
->where('tenant_id', (int) $otherTenant->getKey())
->where('type', 'provider.connection.check')
->exists())->toBeFalse()
->and(OperationRun::query()
->where('tenant_id', (int) $primaryTenant->getKey())
->where('type', 'provider.connection.check')
->exists())->toBeFalse();
});
it('treats a completed verification run as stale when it belongs to a different provider connection', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$otherTenant = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'name' => 'Foreign Query Tenant',
'external_id' => 'foreign-query-tenant',
]);
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions?tenant={$otherTenant->external_id}&tenant_id={$otherTenant->getKey()}&status=all")
->assertSuccessful();
$response
->assertSee($tenant->getFilamentName())
->assertDontSee($otherTenant->name);
});
it('keeps filter state usable without redefining tenant scope', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
config()->set('intune_permissions.permissions', [
[
'key' => 'Tenant.Read.All',
'type' => 'application',
'description' => 'Tenant read permission',
'features' => ['backup'],
],
]);
config()->set('entra_permissions.permissions', []);
TenantPermission::create([
'tenant_id' => (int) $tenant->getKey(),
'permission_key' => 'Tenant.Read.All',
'status' => 'granted',
'details' => ['source' => 'db'],
'last_checked_at' => now(),
]);
$response = $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions?status=present&type=application&search=Tenant")
->assertSuccessful();
$response
->assertSee($tenant->getFilamentName())
->assertSee('data-permission-key="Tenant.Read.All"', false);
});
it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void {
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
$user = User::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'external_id' => 'tenant-required-permissions-404',
]);
createUserWithTenant(
tenant: $tenant,
user: $user,
role: 'readonly',
workspaceRole: 'readonly',
);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'readonly',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspaceB->getKey());
$this->actingAs($user)
->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
->assertNotFound();
});

View File

@ -3,11 +3,14 @@
declare(strict_types=1);
use App\Models\Finding;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@ -82,3 +85,118 @@
expect($result['total_count'])->toBe(2);
expect($result['affected_count'])->toBe(2);
});
it('accepts an allowed single-tenant selection during preflight', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsTenantId', (int) $tenant->getKey())
->assertSet('preflight.affected_count', 1);
});
it('rejects platform tenant selection during preflight', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
assertScopedSelectorRejected(
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class),
'preflight',
[
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $platformTenant->getKey(),
],
);
});
it('resets to an all-tenant trusted scope even when stale single-tenant selector state remains on the page', function () {
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenantA = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant A',
]);
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
'name' => 'Scope Tenant B',
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantA->getKey(),
'due_at' => null,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenantB->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(\App\Filament\System\Pages\Ops\Runbooks::class)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $tenantA->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $tenantA->getKey())
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
'tenant_id' => (int) $tenantA->getKey(),
])
->assertHasNoActionErrors()
->assertSet('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('findingsTenantId', null)
->assertSet('scopeMode', FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->assertSet('tenantId', null)
->assertSet('preflight.estimated_tenants', 2)
->assertSet('preflight.affected_count', 2);
});

View File

@ -3,7 +3,9 @@
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
@ -111,3 +113,141 @@
])
->assertHasActionErrors(['typed_confirmation']);
});
it('rejects forged single-tenant selector state on run and records no run or start audit', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$allowedTenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $allowedTenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->set('findingsScopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('findingsTenantId', (int) $platformTenant->getKey())
->set('scopeMode', FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->set('tenantId', (int) $platformTenant->getKey())
->callAction('run', data: [])
->assertHasActionErrors();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});
it('records a start audit with the canonical single-tenant scope when an allowed run is queued', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertHasNoActionErrors()
->assertNotified('Findings lifecycle backfill queued');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->latest('id')
->first();
expect($run)->not->toBeNull();
$audit = AuditLog::query()
->where('action', 'platform.ops.runbooks.start')
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_id)->toBe((string) $run?->getKey())
->and($audit?->metadata['scope'] ?? null)->toBe(FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->and($audit?->metadata['target_tenant_id'] ?? null)->toBe((int) $tenant->getKey())
->and($audit?->metadata['operation_run_id'] ?? null)->toBe((int) $run?->getKey());
});
it('returns 403 for runbook execution when the platform user is in scope but lacks run capability', function () {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT,
'tenant_id' => (int) $tenant->getKey(),
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [])
->assertForbidden();
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0)
->and(AuditLog::query()->where('action', 'platform.ops.runbooks.start')->count())->toBe(0);
});

View File

@ -44,3 +44,25 @@
expect(OperationRun::query()->count())->toBe(0);
});
it('resolves allowed tenant proposals by id and rejects the platform tenant', function () {
$platformTenant = Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
$customerTenant = Tenant::factory()->create([
'external_id' => 'tenant-2',
'name' => 'Tenant Two',
]);
$universe = app(AllowedTenantUniverse::class);
expect($universe->resolveAllowed((int) $customerTenant->getKey()))
->not->toBeNull()
->getKey()->toBe($customerTenant->getKey());
expect(fn () => $universe->resolveAllowedOrFail((int) $platformTenant->getKey()))
->toThrow(ValidationException::class);
});

View File

@ -48,3 +48,32 @@
->get('/system')
->assertSuccessful();
});
it('returns 403 on runbooks when a platform user lacks the runbooks view capability even with system access', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get('/system/ops/runbooks')
->assertForbidden();
});
it('returns 200 on runbooks when a platform user has the required runbooks capability set', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get('/system/ops/runbooks')
->assertSuccessful();
});

View File

@ -95,6 +95,27 @@ function something()
// ..
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function mutateTrustedStatePayload(array $payload, array $overrides): array
{
return array_replace_recursive($payload, $overrides);
}
/**
* @param array<string, mixed> $data
* @param list<string> $errorKeys
*/
function assertScopedSelectorRejected(mixed $component, string $action, array $data, array $errorKeys = ['tenant_id']): mixed
{
return $component
->callAction($action, data: $data)
->assertHasActionErrors($errorKeys);
}
/**
* Spec test naming helper.
*