Compare commits

...

3 Commits

Author SHA1 Message Date
bd06b479e1 feat: add governance run summaries (#257)
Some checks failed
Main Confidence / confidence (push) Failing after 43s
## Summary
- add the Spec 220 governance run diagnostic summary seam and wire it through the canonical operation run detail presenter
- render summary-first decision guidance for covered governance run families while keeping technical diagnostics secondary
- add focused Pest coverage, spec artifacts, and complete the integrated-browser smoke validation for canonical run detail

## Testing
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/ArtifactTruthRunDetailTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Unit/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilderTest.php tests/Unit/Support/OperatorExplanation/OperatorExplanationBuilderTest.php
- integrated browser smoke pass on localhost:8081 covering summary-first hierarchy, zero-output runs, multi-cause runs, cross-family parity, workspace-wide visibility, and deny-as-not-found tenant safety

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #257
2026-04-20 20:46:09 +00:00
c86b399b43 feat(219): Finding ownership semantics + LEAN-001 constitution + backup_set unification (#256)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary

This PR delivers three related improvements:

### 1. Finding Ownership Semantics (Spec 219)
- Add responsibility/accountability labels to findings and finding exceptions
- `owner_user_id` = accountable party (governance owner)
- `assignee_user_id` = responsible party (technical implementer)
- Expose Assign/Reassign actions in FindingResource with audit logging
- Add ownership columns and filters to finding list
- Propagate owner from finding to exception on creation
- Tests: ownership semantics, assignment audit, workflow actions

### 2. Constitution v2.7.0 — LEAN-001 Pre-Production Lean Doctrine
- New principle forbidding legacy aliases, migration shims, dual-write logic, and compatibility fixtures in a pre-production codebase
- AI-agent 4-question verification gate before adding any compatibility path
- Review rule: compatibility shims without answering the gate questions = merge blocker
- Exit condition: LEAN-001 expires at first production deployment
- Spec template: added default "Compatibility posture" block
- Agent instructions: added "Pre-production compatibility check" section

### 3. Backup Set Operation Type Unification
- Unified `backup_set.add_policies` and `backup_set.remove_policies` into single canonical `backup_set.update`
- Removed all legacy aliases, constants, and test fixtures
- Added lifecycle coverage for `backup_set.update` in config
- Updated all 14+ test files referencing legacy types

### Spec Artifacts
- `specs/219-finding-ownership-semantics/` — full spec, plan, tasks, research, data model, contracts, checklist

### Tests
- All affected tests pass (OperationCatalog, backup set, finding workflow, ownership semantics)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #256
2026-04-20 17:54:33 +00:00
a089350f98 feat: unify provider-backed action dispatch gating (#255)
Some checks failed
Main Confidence / confidence (push) Failing after 49s
## Summary
- unify provider-backed action starts behind the shared provider dispatch gate and shared start-result presenter
- align tenant, onboarding, provider-connection, restore, directory, and monitoring surfaces with the same blocked, deduped, scope-busy, and accepted semantics
- include the spec kit artifacts for spec 216 and the regression fixes that brought the full suite back to green

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RestoreRunIdempotencyTest.php tests/Feature/ExecuteRestoreRunJobTest.php tests/Feature/Restore/RestoreRunProviderStartTest.php tests/Feature/Hardening/ExecuteRestoreRunJobGateTest.php tests/Feature/Hardening/BlockedWriteAuditLogTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec177InventoryCoverageTruthSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact`

## Notes
- branch: `216-provider-dispatch-gate`
- commit: `34230be7`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #255
2026-04-20 06:52:38 +00:00
140 changed files with 9645 additions and 1095 deletions

View File

@ -216,8 +216,14 @@ ## Active Technologies
- PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression) - PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression)
- Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages) - Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages)
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages) - Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure) - Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure) - Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4 (219-finding-ownership-semantics)
- PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned (219-finding-ownership-semantics)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders (220-governance-run-summaries)
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -252,10 +258,20 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests - 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests - 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
- 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders - 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests
- 214-website-visual-foundation: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro component primitives, Playwright browser smoke tests
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check
Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, verify all of the following:
1. Do live production data exist?
2. Is shared staging migration-relevant?
3. Does an external contract depend on the old shape?
4. Does the spec explicitly require compatibility behavior?
If all answers are no, replace the old shape and remove the compatibility path.
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -1,21 +1,21 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.5.0 -> 2.6.0 - Version change: 2.6.0 -> 2.7.0
- Modified principles: - Modified principles: None
- UI surface taxonomy and review expectations: expanded with native - Added sections:
vs custom classification, shared-detail host ownership, named - Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
anti-patterns, and shell/page/detail state ownership review migration shims, dual-write logic, and compatibility fixtures in a
- Filament Native First / No Ad-hoc Styling (UI-FIL-001): expanded pre-production codebase; includes AI-agent verification checklist,
into explicit native-by-default, fake-native, shared-family, and review rule, and explicit exit condition at first production deploy
exception-boundary language
- Added sections: None
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- None in this docs-only constitution slice; enforcement remains - .specify/templates/spec-template.md: added "Compatibility posture"
deferred to Spec 201 default block ✅
- .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo - N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None - Follow-up TODOs: None
--> -->
@ -133,6 +133,37 @@ ### Spec Candidate Gate (SPEC-GATE-001)
### Default Bias (BIAS-001) ### Default Bias (BIAS-001)
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively. - Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
### Pre-Production Lean Doctrine (LEAN-001)
This product has no production deployment, no live customer data, no shared staging with migration-relevant state, and no external API contract consumers.
#### Data and schema
- Old data shapes, column names, enum values, and operation types MAY be replaced in place.
- Migration shims, dual-write logic, and fallback readers MUST NOT be created unless a spec explicitly requires compatibility behavior.
#### Terminology and types
- Renamed or unified operation types, reason codes, and status values MUST replace the old value everywhere (code, config, tests, fixtures, seed data).
- Legacy aliases kept "just in case" are forbidden.
#### Codebase hygiene
- Dead constants, dead enum cases, orphan config keys, and test fixtures that reference replaced shapes MUST be removed in the same PR that introduces the replacement.
- "Old runs / old rows don't matter" is the standing assumption until the product ships.
#### AI-agent rule
- Before adding aliases, fallback readers, dual-write logic, migration shims, or legacy fixtures, agents MUST verify:
1. Do live production data exist?
2. Is shared staging migration-relevant?
3. Does an external contract depend on the old shape?
4. Does the spec explicitly require compatibility behavior?
- If all answers are no, replace the old shape and remove the compatibility path.
#### Review rule
- Any PR that introduces a new legacy alias, compatibility shim, or historical fixture without answering the four questions above is a merge blocker.
#### Exit condition
- LEAN-001 expires when the first production deployment occurs.
- At that point, the constitution MUST be amended to define the real migration and compatibility policy.
### Workspace Isolation is Non-negotiable ### Workspace Isolation is Non-negotiable
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as - Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
deny-as-not-found (404). deny-as-not-found (404).
@ -1573,4 +1604,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.6.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18 **Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19

View File

@ -101,6 +101,14 @@ ## Proportionality Review *(mandatory when structural complexity is introduced)*
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient] - **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
- **Release truth**: [Current-release truth or future-release preparation] - **Release truth**: [Current-release truth or future-release preparation]
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* ## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name. For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.

View File

@ -13,7 +13,9 @@ trait ResolvesPanelTenantContext
{ {
protected static function resolveTenantContextForCurrentPanel(): ?Tenant protected static function resolveTenantContextForCurrentPanel(): ?Tenant
{ {
if (Filament::getCurrentPanel()?->getId() === 'admin') { $request = request();
if (static::currentPanelId($request) === 'admin') {
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request()); $tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
return $tenant instanceof Tenant ? $tenant : null; return $tenant instanceof Tenant ? $tenant : null;
@ -49,4 +51,41 @@ protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{ {
return static::resolveTenantContextForCurrentPanelOrFail(); return static::resolveTenantContextForCurrentPanelOrFail();
} }
private static function currentPanelId(mixed $request): ?string
{
$panelId = Filament::getCurrentPanel()?->getId();
if (is_string($panelId) && $panelId !== '') {
return $panelId;
}
$routeName = is_object($request) && method_exists($request, 'route')
? $request->route()?->getName()
: null;
if (is_string($routeName) && $routeName !== '') {
if (str_contains($routeName, '.tenant.')) {
return 'tenant';
}
if (str_contains($routeName, '.admin.')) {
return 'admin';
}
}
$path = is_object($request) && method_exists($request, 'path')
? '/'.ltrim((string) $request->path(), '/')
: null;
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
return 'tenant';
}
if (is_string($path) && str_starts_with($path, '/admin/')) {
return 'admin';
}
return null;
}
} }

View File

@ -521,7 +521,7 @@ public function basisRunSummary(): array
'badgeColor' => null, 'badgeColor' => null,
'runUrl' => null, 'runUrl' => null,
'historyUrl' => null, 'historyUrl' => null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]; ];
} }
@ -537,7 +537,7 @@ public function basisRunSummary(): array
'badgeColor' => $badge->color, 'badgeColor' => $badge->color,
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null, 'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null, 'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant), 'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
]; ];
} }

View File

@ -246,21 +246,10 @@ public function blockedExecutionBanner(): ?array
return null; return null;
} }
$operatorExplanation = $this->governanceOperatorExplanation();
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
$lines = $operatorExplanation instanceof OperatorExplanationPattern
? array_values(array_filter([
$operatorExplanation->headline,
$operatorExplanation->dominantCauseExplanation,
]))
: ($reasonEnvelope?->toBodyLines(false) ?? [
$this->surfaceFailureDetail() ?? 'The queued operation was refused before side effects could begin.',
]);
return [ return [
'tone' => 'amber', 'tone' => 'amber',
'title' => 'Blocked by prerequisite', 'title' => 'Blocked by prerequisite',
'body' => implode(' ', array_values(array_unique($lines))), 'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
]; ];
} }

View File

@ -122,17 +122,17 @@ public function table(Table $table): Table
TextColumn::make('outcome') TextColumn::make('outcome')
->label('Outcome') ->label('Outcome')
->badge() ->badge()
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryLabel) ->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeLabel']))
->color(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryBadge->color) ->color(\Closure::fromCallable([$this, 'reviewOutcomeBadgeColor']))
->icon(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->icon) ->icon(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIcon']))
->iconColor(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->iconColor) ->iconColor(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIconColor']))
->description(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryReason) ->description(\Closure::fromCallable([$this, 'reviewOutcomeDescription']))
->wrap(), ->wrap(),
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
TextColumn::make('next_step') TextColumn::make('next_step')
->label('Next step') ->label('Next step')
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->nextActionText) ->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeNextStep']))
->wrap(), ->wrap(),
]) ])
->filters([ ->filters([
@ -330,13 +330,46 @@ private function reviewTruth(TenantReview $record, bool $fresh = false): Artifac
: $presenter->forTenantReview($record); : $presenter->forTenantReview($record);
} }
private function reviewOutcomeLabel(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryLabel;
}
private function reviewOutcomeBadgeColor(TenantReview $record): string
{
return $this->reviewOutcome($record)->primaryBadge->color;
}
private function reviewOutcomeBadgeIcon(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->icon;
}
private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(TenantReview $record): ?string
{
return $this->reviewOutcome($record)->primaryReason;
}
private function reviewOutcomeNextStep(TenantReview $record): string
{
return $this->reviewOutcome($record)->nextActionText;
}
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? $this->reviewTruth($record, true)
: $this->reviewTruth($record);
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh) return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh)
?? $presenter->compressedOutcomeFromEnvelope( ?? $presenter->compressedOutcomeFromEnvelope(
$this->reviewTruth($record, $fresh), $truth,
SurfaceCompressionContext::reviewRegister(), SurfaceCompressionContext::reviewRegister(),
); );
} }

View File

@ -46,6 +46,7 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
@ -2873,65 +2874,22 @@ public function startVerification(): void
); );
} }
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()),
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() $notification->send();
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null) $notification->send();
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
@ -2939,24 +2897,12 @@ public function startVerification(): void
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) $notification->send();
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return; return;
} }
OperationUxPresenter::queuedToast((string) $result->run->type) $notification->send();
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
} }
public function refreshVerificationStatus(): void public function refreshVerificationStatus(): void
@ -3056,85 +3002,73 @@ public function startBootstrap(array $operationTypes): void
actor: $user, actor: $user,
expectedVersion: $this->expectedDraftVersion(), expectedVersion: $this->expectedDraftVersion(),
mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void { mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void {
$lockedConnection = ProviderConnection::query() $nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey());
->whereKey($connection->getKey())
->lockForUpdate()
->firstOrFail();
$activeRun = OperationRun::query() if ($nextOperationType === null) {
->where('tenant_id', $tenant->getKey())
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->first();
if ($activeRun instanceof OperationRun) {
$result = [ $result = [
'status' => 'scope_busy', 'status' => 'already_completed',
'run' => $activeRun, 'operation_type' => null,
'remaining_types' => [],
]; ];
return; return;
} }
$runsService = app(OperationRunService::class); $capability = $this->resolveBootstrapCapability($nextOperationType);
$bootstrapRuns = [];
$bootstrapCreated = [];
foreach ($types as $operationType) { if ($capability === null) {
$definition = $registry->get($operationType); throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}");
}
$context = [ $startResult = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: $connection,
operationType: $nextOperationType,
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void {
$this->dispatchBootstrapJob(
operationType: $nextOperationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
run: $run,
);
},
initiator: $user,
extraContext: [
'wizard' => [ 'wizard' => [
'flow' => 'managed_tenant_onboarding', 'flow' => 'managed_tenant_onboarding',
'step' => 'bootstrap', 'step' => 'bootstrap',
], ],
'provider' => $lockedConnection->provider, 'required_capability' => $capability,
'module' => $definition['module'], ],
'provider_connection_id' => (int) $lockedConnection->getKey(), );
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
];
$run = $runsService->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: [
'provider_connection_id' => (int) $lockedConnection->getKey(),
],
context: $context,
initiator: $user,
);
if ($run->wasRecentlyCreated) {
$this->dispatchBootstrapJob(
operationType: $operationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $lockedConnection->getKey(),
run: $run,
);
}
$bootstrapRuns[$operationType] = (int) $run->getKey();
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
}
$state = $draft->state ?? []; $state = $draft->state ?? [];
$existing = $state['bootstrap_operation_runs'] ?? []; $existing = $state['bootstrap_operation_runs'] ?? [];
$existing = is_array($existing) ? $existing : []; $existing = is_array($existing) ? $existing : [];
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); if ($startResult->status !== 'scope_busy') {
$existing[$nextOperationType] = (int) $startResult->run->getKey();
}
$state['bootstrap_operation_runs'] = $existing;
$state['bootstrap_operation_types'] = $types; $state['bootstrap_operation_types'] = $types;
$draft->state = $state; $draft->state = $state;
$draft->current_step = 'bootstrap'; $draft->current_step = 'bootstrap';
$remainingTypes = array_values(array_filter(
$types,
fn (string $candidate): bool => $candidate !== $nextOperationType
&& ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()),
));
$result = [ $result = [
'status' => 'started', 'status' => $startResult->status,
'runs' => $bootstrapRuns, 'start_result' => $startResult,
'created' => $bootstrapCreated, 'operation_type' => $nextOperationType,
'run' => $startResult->run,
'remaining_types' => $remainingTypes,
]; ];
}, },
)); ));
@ -3152,26 +3086,36 @@ public function startBootstrap(array $operationTypes): void
throw new RuntimeException('Bootstrap start did not return a run result.'); throw new RuntimeException('Bootstrap start did not return a run result.');
} }
if ($result['status'] === 'scope_busy') { if ($result['status'] === 'already_completed') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() Notification::make()
->title('Another operation is already running') ->title('Bootstrap already completed')
->body('Please wait for the active operation to finish.') ->body('All selected bootstrap actions have already finished successfully for this provider connection.')
->warning() ->info()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())),
])
->send(); ->send();
return; return;
} }
$bootstrapRuns = $result['runs']; $operationType = (string) ($result['operation_type'] ?? '');
$startResult = $result['start_result'] ?? null;
$run = $result['run'] ?? null;
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') {
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
}
$remainingTypes = is_array($result['remaining_types'] ?? null)
? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== ''))
: [];
if ($this->onboardingSession instanceof TenantOnboardingSession) { if ($this->onboardingSession instanceof TenantOnboardingSession) {
$auditStatus = match ($result['status']) {
'started' => 'success',
'deduped' => 'deduped',
'scope_busy' => 'blocked',
default => 'success',
};
app(WorkspaceAuditLogger::class)->log( app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace, workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value, action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
@ -3181,36 +3125,40 @@ public function startBootstrap(array $operationTypes): void
'tenant_db_id' => (int) $tenant->getKey(), 'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(), 'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types, 'operation_types' => $types,
'operation_run_ids' => $bootstrapRuns, 'started_operation_type' => $operationType,
'operation_run_id' => (int) $run->getKey(),
'result' => (string) $result['status'],
], ],
], ],
actor: $user, actor: $user,
status: 'success', status: $auditStatus,
resourceType: 'managed_tenant_onboarding_session', resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(), resourceId: (string) $this->onboardingSession->getKey(),
); );
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); $notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $startResult,
blockedTitle: 'Bootstrap action blocked',
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()),
scopeBusyTitle: 'Bootstrap action busy',
scopeBusyBody: $remainingTypes !== []
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
);
foreach ($types as $operationType) { if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
$runId = (int) ($bootstrapRuns[$operationType] ?? 0); OpsUxBrowserEvents::dispatchRunEnqueued($this);
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null; }
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
$toast = $wasCreated $notification->send();
? OperationUxPresenter::queuedToast($operationType)
: OperationUxPresenter::alreadyQueuedToast($operationType);
if ($runUrl !== null) { if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
$toast->actions([ Notification::make()
Action::make('view_run') ->title('Continue bootstrap after this run finishes')
->label(OperationRunLinks::openLabel()) ->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
->url($runUrl), ->info()
]); ->send();
}
$toast->send();
} }
} }
@ -3227,17 +3175,65 @@ private function dispatchBootstrapJob(
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
operationRun: $run, operationRun: $run,
), )->afterCommit(),
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
tenantId: $tenantId, tenantId: $tenantId,
userId: $userId, userId: $userId,
providerConnectionId: $providerConnectionId, providerConnectionId: $providerConnectionId,
operationRun: $run, operationRun: $run,
), )->afterCommit(),
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
}; };
} }
/**
* @param array<int, string> $types
*/
private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string
{
foreach ($types as $type) {
if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) {
return $type;
}
}
return null;
}
private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool
{
$state = is_array($draft->state) ? $draft->state : [];
$runMap = $state['bootstrap_operation_runs'] ?? [];
if (! is_array($runMap)) {
return false;
}
$runId = $runMap[$type] ?? null;
if (! is_numeric($runId)) {
return false;
}
$run = OperationRun::query()->whereKey((int) $runId)->first();
if (! $run instanceof OperationRun) {
return false;
}
$context = is_array($run->context ?? null) ? $run->context : [];
$runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
if ($runProviderConnectionId !== $providerConnectionId) {
return false;
}
return $run->status === OperationRunStatus::Completed->value
&& $run->outcome === OperationRunOutcome::Succeeded->value;
}
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return match ($operationType) {

View File

@ -137,7 +137,7 @@ public function table(Table $table): Table
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'backup_set.remove_policies', type: 'backup_set.update',
inputs: [ inputs: [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds, 'backup_item_ids' => $backupItemIds,
@ -220,7 +220,7 @@ public function table(Table $table): Table
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: 'backup_set.remove_policies', type: 'backup_set.update',
inputs: [ inputs: [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
'backup_item_ids' => $backupItemIds, 'backup_item_ids' => $backupItemIds,

View File

@ -182,7 +182,11 @@ public static function table(Table $table): Table
->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color) ->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color)
->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon) ->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon)
->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor) ->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor)
->description(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryReason) ->description(static fn (BaselineSnapshot $record): ?string => self::truthHeadline($record))
->wrap(),
TextColumn::make('coverage_summary')
->label('Coverage')
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
->wrap(), ->wrap(),
TextColumn::make('next_step') TextColumn::make('next_step')
->label('Next step') ->label('Next step')
@ -377,6 +381,12 @@ private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh =
: $presenter->forBaselineSnapshot($snapshot); : $presenter->forBaselineSnapshot($snapshot);
} }
private static function truthHeadline(BaselineSnapshot $record): ?string
{
return self::truthEnvelope($record)->operatorExplanation?->headline
?? self::compressedOutcome($record)->primaryReason;
}
private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);

View File

@ -3,16 +3,14 @@
namespace App\Filament\Resources\EntraGroupResource\Pages; namespace App\Filament\Resources\EntraGroupResource\Pages;
use App\Filament\Resources\EntraGroupResource; use App\Filament\Resources\EntraGroupResource;
use App\Jobs\EntraGroupSyncJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSyncService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -55,7 +53,7 @@ protected function getHeaderActions(): array
->label('Sync Groups') ->label('Sync Groups')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('primary') ->color('primary')
->action(function (): void { ->action(function (EntraGroupSyncService $syncService): void {
$user = auth()->user(); $user = auth()->user();
$tenant = EntraGroupResource::panelTenantContext(); $tenant = EntraGroupResource::panelTenantContext();
@ -63,52 +61,18 @@ protected function getHeaderActions(): array
return; return;
} }
$selectionKey = EntraGroupSelection::allGroupsV1(); $result = $syncService->startManualSync($tenant, $user);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
// --- Phase 3: Canonical Operation Run Start --- result: $result,
/** @var OperationRunService $opService */ blockedTitle: 'Directory groups sync blocked',
$opService = app(OperationRunService::class); runUrl: OperationRunLinks::view($result->run, $tenant),
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'entra_group_sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) { if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
} }
// ----------------------------------------------
dispatch(new EntraGroupSyncJob( $notification->send();
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
operationRun: $opRun
));
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
}) })
) )
->requireCapability(Capabilities::TENANT_SYNC) ->requireCapability(Capabilities::TENANT_SYNC)

View File

@ -691,9 +691,12 @@ private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = fa
private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh) return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh)
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh)); ?? $truth->toArray(static::compressedOutcome($record, $fresh));
} }
private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -177,12 +177,11 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null), ->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
TextEntry::make('owner_user_id_leading') TextEntry::make('finding_responsibility_state_leading')
->label('Owner') ->label('Responsibility state')
->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'), ->badge()
TextEntry::make('assignee_user_id_leading') ->state(fn (Finding $record): string => $record->responsibilityStateLabel())
->label('Assignee') ->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'),
TextEntry::make('finding_primary_narrative') TextEntry::make('finding_primary_narrative')
->label('Current reading') ->label('Current reading')
->state(fn (Finding $record): string => static::primaryNarrative($record)) ->state(fn (Finding $record): string => static::primaryNarrative($record))
@ -207,6 +206,27 @@ public static function infolist(Schema $schema): Schema
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Responsibility')
->schema([
TextEntry::make('finding_responsibility_state')
->label('Responsibility state')
->badge()
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
->color(fn (Finding $record): string => static::responsibilityStateColor($record)),
TextEntry::make('owner_user_id_leading')
->label('Accountable owner')
->state(fn (Finding $record): string => static::accountableOwnerDisplay($record)),
TextEntry::make('assignee_user_id_leading')
->label('Active assignee')
->state(fn (Finding $record): string => static::activeAssigneeDisplay($record)),
TextEntry::make('finding_responsibility_summary')
->label('Current split')
->state(fn (Finding $record): string => static::responsibilitySummary($record))
->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Finding') Section::make('Finding')
->schema([ ->schema([
TextEntry::make('finding_type')->badge()->label('Type'), TextEntry::make('finding_type')->badge()->label('Type'),
@ -268,12 +288,6 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
TextEntry::make('sla_days')->label('SLA days')->placeholder('—'), TextEntry::make('sla_days')->label('SLA days')->placeholder('—'),
TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'), TextEntry::make('due_at')->label('Due at')->dateTime()->placeholder('—'),
TextEntry::make('owner_user_id')
->label('Owner')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->ownerUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('assignee_user_id')
->label('Assignee')
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->assigneeUser?->name ?? ($state ? 'User #'.$state : '—')),
TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'), TextEntry::make('triaged_at')->label('Triaged at')->dateTime()->placeholder('—'),
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'), TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'), TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
@ -722,7 +736,13 @@ public static function table(Table $table): Table
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity)) ->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
->placeholder('—') ->placeholder('—')
->description(fn (Finding $record): ?string => static::governanceWarning($record)), ->description(fn (Finding $record): ?string => static::governanceListDescription($record)),
Tables\Columns\TextColumn::make('responsibility_state')
->label('Responsibility')
->badge()
->state(fn (Finding $record): string => $record->responsibilityStateLabel())
->color(fn (Finding $record): string => static::responsibilityStateColor($record))
->description(fn (Finding $record): string => static::responsibilitySummary($record)),
Tables\Columns\TextColumn::make('evidence_fidelity') Tables\Columns\TextColumn::make('evidence_fidelity')
->label('Fidelity') ->label('Fidelity')
->badge() ->badge()
@ -745,10 +765,12 @@ public static function table(Table $table): Table
->sortable() ->sortable()
->placeholder('—') ->placeholder('—')
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)), ->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
Tables\Columns\TextColumn::make('ownerUser.name')
->label('Accountable owner')
->placeholder('—'),
Tables\Columns\TextColumn::make('assigneeUser.name') Tables\Columns\TextColumn::make('assigneeUser.name')
->label('Assignee') ->label('Active assignee')
->placeholder('—') ->placeholder('—'),
->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'),
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
@ -770,7 +792,7 @@ public static function table(Table $table): Table
Finding::SEVERITY_CRITICAL, Finding::SEVERITY_CRITICAL,
])), ])),
Tables\Filters\Filter::make('my_assigned') Tables\Filters\Filter::make('my_assigned')
->label('My assigned') ->label('My assigned work')
->query(function (Builder $query): Builder { ->query(function (Builder $query): Builder {
$userId = auth()->id(); $userId = auth()->id();
@ -780,6 +802,17 @@ public static function table(Table $table): Table
return $query->where('assignee_user_id', (int) $userId); return $query->where('assignee_user_id', (int) $userId);
}), }),
Tables\Filters\Filter::make('my_accountability')
->label('My accountability')
->query(function (Builder $query): Builder {
$userId = auth()->id();
if (! is_numeric($userId)) {
return $query->whereRaw('1 = 0');
}
return $query->where('owner_user_id', (int) $userId);
}),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(FilterOptionCatalog::findingStatuses()) ->options(FilterOptionCatalog::findingStatuses())
->label('Status'), ->label('Status'),
@ -966,13 +999,15 @@ public static function table(Table $table): Table
->requiresConfirmation() ->requiresConfirmation()
->form([ ->form([
Select::make('assignee_user_id') Select::make('assignee_user_id')
->label('Assignee') ->label('Active assignee')
->placeholder('Unassigned') ->placeholder('Unassigned')
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Accountable owner')
->placeholder('Unassigned') ->placeholder('Unassigned')
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
]) ])
@ -990,6 +1025,7 @@ public static function table(Table $table): Table
$assignedCount = 0; $assignedCount = 0;
$skippedCount = 0; $skippedCount = 0;
$failedCount = 0; $failedCount = 0;
$classificationCounts = [];
foreach ($records as $record) { foreach ($records as $record) {
if (! $record instanceof Finding) { if (! $record instanceof Finding) {
@ -1012,14 +1048,25 @@ public static function table(Table $table): Table
try { try {
$record = static::resolveProtectedFindingRecordOrFail($record); $record = static::resolveProtectedFindingRecordOrFail($record);
$classification = $workflow->responsibilityChangeClassification(
beforeOwnerUserId: is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null,
beforeAssigneeUserId: is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId); $workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$assignedCount++; $assignedCount++;
$classificationCounts[$classification ?? 'unchanged'] = ($classificationCounts[$classification ?? 'unchanged'] ?? 0) + 1;
} catch (Throwable) { } catch (Throwable) {
$failedCount++; $failedCount++;
} }
} }
$body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.'; $body = "Updated {$assignedCount} finding".($assignedCount === 1 ? '' : 's').'.';
$classificationSummary = static::bulkResponsibilityClassificationSummary($classificationCounts);
if ($classificationSummary !== null) {
$body .= ' '.$classificationSummary;
}
if ($skippedCount > 0) { if ($skippedCount > 0) {
$body .= " Skipped {$skippedCount}."; $body .= " Skipped {$skippedCount}.";
} }
@ -1373,28 +1420,20 @@ public static function assignAction(): Actions\Action
]) ])
->form([ ->form([
Select::make('assignee_user_id') Select::make('assignee_user_id')
->label('Assignee') ->label('Active assignee')
->placeholder('Unassigned') ->placeholder('Unassigned')
->helperText('Assign the person currently expected to perform or coordinate the remediation work.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Accountable owner')
->placeholder('Unassigned') ->placeholder('Unassigned')
->helperText('Assign the person accountable for ensuring the finding reaches a governed outcome.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
]) ])
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void { ->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runResponsibilityMutation($record, $data, $workflow);
record: $record,
successTitle: 'Finding assignment updated',
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->assign(
$finding,
$tenant,
$user,
is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null,
is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null,
),
);
}) })
) )
->preserveVisibility() ->preserveVisibility()
@ -1488,8 +1527,9 @@ public static function requestExceptionAction(): Actions\Action
->requiresConfirmation() ->requiresConfirmation()
->form([ ->form([
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Exception owner')
->required() ->required()
->helperText('Owns the exception record, not the finding outcome.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
Textarea::make('request_reason') Textarea::make('request_reason')
@ -1556,8 +1596,9 @@ public static function renewExceptionAction(): Actions\Action
->modalDescription($rule->modalDescription) ->modalDescription($rule->modalDescription)
->form([ ->form([
Select::make('owner_user_id') Select::make('owner_user_id')
->label('Owner') ->label('Exception owner')
->required() ->required()
->helperText('Owns the exception record, not the finding outcome.')
->options(fn (): array => static::tenantMemberOptions()) ->options(fn (): array => static::tenantMemberOptions())
->searchable(), ->searchable(),
Textarea::make('request_reason') Textarea::make('request_reason')
@ -1727,6 +1768,76 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
->send(); ->send();
} }
/**
* @param array<string, mixed> $data
*/
private static function runResponsibilityMutation(Finding $record, array $data, FindingWorkflowService $workflow): void
{
$pageRecord = $record;
$record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return;
}
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
Notification::make()
->title('Finding belongs to a different tenant')
->danger()
->send();
return;
}
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
Notification::make()
->title('Finding belongs to a different workspace')
->danger()
->send();
return;
}
$beforeOwnerUserId = is_numeric($record->owner_user_id) ? (int) $record->owner_user_id : null;
$beforeAssigneeUserId = is_numeric($record->assignee_user_id) ? (int) $record->assignee_user_id : null;
$assigneeUserId = is_numeric($data['assignee_user_id'] ?? null) ? (int) $data['assignee_user_id'] : null;
$ownerUserId = is_numeric($data['owner_user_id'] ?? null) ? (int) $data['owner_user_id'] : null;
try {
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$pageRecord->refresh();
} catch (InvalidArgumentException $e) {
Notification::make()
->title('Responsibility update failed')
->body($e->getMessage())
->danger()
->send();
return;
}
$classification = $workflow->responsibilityChangeClassification(
beforeOwnerUserId: $beforeOwnerUserId,
beforeAssigneeUserId: $beforeAssigneeUserId,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
Notification::make()
->title($classification === null ? 'Finding responsibility unchanged' : 'Finding responsibility updated')
->body($workflow->responsibilityChangeSummary(
beforeOwnerUserId: $beforeOwnerUserId,
beforeAssigneeUserId: $beforeAssigneeUserId,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
))
->success()
->send();
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
@ -1754,6 +1865,7 @@ private static function runExceptionRequestMutation(Finding $record, array $data
Notification::make() Notification::make()
->title('Exception request submitted') ->title('Exception request submitted')
->body('Exception ownership stays separate from the finding owner.')
->success() ->success()
->actions([ ->actions([
Actions\Action::make('view_exception') Actions\Action::make('view_exception')
@ -1789,6 +1901,7 @@ private static function runExceptionRenewalMutation(Finding $record, array $data
Notification::make() Notification::make()
->title('Renewal request submitted') ->title('Renewal request submitted')
->body('Exception ownership stays separate from the finding owner.')
->success() ->success()
->actions([ ->actions([
Actions\Action::make('view_exception') Actions\Action::make('view_exception')
@ -1913,6 +2026,87 @@ private static function findingExceptionViewUrl(\App\Models\FindingException $ex
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant); return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
} }
/**
* @param array<string, int> $classificationCounts
*/
private static function bulkResponsibilityClassificationSummary(array $classificationCounts): ?string
{
$parts = [];
foreach ($classificationCounts as $classification => $count) {
$parts[] = static::responsibilityClassificationLabel($classification).': '.$count;
}
if ($parts === []) {
return null;
}
return implode('. ', $parts).'.';
}
private static function responsibilityClassificationLabel(string $classification): string
{
return match ($classification) {
'owner_only' => 'Owner only',
'assignee_only' => 'Assignee only',
'owner_and_assignee' => 'Owner and assignee',
'clear_owner' => 'Cleared owner',
'clear_assignee' => 'Cleared assignee',
default => 'Unchanged',
};
}
private static function responsibilityStateColor(Finding $finding): string
{
return match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'danger',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'warning',
default => 'success',
};
}
private static function accountableOwnerDisplay(Finding $finding): string
{
return $finding->ownerUser?->name ?? 'Unassigned';
}
private static function activeAssigneeDisplay(Finding $finding): string
{
return $finding->assigneeUser?->name ?? 'Unassigned';
}
private static function responsibilitySummary(Finding $finding): string
{
$ownerName = $finding->ownerUser?->name;
$assigneeName = $finding->assigneeUser?->name;
return match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => $assigneeName !== null
? "No accountable owner is set. {$assigneeName} is currently carrying the active remediation work."
: 'No accountable owner or active assignee is set.',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => "{$ownerName} owns the outcome, but active remediation is still unassigned.",
default => $ownerName === $assigneeName
? "{$ownerName} owns the outcome and is also the active assignee."
: "{$ownerName} owns the outcome. {$assigneeName} is the active assignee.",
};
}
private static function governanceListDescription(Finding $finding): ?string
{
$parts = array_values(array_filter([
static::governanceWarning($finding),
static::resolvedFindingException($finding)?->owner?->name !== null
? 'Exception owner: '.static::resolvedFindingException($finding)?->owner?->name
: null,
]));
if ($parts === []) {
return null;
}
return implode(' ', $parts);
}
private static function governanceWarning(Finding $finding): ?string private static function governanceWarning(Finding $finding): ?string
{ {
return app(FindingRiskGovernanceResolver::class) return app(FindingRiskGovernanceResolver::class)

View File

@ -316,7 +316,13 @@ public static function getEloquentQuery(): Builder
public static function resolveScopedRecordOrFail(int|string $key): Model public static function resolveScopedRecordOrFail(int|string $key): Model
{ {
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun')); $tenant = static::resolveTenantContextForCurrentPanelOrFail();
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->with('lastSeenRun'),
$tenant,
);
} }
public static function getPages(): array public static function getPages(): array

View File

@ -2,14 +2,30 @@
namespace App\Filament\Resources\InventoryItemResource\Pages; namespace App\Filament\Resources\InventoryItemResource\Pages;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord class ViewInventoryItem extends ViewRecord
{ {
use ResolvesPanelTenantContext;
protected static string $resource = InventoryItemResource::class; protected static string $resource = InventoryItemResource::class;
public function mount(int|string $record): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof Tenant) {
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
}
parent::mount($record);
}
protected function resolveRecord(int|string $key): Model protected function resolveRecord(int|string $key): Model
{ {
return InventoryItemResource::resolveScopedRecordOrFail($key); return InventoryItemResource::resolveScopedRecordOrFail($key);

View File

@ -280,16 +280,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null; : null;
$artifactTruth = static::artifactTruthEnvelope($record); $artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$diagnosticSummary = OperationUxPresenter::governanceDiagnosticSummary($record);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$decisionNextStep = $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? [
'text' => $diagnosticSummary->nextActionText,
'source' => $diagnosticSummary->nextActionCategory,
'secondaryGuidance' => $primaryNextStep['secondaryGuidance'],
]
: $primaryNextStep;
$restoreContinuation = static::restoreContinuation($record); $restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups( $supportingGroups = static::supportingGroups(
record: $record, record: $record,
factory: $factory, factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle, referencedTenantLifecycle: $referencedTenantLifecycle,
diagnosticSummary: $diagnosticSummary,
operatorExplanation: $operatorExplanation, operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope, reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep, primaryNextStep: $decisionNextStep,
); );
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context') $builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
@ -307,49 +316,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.', descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
)) ))
->decisionZone($factory->decisionZone( ->decisionZone($factory->decisionZone(
facts: array_values(array_filter([ facts: static::decisionFacts(
$factory->keyFact( factory: $factory,
'Execution state', record: $record,
$statusSpec->label, statusSpec: $statusSpec,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor), outcomeSpec: $outcomeSpec,
), artifactTruth: $artifactTruth,
$factory->keyFact( operatorExplanation: $operatorExplanation,
'Outcome', restoreContinuation: $restoreContinuation,
$outcomeSpec->label, diagnosticSummary: $diagnosticSummary,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
])),
primaryNextStep: $factory->primaryNextStep(
$primaryNextStep['text'],
$primaryNextStep['source'],
$primaryNextStep['secondaryGuidance'],
), ),
description: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.', primaryNextStep: $factory->primaryNextStep(
$decisionNextStep['text'],
$decisionNextStep['source'],
$decisionNextStep['secondaryGuidance'],
'Primary next step',
),
description: $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? 'Start here to see what happened, how reliable the resulting artifact is, what was affected, and the one next step.'
: 'Start here to see how the operation ended, whether the result is trustworthy enough to use, and the one primary next step.',
compactCounts: $summaryLine !== null compactCounts: $summaryLine !== null
? $factory->countPresentation(summaryLine: $summaryLine) ? $factory->countPresentation(summaryLine: $summaryLine)
: null, : null,
@ -550,6 +535,7 @@ private static function supportingGroups(
OperationRun $record, OperationRun $record,
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle, ?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope, ?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep, array $primaryNextStep,
@ -559,6 +545,21 @@ private static function supportingGroups(
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope); $reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([ $guidanceItems = array_values(array_filter([
...array_map(
static fn (array $fact): array => $factory->keyFact(
(string) ($fact['label'] ?? 'Summary detail'),
(string) ($fact['value'] ?? '—'),
is_string($fact['hint'] ?? null) ? $fact['hint'] : null,
tone: match ($fact['emphasis'] ?? null) {
'blocked' => 'danger',
'caution' => 'warning',
default => null,
},
),
$diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary
? array_values(array_filter($diagnosticSummary->secondaryFacts, 'is_array'))
: [],
),
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null $operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement) ? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
: null, : null,
@ -811,6 +812,8 @@ private static function guidanceLabel(string $source): string
private static function artifactTruthFact( private static function artifactTruthFact(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ArtifactTruthEnvelope $artifactTruth, ?ArtifactTruthEnvelope $artifactTruth,
?string $hintOverride = null,
bool $preferOverride = false,
): ?array { ): ?array {
if (! $artifactTruth instanceof ArtifactTruthEnvelope) { if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
return null; return null;
@ -823,19 +826,138 @@ private static function artifactTruthFact(
$badge = $outcome->primaryBadge; $badge = $outcome->primaryBadge;
return $factory->keyFact( return $factory->keyFact(
'Outcome', 'Artifact impact',
$outcome->primaryLabel, $outcome->primaryLabel,
$outcome->primaryReason, $preferOverride ? $hintOverride : ($hintOverride ?? $outcome->primaryReason),
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor), $factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
); );
} }
/**
* @return list<array<string, mixed>>
*/
private static function decisionFacts(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
OperationRun $record,
\App\Support\Badges\BadgeSpec $statusSpec,
\App\Support\Badges\BadgeSpec $outcomeSpec,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
mixed $restoreContinuation,
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
): array {
if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) {
return array_values(array_filter([
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$outcomeSpec->label,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact($factory, $artifactTruth),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result meaning',
$operatorExplanation->evaluationResultLabel(),
$operatorExplanation->headline,
)
: null,
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$artifactTruth?->primaryExplanation,
),
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
]));
}
$facts = [
$factory->keyFact(
'Execution state',
$statusSpec->label,
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
),
$factory->keyFact(
'Outcome',
$diagnosticSummary->executionOutcomeLabel,
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
),
static::artifactTruthFact(
$factory,
$artifactTruth,
static::detailHintUnlessDuplicate(
$diagnosticSummary->headline,
$artifactTruth?->primaryExplanation,
$diagnosticSummary->primaryReason,
),
true,
),
$factory->keyFact(
'Dominant cause',
$diagnosticSummary->dominantCause['label'],
$diagnosticSummary->primaryReason,
tone: in_array($diagnosticSummary->nextActionCategory, ['refresh_prerequisite_data', 'review_scope_or_ambiguous_matches'], true)
? 'warning'
: (in_array($diagnosticSummary->nextActionCategory, ['retry_later', 'no_further_action'], true) ? null : 'danger'),
),
$operatorExplanation instanceof OperatorExplanationPattern
? $factory->keyFact(
'Result trust',
$operatorExplanation->trustworthinessLabel(),
static::detailHintUnlessDuplicate(
$operatorExplanation->reliabilityStatement,
$diagnosticSummary->primaryReason,
),
tone: match ($operatorExplanation->trustworthinessLevel->value) {
'unusable' => 'danger',
'diagnostic_only', 'limited_confidence' => 'warning',
default => 'success',
},
)
: null,
is_array($restoreContinuation)
? $factory->keyFact(
'Restore continuation',
(string) ($restoreContinuation['badge_label'] ?? 'Restore detail'),
(string) ($restoreContinuation['summary'] ?? 'Restore continuation detail is unavailable.'),
)
: null,
];
if (is_array($diagnosticSummary->affectedScaleCue)) {
$source = str_replace('_', ' ', (string) ($diagnosticSummary->affectedScaleCue['source'] ?? 'recorded detail'));
$facts[] = $factory->keyFact(
(string) ($diagnosticSummary->affectedScaleCue['label'] ?? 'Affected scale'),
(string) ($diagnosticSummary->affectedScaleCue['value'] ?? 'Recorded detail is available.'),
'Backed by '.$source.'.',
);
}
return array_values(array_filter($facts));
}
private static function decisionAttentionNote(OperationRun $record): ?string private static function decisionAttentionNote(OperationRun $record): ?string
{ {
return OperationUxPresenter::decisionAttentionNote($record); return OperationUxPresenter::decisionAttentionNote($record);
} }
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string private static function detailHintUnlessDuplicate(?string $hint, ?string ...$duplicates): ?string
{ {
$normalizedHint = static::normalizeDetailText($hint); $normalizedHint = static::normalizeDetailText($hint);
@ -843,8 +965,10 @@ private static function detailHintUnlessDuplicate(?string $hint, ?string $duplic
return null; return null;
} }
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) { foreach ($duplicates as $duplicate) {
return null; if ($normalizedHint === static::normalizeDetailText($duplicate)) {
return null;
}
} }
return trim($hint ?? ''); return trim($hint ?? '');

View File

@ -21,6 +21,7 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -1357,20 +1358,23 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
initiator: $user, initiator: $user,
); );
$runUrl = OperationRunLinks::view($result->run, $tenant);
$extraActions = $result->status === 'started'
? []
: [
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
];
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Connection check blocked',
runUrl: $runUrl,
extraActions: $extraActions,
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
Notification::make() $notification->send();
->title('Scope busy')
->body('Another provider operation is already running for this connection.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return; return;
} }
@ -1378,50 +1382,20 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); $notification->send();
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Connection check blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
Actions\Action::make('manage_connections')
->label('Manage Provider Connections')
->url(static::getUrl('index', tenant: $tenant)),
])
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
} }
/** /**
@ -1452,17 +1426,14 @@ private static function handleProviderOperationAction(
initiator: $user, initiator: $user,
); );
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: $blockedTitle,
runUrl: OperationRunLinks::view($result->run, $tenant),
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
Notification::make() $notification->send();
->title('Scope is busy')
->body('Another provider operation is already running for this connection.')
->danger()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
@ -1470,44 +1441,20 @@ private static function handleProviderOperationAction(
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification'); $notification->send();
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title($blockedTitle)
->body(implode("\n", $bodyLines))
->warning()
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
} }
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder

View File

@ -14,6 +14,7 @@
use App\Models\BackupItem; use App\Models\BackupItem;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\EntraGroup; use App\Models\EntraGroup;
use App\Models\OperationRun;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
@ -26,6 +27,8 @@
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver; use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
@ -35,6 +38,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
@ -1917,6 +1921,53 @@ public static function createRestoreRun(array $data): RestoreRun
->executionSafetySnapshot($tenant, $user, $data) ->executionSafetySnapshot($tenant, $user, $data)
->toArray(); ->toArray();
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if (! in_array($result->status, ['started', 'deduped'], true)) {
throw new \Filament\Support\Exceptions\Halt;
}
if (! $restoreRun instanceof RestoreRun) {
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
}
return $restoreRun;
}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, mixed> $preview
* @param array<string, mixed> $metadata
* @param array<string, mixed> $groupMapping
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
*/
private static function startQueuedRestoreExecution(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
array $preview,
array $metadata,
array $groupMapping,
?string $actorEmail,
?string $actorName,
): array {
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( $idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(), tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(), backupSetId: (int) $backupSet->getKey(),
@ -1924,34 +1975,27 @@ public static function createRestoreRun(array $data): RestoreRun
groupMapping: $groupMapping, groupMapping: $groupMapping,
); );
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); $initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null;
if ($existing) { $queuedRestoreRun = null;
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') $dispatcher = function (OperationRun $run) use (
->body('Reusing the active restore run.'); $tenant,
$backupSet,
if ($existingOpRun) { $selectedItemIds,
$toast->actions([ $preview,
Actions\Action::make('view_run') $metadata,
->label('Open operation') $groupMapping,
->url(OperationRunLinks::view($existingOpRun, $tenant)), $actorEmail,
]); $actorName,
} $idempotencyKey,
&$queuedRestoreRun,
$toast->send(); ): void {
$queuedRestoreRun = RestoreRun::create([
return $existing;
}
try {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'operation_run_id' => $run->getKey(),
'requested_by' => $actorEmail, 'requested_by' => $actorEmail,
'is_dry_run' => false, 'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value, 'status' => RestoreRunStatus::Queued->value,
@ -1961,83 +2005,114 @@ public static function createRestoreRun(array $data): RestoreRun
'metadata' => $metadata, 'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) { $context = is_array($run->context) ? $run->context : [];
$existingOpRunId = (int) ($existing->operation_run_id ?? 0); $context['restore_run_id'] = (int) $queuedRestoreRun->getKey();
$existingOpRun = $existingOpRunId > 0 $run->forceFill(['context' => $context])->save();
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute') app(AuditLogger::class)->log(
->body('Reusing the active restore run.'); tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $queuedRestoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $queuedRestoreRun->id,
status: 'success',
);
if ($existingOpRun) { $providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
$toast->actions([ ? (int) $context['provider_connection_id']
Actions\Action::make('view_run') : null;
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send(); ExecuteRestoreRunJob::dispatch(
restoreRunId: (int) $queuedRestoreRun->getKey(),
actorEmail: $actorEmail,
actorName: $actorName,
operationRun: $run,
providerConnectionId: $providerConnectionId,
)->afterCommit();
};
return $existing; if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'restore.execute',
dispatcher: $dispatcher,
initiator: $initiator,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
);
} else {
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: 'restore.execute',
identityInputs: [
'idempotency_key' => $idempotencyKey,
],
context: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
],
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
$dispatcher($run);
$result = ProviderOperationStartResult::started($run, true);
} else {
$result = ProviderOperationStartResult::deduped($run);
}
}
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
if (is_numeric($restoreRunId)) {
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
} }
throw $exception; $queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
(int) $tenant->getKey(),
$idempotencyKey,
);
} }
app(AuditLogger::class)->log( return [$result, $queuedRestoreRun?->refresh()];
tenant: $tenant, }
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success',
);
/** @var OperationRunService $runs */ /**
$runs = app(OperationRunService::class); * @param array<int>|null $selectedItemIds
$initiator = auth()->user(); */
$initiator = $initiator instanceof \App\Models\User ? $initiator : null; private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
{
$query = $backupSet->items()->select(['id', 'policy_type']);
$opRun = $runs->ensureRun( if (is_array($selectedItemIds) && $selectedItemIds !== []) {
tenant: $tenant, $query->whereIn('id', $selectedItemIds);
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
} }
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun); return $query->get()->contains(function (BackupItem $item): bool {
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
OperationUxPresenter::queuedToast('restore.execute') return $restoreMode !== 'preview-only';
->actions([ });
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return $restoreRun->refresh();
} }
/** /**
@ -2452,122 +2527,34 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'rerun_of_restore_run_id' => $record->id, 'rerun_of_restore_run_id' => $record->id,
]; ];
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey( $metadata['rerun_of_restore_run_id'] = $record->id;
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey); [$result, $newRun] = static::startQueuedRestoreExecution(
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
try {
$newRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
} catch (QueryException $exception) {
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
if ($existing) {
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
$existingOpRun = $existingOpRunId > 0
? \App\Models\OperationRun::query()->find($existingOpRunId)
: null;
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
->body('Reusing the active restore run.');
if ($existingOpRun) {
$toast->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($existingOpRun, $tenant)),
]);
}
$toast->send();
return;
}
throw $exception;
}
$auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'restore.queued', backupSet: $backupSet,
context: [ selectedItemIds: $selectedItemIds,
'metadata' => [ preview: $preview,
'restore_run_id' => $newRun->id, metadata: $metadata,
'backup_set_id' => $backupSet->id, groupMapping: $groupMapping,
'rerun_of_restore_run_id' => $record->id,
],
],
actorEmail: $actorEmail, actorEmail: $actorEmail,
actorName: $actorName, actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $newRun->id,
status: 'success',
); );
/** @var OperationRunService $runs */ if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
$runs = app(OperationRunService::class); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
$initiator = auth()->user();
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
$opRun = $runs->ensureRun(
tenant: $tenant,
type: 'restore.execute',
inputs: [
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
$newRun->update(['operation_run_id' => $opRun->getKey()]);
} }
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun); app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
return;
}
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -2585,15 +2572,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
actorName: $actorName, actorName: $actorName,
); );
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->actions([
Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return; return;
} }

View File

@ -399,9 +399,12 @@ private static function truthEnvelope(ReviewPack $record, bool $fresh = false):
private static function truthState(ReviewPack $record, bool $fresh = false): array private static function truthState(ReviewPack $record, bool $fresh = false): array
{ {
$presenter = app(ArtifactTruthPresenter::class); $presenter = app(ArtifactTruthPresenter::class);
$truth = $fresh
? static::truthEnvelope($record, true)
: static::truthEnvelope($record);
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh) return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh)); ?? $truth->toArray(static::compressedOutcome($record, $fresh));
} }
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome

View File

@ -42,6 +42,7 @@
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
@ -513,20 +514,16 @@ private static function handleVerifyConfigurationAction(
); );
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
Notification::make() $notification->send();
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
@ -534,68 +531,20 @@ private static function handleVerifyConfigurationAction(
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$actions = [ $notification->send();
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Actions\Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $result->run->type) $notification->send();
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
} }
private static function userCanManageAnyTenant(User $user): bool private static function userCanManageAnyTenant(User $user): bool
@ -3319,29 +3268,14 @@ public static function syncRoleDefinitionsAction(): Actions\Action
abort(403); abort(403);
} }
$opRun = $syncService->startManualSync($record, $user); $result = $syncService->startManualSync($record, $user);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Role definitions sync blocked',
runUrl: OperationRunLinks::tenantlessView($result->run),
);
$runUrl = OperationRunLinks::tenantlessView($opRun); $notification->send();
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return;
}
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
->actions([
Actions\Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
}); });
} }
} }

View File

@ -17,6 +17,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -71,20 +72,16 @@ public function startVerification(StartVerification $verification): void
); );
$runUrl = OperationRunLinks::tenantlessView($result->run); $runUrl = OperationRunLinks::tenantlessView($result->run);
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
result: $result,
blockedTitle: 'Verification blocked',
runUrl: $runUrl,
);
if ($result->status === 'scope_busy') { if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
Notification::make() $notification->send();
->title('Another operation is already running')
->body('Please wait for the active operation to finish.')
->warning()
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
@ -92,72 +89,20 @@ public function startVerification(StartVerification $verification): void
if ($result->status === 'deduped') { if ($result->status === 'deduped') {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type) $notification->send();
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
return; return;
} }
if ($result->status === 'blocked') { if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null) $notification->send();
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
Notification::make()
->title('Verification blocked')
->body(implode("\n", $bodyLines))
->warning()
->actions($actions)
->send();
return; return;
} }
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $result->run->type) $notification->send();
->actions([
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
])
->send();
} }
/** /**

View File

@ -121,6 +121,10 @@ public function handle(Request $request, Closure $next): Response
reason: 'single_membership', reason: 'single_membership',
); );
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($workspace, $user); return $this->redirectViaTenantBranching($workspace, $user);
} }
} }
@ -144,6 +148,10 @@ public function handle(Request $request, Closure $next): Response
reason: 'last_used', reason: 'last_used',
); );
if ($this->requestHasExplicitTenantContext($request)) {
return $next($request);
}
return $this->redirectViaTenantBranching($lastWorkspace, $user); return $this->redirectViaTenantBranching($lastWorkspace, $user);
} }
@ -203,6 +211,17 @@ private function isChooserFirstPath(string $path): bool
return in_array($path, ['/admin', '/admin/choose-tenant'], true); return in_array($path, ['/admin', '/admin/choose-tenant'], true);
} }
private function requestHasExplicitTenantContext(Request $request): bool
{
if (filled($request->query('tenant')) || filled($request->query('tenant_id'))) {
return true;
}
$route = $request->route();
return $route?->hasParameter('tenant') && filled($route->parameter('tenant'));
}
private function redirectToChooser(): Response private function redirectToChooser(): Response
{ {
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']); return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);

View File

@ -31,6 +31,10 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
/** /**

View File

@ -31,6 +31,7 @@ public function __construct(
public string $selectionKey, public string $selectionKey,
public ?string $slotKey = null, public ?string $slotKey = null,
public ?int $runId = null, public ?int $runId = null,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null ?OperationRun $operationRun = null
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -74,7 +75,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
$result = $syncService->sync($tenant, $this->selectionKey); $result = $syncService->sync($tenant, $this->selectionKey, $this->providerConnectionId());
$terminalStatus = 'succeeded'; $terminalStatus = 'succeeded';
@ -133,4 +134,16 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -36,6 +36,7 @@ public function __construct(
public ?string $actorEmail = null, public ?string $actorEmail = null,
public ?string $actorName = null, public ?string $actorName = null,
?OperationRun $operationRun = null, ?OperationRun $operationRun = null,
public ?int $providerConnectionId = null,
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
} }
@ -160,12 +161,15 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
); );
try { try {
$providerConnectionId = $this->providerConnectionId();
$restoreService->executeForRun( $restoreService->executeForRun(
restoreRun: $restoreRun, restoreRun: $restoreRun,
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, backupSet: $backupSet,
actorEmail: $this->actorEmail, actorEmail: $this->actorEmail,
actorName: $this->actorName, actorName: $this->actorName,
providerConnectionId: $providerConnectionId,
); );
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh()); app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
@ -207,4 +211,16 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
throw $throwable; throw $throwable;
} }
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -22,6 +22,10 @@ class RemovePoliciesFromBackupSetJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 240;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
/** /**

View File

@ -31,6 +31,7 @@ class SyncRoleDefinitionsJob implements ShouldQueue
*/ */
public function __construct( public function __construct(
public int $tenantId, public int $tenantId,
public ?int $providerConnectionId = null,
?OperationRun $operationRun = null, ?OperationRun $operationRun = null,
) { ) {
$this->operationRun = $operationRun; $this->operationRun = $operationRun;
@ -69,7 +70,7 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
$result = $syncService->sync($tenant); $result = $syncService->sync($tenant, $this->providerConnectionId());
/** @var OperationRunService $opService */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -124,4 +125,16 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
resourceId: (string) $this->operationRun->getKey(), resourceId: (string) $this->operationRun->getKey(),
); );
} }
private function providerConnectionId(): ?int
{
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
return $this->providerConnectionId;
}
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
$providerConnectionId = $context['provider_connection_id'] ?? null;
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
}
} }

View File

@ -293,7 +293,7 @@ public function table(Table $table): Table
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->enqueueBulkOperation( $opRun = $opService->enqueueBulkOperation(
tenant: $tenant, tenant: $tenant,
type: 'backup_set.add_policies', type: 'backup_set.update',
targetScope: [ targetScope: [
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id), 'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
], ],

View File

@ -4,13 +4,16 @@
namespace App\Livewire; namespace App\Livewire;
use App\Filament\Resources\InventoryItemResource;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Enums\RelationshipType; use App\Support\Enums\RelationshipType;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
@ -235,7 +238,7 @@ private function resolveInventoryItem(): InventoryItem
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId); $inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
$tenant = $this->resolveCurrentTenant(); $tenant = $this->resolveCurrentTenant();
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) { if (! $this->canViewInventoryItem($inventoryItem, $tenant)) {
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
@ -246,6 +249,10 @@ private function resolveCurrentTenant(): Tenant
{ {
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
$tenant = app(WorkspaceContext::class)->rememberedTenant(request());
}
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
@ -253,6 +260,21 @@ private function resolveCurrentTenant(): Tenant
return $tenant; return $tenant;
} }
private function canViewInventoryItem(InventoryItem $inventoryItem, Tenant $tenant): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$capabilityResolver = app(CapabilityResolver::class);
return (int) $inventoryItem->tenant_id === (int) $tenant->getKey()
&& $capabilityResolver->isMember($user, $tenant)
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
private function normalizeRelationshipType(mixed $value): ?string private function normalizeRelationshipType(mixed $value): ?string
{ {
if (! is_string($value) || $value === '' || $value === 'all') { if (! is_string($value) || $value === '' || $value === 'all') {

View File

@ -41,6 +41,14 @@ private function assignmentReferences(): array
$tenant = rescue(fn () => Tenant::current(), null); $tenant = rescue(fn () => Tenant::current(), null);
if (! $tenant instanceof Tenant) {
$this->version->loadMissing('tenant');
$tenant = $this->version->tenant instanceof Tenant
? $this->version->tenant
: null;
}
$groupIds = []; $groupIds = [];
$sourceNames = []; $sourceNames = [];

View File

@ -47,6 +47,12 @@ class Finding extends Model
public const string STATUS_RISK_ACCEPTED = 'risk_accepted'; public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
public const string RESPONSIBILITY_STATE_ASSIGNED = 'assigned';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@ -246,6 +252,33 @@ public function resolvedSubjectDisplayName(): ?string
return $fallback !== '' ? $fallback : null; return $fallback !== '' ? $fallback : null;
} }
public function responsibilityState(): string
{
if ($this->owner_user_id === null) {
return self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
}
if ($this->assignee_user_id === null) {
return self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED;
}
return self::RESPONSIBILITY_STATE_ASSIGNED;
}
public function hasAccountabilityGap(): bool
{
return $this->responsibilityState() === self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY;
}
public function responsibilityStateLabel(): string
{
return match ($this->responsibilityState()) {
self::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'orphaned accountability',
self::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'owned but unassigned',
default => 'assigned',
};
}
public function scopeWithSubjectDisplayName(Builder $query): Builder public function scopeWithSubjectDisplayName(Builder $query): Builder
{ {
return $query->addSelect([ return $query->addSelect([

View File

@ -225,6 +225,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary', title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table', view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows], viewData: ['rows' => $rendered->summaryRows],
description: $rendered->fidelitySummary,
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'), emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
), ),
$factory->viewSection( $factory->viewSection(
@ -262,6 +263,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage', title: 'Coverage',
items: [ items: [
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge), $factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Fidelity mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount), $factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount), $factory->keyFact('Captured items', $capturedItemCount),
], ],

View File

@ -10,8 +10,10 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class EntraGroupSyncService class EntraGroupSyncService
@ -20,38 +22,38 @@ public function __construct(
private readonly GraphClientInterface $graph, private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {} ) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
{ {
$selectionKey = EntraGroupSelection::allGroupsV1(); $selectionKey = EntraGroupSelection::allGroupsV1();
/** @var OperationRunService $opService */ return $this->providerStarts->start(
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'entra_group_sync', connection: null,
identityInputs: ['selection_key' => $selectionKey], operationType: 'entra_group_sync',
context: [ dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id']
: null;
EntraGroupSyncJob::dispatch(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
providerConnectionId: $providerConnectionId,
operationRun: $run,
)->afterCommit();
},
initiator: $user,
extraContext: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'trigger' => 'manual', 'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_SYNC,
], ],
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new EntraGroupSyncJob(
tenantId: (int) $tenant->getKey(),
selectionKey: $selectionKey,
slotKey: null,
runId: null,
operationRun: $opRun,
));
return $opRun;
} }
/** /**
@ -67,7 +69,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
* error_summary:?string * error_summary:?string
* } * }
*/ */
public function sync(Tenant $tenant, string $selectionKey): array public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnectionId = null): array
{ {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
@ -105,7 +107,9 @@ public function sync(Tenant $tenant, string $selectionKey): array
$errorSummary = null; $errorSummary = null;
$errorCount = 0; $errorCount = 0;
$options = $this->graphOptionsResolver->resolveForTenant($tenant); $options = $providerConnectionId !== null
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query; $useQuery = $query;
$nextPath = $path; $nextPath = $path;

View File

@ -10,8 +10,10 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService class RoleDefinitionsSyncService
@ -20,36 +22,35 @@ public function __construct(
private readonly GraphClientInterface $graph, private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProviderOperationStartGate $providerStarts,
) {} ) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
{ {
$selectionKey = 'role_definitions_v1'; $selectionKey = 'role_definitions_v1';
/** @var OperationRunService $opService */ return $this->providerStarts->start(
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'directory_role_definitions.sync', connection: null,
identityInputs: ['selection_key' => $selectionKey], operationType: 'directory_role_definitions.sync',
context: [ dispatcher: function (OperationRun $run) use ($tenant): void {
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
? (int) $run->context['provider_connection_id']
: null;
SyncRoleDefinitionsJob::dispatch(
tenantId: (int) $tenant->getKey(),
providerConnectionId: $providerConnectionId,
operationRun: $run,
)->afterCommit();
},
initiator: $user,
extraContext: [
'selection_key' => $selectionKey, 'selection_key' => $selectionKey,
'trigger' => 'manual', 'trigger' => 'manual',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
initiator: $user,
); );
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new SyncRoleDefinitionsJob(
tenantId: (int) $tenant->getKey(),
operationRun: $opRun,
));
return $opRun;
} }
/** /**
@ -65,7 +66,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
* error_summary:?string * error_summary:?string
* } * }
*/ */
public function sync(Tenant $tenant): array public function sync(Tenant $tenant, ?int $providerConnectionId = null): array
{ {
$nowUtc = CarbonImmutable::now('UTC'); $nowUtc = CarbonImmutable::now('UTC');
@ -103,7 +104,9 @@ public function sync(Tenant $tenant): array
$errorSummary = null; $errorSummary = null;
$errorCount = 0; $errorCount = 0;
$options = $this->graphOptionsResolver->resolveForTenant($tenant); $options = $providerConnectionId !== null
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
: $this->graphOptionsResolver->resolveForTenant($tenant);
$useQuery = $query; $useQuery = $query;
$nextPath = $path; $nextPath = $path;

View File

@ -651,16 +651,18 @@ private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): v
private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int
{ {
$label = $this->fieldLabel($field);
if ($userId === null || $userId === '') { if ($userId === null || $userId === '') {
if ($required) { if ($required) {
throw new InvalidArgumentException(sprintf('%s is required.', $field)); throw new InvalidArgumentException(sprintf('%s is required.', $label));
} }
return null; return null;
} }
if (! is_numeric($userId) || (int) $userId <= 0) { if (! is_numeric($userId) || (int) $userId <= 0) {
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field)); throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $label));
} }
$resolvedUserId = (int) $userId; $resolvedUserId = (int) $userId;
@ -671,7 +673,7 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $
->exists(); ->exists();
if (! $isMember) { if (! $isMember) {
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field)); throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $label));
} }
return $resolvedUserId; return $resolvedUserId;
@ -679,18 +681,20 @@ private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $
private function validatedReason(mixed $reason, string $field): string private function validatedReason(mixed $reason, string $field): string
{ {
$label = $this->fieldLabel($field);
if (! is_string($reason)) { if (! is_string($reason)) {
throw new InvalidArgumentException(sprintf('%s is required.', $field)); throw new InvalidArgumentException(sprintf('%s is required.', $label));
} }
$resolved = trim($reason); $resolved = trim($reason);
if ($resolved === '') { if ($resolved === '') {
throw new InvalidArgumentException(sprintf('%s is required.', $field)); throw new InvalidArgumentException(sprintf('%s is required.', $label));
} }
if (mb_strlen($resolved) > 2000) { if (mb_strlen($resolved) > 2000) {
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field)); throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $label));
} }
return $resolved; return $resolved;
@ -698,10 +702,12 @@ private function validatedReason(mixed $reason, string $field): string
private function validatedDate(mixed $value, string $field): CarbonImmutable private function validatedDate(mixed $value, string $field): CarbonImmutable
{ {
$label = $this->fieldLabel($field);
try { try {
return CarbonImmutable::parse((string) $value); return CarbonImmutable::parse((string) $value);
} catch (\Throwable) { } catch (\Throwable) {
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field)); throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $label));
} }
} }
@ -710,7 +716,7 @@ private function validatedFutureDate(mixed $value, string $field): CarbonImmutab
$date = $this->validatedDate($value, $field); $date = $this->validatedDate($value, $field);
if ($date->lessThanOrEqualTo(CarbonImmutable::now())) { if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
throw new InvalidArgumentException(sprintf('%s must be in the future.', $field)); throw new InvalidArgumentException(sprintf('%s must be in the future.', $this->fieldLabel($field)));
} }
return $date; return $date;
@ -735,6 +741,21 @@ private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum,
return $expiresAt; return $expiresAt;
} }
private function fieldLabel(string $field): string
{
return match ($field) {
'owner_user_id' => 'Exception owner',
'request_reason' => 'Request reason',
'review_due_at' => 'Review due at',
'approval_reason' => 'Approval reason',
'rejection_reason' => 'Rejection reason',
'revocation_reason' => 'Revocation reason',
'effective_from' => 'Effective from',
'expires_at' => 'Expires at',
default => str_replace('_', ' ', $field),
};
}
/** /**
* @return list<array{ * @return list<array{
* source_type: string, * source_type: string,

View File

@ -223,14 +223,22 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.', Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
default => 'This finding is historical workflow context.', default => 'This finding is historical workflow context.',
}, },
default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.', default => match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
default => 'This finding is still active workflow work with accountable ownership and an active assignee.',
},
}; };
} }
public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
{ {
if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) { if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) {
return 'Review the overdue finding and update ownership or next workflow step.'; return match ($finding->responsibilityState()) {
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Review the overdue finding, set an accountable owner, and confirm the next workflow step.',
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'Review the overdue finding and assign the active remediation work or the next workflow step.',
default => 'Review the overdue finding and confirm the next workflow step.',
};
} }
if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') { if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') {
@ -249,11 +257,11 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.'; return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
} }
if ($finding->assignee_user_id === null || $finding->owner_user_id === null) { return match ($finding->responsibilityState()) {
return 'Assign an owner and next workflow step so follow-up does not stall.'; Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'Set an accountable owner so follow-up does not stall, even if remediation work is already assigned.',
} Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'An accountable owner is set. Assign the active remediation work or record the next workflow step.',
default => 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.',
return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.'; };
} }
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException

View File

@ -110,6 +110,19 @@ public function assign(
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id'); $this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id'); $this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
$changeClassification = $this->responsibilityChangeClassification(
beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
$changeSummary = $this->responsibilityChangeSummary(
beforeOwnerUserId: is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null,
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
afterOwnerUserId: $ownerUserId,
afterAssigneeUserId: $assigneeUserId,
);
return $this->mutateAndAudit( return $this->mutateAndAudit(
finding: $finding, finding: $finding,
tenant: $tenant, tenant: $tenant,
@ -119,6 +132,8 @@ public function assign(
'metadata' => [ 'metadata' => [
'assignee_user_id' => $assigneeUserId, 'assignee_user_id' => $assigneeUserId,
'owner_user_id' => $ownerUserId, 'owner_user_id' => $ownerUserId,
'responsibility_change_classification' => $changeClassification,
'responsibility_change_summary' => $changeSummary,
], ],
], ],
mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void { mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void {
@ -128,6 +143,55 @@ public function assign(
); );
} }
public function responsibilityChangeClassification(
?int $beforeOwnerUserId,
?int $beforeAssigneeUserId,
?int $afterOwnerUserId,
?int $afterAssigneeUserId,
): ?string {
$ownerChanged = $beforeOwnerUserId !== $afterOwnerUserId;
$assigneeChanged = $beforeAssigneeUserId !== $afterAssigneeUserId;
if ($ownerChanged && $assigneeChanged) {
return 'owner_and_assignee';
}
if ($ownerChanged) {
return $afterOwnerUserId === null
? 'clear_owner'
: 'owner_only';
}
if ($assigneeChanged) {
return $afterAssigneeUserId === null
? 'clear_assignee'
: 'assignee_only';
}
return null;
}
public function responsibilityChangeSummary(
?int $beforeOwnerUserId,
?int $beforeAssigneeUserId,
?int $afterOwnerUserId,
?int $afterAssigneeUserId,
): string {
return match ($this->responsibilityChangeClassification(
beforeOwnerUserId: $beforeOwnerUserId,
beforeAssigneeUserId: $beforeAssigneeUserId,
afterOwnerUserId: $afterOwnerUserId,
afterAssigneeUserId: $afterAssigneeUserId,
)) {
'owner_only' => 'Updated the accountable owner and kept the active assignee unchanged.',
'assignee_only' => 'Updated the active assignee and kept the accountable owner unchanged.',
'owner_and_assignee' => 'Updated the accountable owner and the active assignee.',
'clear_owner' => 'Cleared the accountable owner and kept the active assignee unchanged.',
'clear_assignee' => 'Cleared the active assignee and kept the accountable owner unchanged.',
default => 'No responsibility changes were needed.',
};
}
public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]); $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]);

View File

@ -236,6 +236,7 @@ public function executeForRun(
BackupSet $backupSet, BackupSet $backupSet,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
?int $providerConnectionId = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -266,6 +267,7 @@ public function executeForRun(
actorName: $actorName, actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [], groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun, existingRun: $restoreRun,
providerConnectionId: $providerConnectionId,
); );
} }
@ -286,6 +288,7 @@ public function execute(
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [], array $groupMapping = [],
?RestoreRun $existingRun = null, ?RestoreRun $existingRun = null,
?int $providerConnectionId = null,
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -297,7 +300,7 @@ public function execute(
$baseGraphOptions = []; $baseGraphOptions = [];
if (! $dryRun) { if (! $dryRun) {
$connection = $this->resolveProviderConnection($tenant); $connection = $this->resolveProviderConnection($tenant, $providerConnectionId);
$tenantIdentifier = (string) $connection->entra_tenant_id; $tenantIdentifier = (string) $connection->entra_tenant_id;
$baseGraphOptions = $this->providerGateway()->graphOptions($connection); $baseGraphOptions = $this->providerGateway()->graphOptions($connection);
} }
@ -2910,9 +2913,23 @@ private function buildScopeTagsForVersion(
]; ];
} }
private function resolveProviderConnection(Tenant $tenant): ProviderConnection private function resolveProviderConnection(Tenant $tenant, ?int $providerConnectionId = null): ProviderConnection
{ {
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft'); if ($providerConnectionId !== null) {
$connection = ProviderConnection::query()->find($providerConnectionId);
if (! $connection instanceof ProviderConnection) {
throw new RuntimeException(sprintf(
'[%s] %s',
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is not configured.',
));
}
$resolution = $this->providerConnections()->validateConnection($tenant, 'microsoft', $connection);
} else {
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
}
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) { if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
return $resolution->connection; return $resolution->connection;

View File

@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
{ {
$maskedId = static::mask($targetId); $maskedId = static::mask($targetId);
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null; $url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null;
return new self( return new self(
targetLabel: $label, targetLabel: $label,

View File

@ -2,7 +2,9 @@
namespace App\Services\Providers; namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Providers\ProviderReasonCodes;
final class MicrosoftGraphOptionsResolver final class MicrosoftGraphOptionsResolver
{ {
@ -28,4 +30,37 @@ public function resolveForTenant(Tenant $tenant, array $overrides = []): array
return $this->gateway->graphOptions($resolution->connection, $overrides); return $this->gateway->graphOptions($resolution->connection, $overrides);
} }
/**
* @return array<string, mixed>
*/
public function resolveForConnection(Tenant $tenant, int|ProviderConnection $connection, array $overrides = []): array
{
$providerConnection = $connection instanceof ProviderConnection
? $connection
: ProviderConnection::query()->find($connection);
if (! $providerConnection instanceof ProviderConnection) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'The selected provider connection could not be found.',
),
);
}
$resolution = $this->connections->validateConnection($tenant, 'microsoft', $providerConnection);
if (! $resolution->resolved || $resolution->connection === null) {
throw ProviderConfigurationRequiredException::forTenant(
tenant: $tenant,
provider: 'microsoft',
resolution: $resolution,
);
}
return $this->gateway->graphOptions($resolution->connection, $overrides);
}
} }

View File

@ -2,12 +2,13 @@
namespace App\Services\Providers; namespace App\Services\Providers;
use App\Support\Auth\Capabilities;
use InvalidArgumentException; use InvalidArgumentException;
final class ProviderOperationRegistry final class ProviderOperationRegistry
{ {
/** /**
* @return array<string, array{provider: string, module: string, label: string}> * @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
*/ */
public function all(): array public function all(): array
{ {
@ -16,16 +17,37 @@ public function all(): array
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'health_check', 'module' => 'health_check',
'label' => 'Provider connection check', 'label' => 'Provider connection check',
'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'inventory_sync' => [ 'inventory_sync' => [
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'inventory', 'module' => 'inventory',
'label' => 'Inventory sync', 'label' => 'Inventory sync',
'required_capability' => Capabilities::PROVIDER_RUN,
], ],
'compliance.snapshot' => [ 'compliance.snapshot' => [
'provider' => 'microsoft', 'provider' => 'microsoft',
'module' => 'compliance', 'module' => 'compliance',
'label' => 'Compliance snapshot', 'label' => 'Compliance snapshot',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'restore.execute' => [
'provider' => 'microsoft',
'module' => 'restore',
'label' => 'Restore execution',
'required_capability' => Capabilities::TENANT_MANAGE,
],
'entra_group_sync' => [
'provider' => 'microsoft',
'module' => 'directory_groups',
'label' => 'Directory groups sync',
'required_capability' => Capabilities::TENANT_SYNC,
],
'directory_role_definitions.sync' => [
'provider' => 'microsoft',
'module' => 'directory_role_definitions',
'label' => 'Role definitions sync',
'required_capability' => Capabilities::TENANT_MANAGE,
], ],
]; ];
} }
@ -36,7 +58,7 @@ public function isAllowed(string $operationType): bool
} }
/** /**
* @return array{provider: string, module: string, label: string} * @return array{provider: string, module: string, label: string, required_capability: string}
*/ */
public function get(string $operationType): array public function get(string $operationType): array
{ {

View File

@ -244,6 +244,15 @@ private function resolveRequiredCapability(string $operationType, array $extraCo
return trim((string) $extraContext['required_capability']); return trim((string) $extraContext['required_capability']);
} }
if ($this->registry->isAllowed($operationType)) {
$definition = $this->registry->get($operationType);
$requiredCapability = $definition['required_capability'] ?? null;
if (is_string($requiredCapability) && trim($requiredCapability) !== '') {
return trim($requiredCapability);
}
}
if ($this->registry->isAllowed($operationType)) { if ($this->registry->isAllowed($operationType)) {
return Capabilities::PROVIDER_RUN; return Capabilities::PROVIDER_RUN;
} }

View File

@ -86,6 +86,10 @@ public function handle(Request $request, Closure $next): Response
! $resolvedContext->hasTenant() ! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path) && $this->adminPathRequiresTenantSelection($path)
) { ) {
if ($this->requestHasExplicitTenantHint($request)) {
abort(404);
}
return redirect()->route('filament.admin.pages.choose-tenant'); return redirect()->route('filament.admin.pages.choose-tenant');
} }
@ -232,12 +236,21 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer; return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
} }
private function requestHasExplicitTenantHint(Request $request): bool
{
return filled($request->query('tenant')) || filled($request->query('tenant_id'));
}
private function adminPathRequiresTenantSelection(string $path): bool private function adminPathRequiresTenantSelection(string $path): bool
{ {
if (! str_starts_with($path, '/admin/')) { if (! str_starts_with($path, '/admin/')) {
return false; return false;
} }
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1; if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
} }
} }

View File

@ -119,7 +119,7 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$pageCategory = $this->pageCategory($request); $pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory); $routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request); $sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request); $workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request);
$workspaceSource = match (true) { $workspaceSource = match (true) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace', $workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
@ -185,6 +185,19 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
$recoveryReason ??= $queryHintTenant['reason']; $recoveryReason ??= $queryHintTenant['reason'];
if ($this->requiresStrictQueryTenantHintResolution($request)) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'invalid_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: 'abort_not_found',
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace); $tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
if ($tenant instanceof Tenant) { if ($tenant instanceof Tenant) {
@ -256,7 +269,7 @@ private function resolveValidatedFilamentTenant(
} }
$pageCategory ??= $this->pageCategory($request); $pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request); $workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request);
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) { if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
return $tenant; return $tenant;
@ -288,6 +301,18 @@ private function resolveValidatedRouteTenant(
return ['tenant' => $tenant, 'reason' => null]; return ['tenant' => $tenant, 'reason' => null];
} }
private function resolveWorkspaceForPageCategory(
?Tenant $tenant,
TenantPageCategory $pageCategory,
?Request $request = null,
): ?Workspace {
return match ($pageCategory) {
TenantPageCategory::TenantScopedEvidence => $this->workspaceContext->currentWorkspace($request),
TenantPageCategory::TenantBound => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
default => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
};
}
private function resolveValidatedQueryHintTenant( private function resolveValidatedQueryHintTenant(
?Request $request, ?Request $request,
Workspace $workspace, Workspace $workspace,
@ -349,6 +374,30 @@ private function resolveQueryTenantHint(?Request $request = null): ?Tenant
return null; return null;
} }
private function hasExplicitQueryTenantHint(?Request $request = null): bool
{
return filled($request?->query('tenant')) || filled($request?->query('tenant_id'));
}
private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool
{
if (! $this->hasExplicitQueryTenantHint($request)) {
return false;
}
$path = '/'.ltrim((string) $request?->path(), '/');
if (! str_starts_with($path, '/admin/')) {
return false;
}
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
return false;
}
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
}
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
{ {
if ($tenantIdentifier instanceof Tenant) { if ($tenantIdentifier instanceof Tenant) {

View File

@ -273,8 +273,7 @@ private static function operationAliases(): array
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),

View File

@ -175,7 +175,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
} }
} }
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) { if ($run->type === 'backup_set.update') {
$links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant); $links['Backup Sets'] = BackupSetResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$backupSetId = $context['backup_set_id'] ?? null; $backupSetId = $context['backup_set_id'] ?? null;

View File

@ -10,8 +10,7 @@ enum OperationRunType: string
case PolicySync = 'policy.sync'; case PolicySync = 'policy.sync';
case PolicySyncOne = 'policy.sync_one'; case PolicySyncOne = 'policy.sync_one';
case DirectoryGroupsSync = 'entra_group_sync'; case DirectoryGroupsSync = 'entra_group_sync';
case BackupSetAddPolicies = 'backup_set.add_policies'; case BackupSetUpdate = 'backup_set.update';
case BackupSetRemovePolicies = 'backup_set.remove_policies';
case BackupScheduleExecute = 'backup_schedule_run'; case BackupScheduleExecute = 'backup_schedule_run';
case BackupScheduleRetention = 'backup_schedule_retention'; case BackupScheduleRetention = 'backup_schedule_retention';
case BackupSchedulePurge = 'backup_schedule_purge'; case BackupSchedulePurge = 'backup_schedule_purge';
@ -36,6 +35,7 @@ public function canonicalCode(): string
self::InventorySync => 'inventory.sync', self::InventorySync => 'inventory.sync',
self::PolicySync, self::PolicySyncOne => 'policy.sync', self::PolicySync, self::PolicySyncOne => 'policy.sync',
self::DirectoryGroupsSync => 'directory.groups.sync', self::DirectoryGroupsSync => 'directory.groups.sync',
self::BackupSetUpdate => 'backup_set.update',
self::BackupScheduleExecute => 'backup.schedule.execute', self::BackupScheduleExecute => 'backup.schedule.execute',
self::BackupScheduleRetention => 'backup.schedule.retention', self::BackupScheduleRetention => 'backup.schedule.retention',
self::BackupSchedulePurge => 'backup.schedule.purge', self::BackupSchedulePurge => 'backup.schedule.purge',

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use InvalidArgumentException;
final readonly class GovernanceRunDiagnosticSummary
{
/**
* @param array{label: string, value: string, source: string, confidence?: string}|null $affectedScaleCue
* @param array{
* code: ?string,
* label: string,
* explanation: string
* } $dominantCause
* @param list<array{
* code: ?string,
* label: string,
* explanation: string
* }> $secondaryCauses
* @param list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }> $secondaryFacts
*/
public function __construct(
public string $headline,
public string $executionOutcomeLabel,
public string $artifactImpactLabel,
public string $primaryReason,
public ?array $affectedScaleCue,
public string $nextActionCategory,
public string $nextActionText,
public array $dominantCause,
public array $secondaryCauses = [],
public array $secondaryFacts = [],
public bool $diagnosticsAvailable = false,
) {
foreach ([
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
] as $field => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException("Governance run summaries require {$field}.");
}
}
if (trim((string) ($this->dominantCause['label'] ?? '')) === '' || trim((string) ($this->dominantCause['explanation'] ?? '')) === '') {
throw new InvalidArgumentException('Governance run summaries require a dominant cause label and explanation.');
}
}
/**
* @return array{
* headline: string,
* executionOutcomeLabel: string,
* artifactImpactLabel: string,
* primaryReason: string,
* affectedScaleCue: array{label: string, value: string, source: string, confidence?: string}|null,
* nextActionCategory: string,
* nextActionText: string,
* dominantCause: array{code: ?string, label: string, explanation: string},
* secondaryCauses: list<array{code: ?string, label: string, explanation: string}>,
* secondaryFacts: list<array{label: string, value: string, hint?: ?string, emphasis?: string}>,
* diagnosticsAvailable: bool
* }
*/
public function toArray(): array
{
return [
'headline' => $this->headline,
'executionOutcomeLabel' => $this->executionOutcomeLabel,
'artifactImpactLabel' => $this->artifactImpactLabel,
'primaryReason' => $this->primaryReason,
'affectedScaleCue' => $this->affectedScaleCue,
'nextActionCategory' => $this->nextActionCategory,
'nextActionText' => $this->nextActionText,
'dominantCause' => $this->dominantCause,
'secondaryCauses' => $this->secondaryCauses,
'secondaryFacts' => $this->secondaryFacts,
'diagnosticsAvailable' => $this->diagnosticsAvailable,
];
}
}

View File

@ -0,0 +1,913 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Models\OperationRun;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
final class GovernanceRunDiagnosticSummaryBuilder
{
public function __construct(
private readonly ArtifactTruthPresenter $artifactTruthPresenter,
private readonly ReasonPresenter $reasonPresenter,
) {}
public function build(
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth = null,
?OperatorExplanationPattern $operatorExplanation = null,
?ReasonResolutionEnvelope $reasonEnvelope = null,
): ?GovernanceRunDiagnosticSummary {
if (! $run->supportsOperatorExplanation()) {
return null;
}
$artifactTruth ??= $this->artifactTruthPresenter->forOperationRun($run);
$operatorExplanation ??= $artifactTruth?->operatorExplanation;
$reasonEnvelope ??= $this->reasonPresenter->forOperationRun($run, 'run_detail');
if (! $artifactTruth instanceof ArtifactTruthEnvelope && ! $operatorExplanation instanceof OperatorExplanationPattern) {
return null;
}
$canonicalType = OperationCatalog::canonicalCode((string) $run->type);
$context = is_array($run->context) ? $run->context : [];
$counts = SummaryCountsNormalizer::normalize(is_array($run->summary_counts) ? $run->summary_counts : []);
$causeCandidates = $this->rankCauseCandidates($canonicalType, $run, $artifactTruth, $operatorExplanation, $reasonEnvelope, $context);
$dominantCause = $causeCandidates[0] ?? $this->fallbackCause($artifactTruth, $operatorExplanation, $reasonEnvelope);
$secondaryCauses = array_values(array_slice($causeCandidates, 1));
$artifactImpactLabel = $this->artifactImpactLabel($artifactTruth, $operatorExplanation);
$headline = $this->headline($canonicalType, $run, $artifactTruth, $operatorExplanation, $dominantCause, $context, $counts);
$primaryReason = $this->primaryReason($dominantCause, $artifactTruth, $operatorExplanation, $reasonEnvelope);
$nextActionCategory = $this->nextActionCategory($canonicalType, $run, $reasonEnvelope, $operatorExplanation, $context);
$nextActionText = $this->nextActionText($artifactTruth, $operatorExplanation, $reasonEnvelope);
$affectedScaleCue = $this->affectedScaleCue($canonicalType, $run, $artifactTruth, $operatorExplanation, $context, $counts);
$secondaryFacts = $this->secondaryFacts($artifactTruth, $operatorExplanation, $secondaryCauses, $nextActionCategory, $nextActionText);
return new GovernanceRunDiagnosticSummary(
headline: $headline,
executionOutcomeLabel: $this->executionOutcomeLabel($run),
artifactImpactLabel: $artifactImpactLabel,
primaryReason: $primaryReason,
affectedScaleCue: $affectedScaleCue,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
dominantCause: [
'code' => $dominantCause['code'] ?? null,
'label' => $dominantCause['label'],
'explanation' => $dominantCause['explanation'],
],
secondaryCauses: array_map(
static fn (array $cause): array => [
'code' => $cause['code'] ?? null,
'label' => $cause['label'],
'explanation' => $cause['explanation'],
],
$secondaryCauses,
),
secondaryFacts: $secondaryFacts,
diagnosticsAvailable: (bool) ($operatorExplanation?->diagnosticsAvailable ?? false),
);
}
private function executionOutcomeLabel(OperationRun $run): string
{
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, (string) $run->outcome);
return $spec->label !== 'Unknown'
? $spec->label
: ucfirst(str_replace('_', ' ', trim((string) $run->outcome)));
}
private function artifactImpactLabel(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
if ($artifactTruth instanceof ArtifactTruthEnvelope && trim($artifactTruth->primaryLabel) !== '') {
return $artifactTruth->primaryLabel;
}
if ($operatorExplanation instanceof OperatorExplanationPattern) {
return $operatorExplanation->trustworthinessLabel();
}
return 'Result needs review';
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function headline(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $dominantCause,
array $context,
array $counts,
): string {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'baseline.compare' => $this->baselineCompareHeadline($artifactTruth, $context, $counts, $operatorExplanation),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotHeadline($artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeHeadline($artifactTruth, $dominantCause, $operatorExplanation),
'tenant.review_pack.generate' => $this->reviewPackHeadline($artifactTruth, $dominantCause, $operatorExplanation),
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'This governance run needs review before it can be relied on.',
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCaptureHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($subjectsTotal === 0) {
return 'No baseline was captured because no governed subjects were ready.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The baseline capture started, but more evidence still needs to be collected.';
}
if ($gapCount > 0) {
return 'The baseline capture finished, but evidence gaps still limit the snapshot.';
}
if (($artifactTruth?->artifactExistence ?? null) === 'created_but_not_usable') {
return 'The baseline capture finished without a usable snapshot.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The baseline capture finished without producing a decision-grade snapshot.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The baseline capture needs review before it can be used.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
*/
private function baselineCompareHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $context,
array $counts,
?OperatorExplanationPattern $operatorExplanation,
): string {
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
return 'The compare finished, but ambiguous subject matching limited the result.';
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
return 'The compare finished, but a compare strategy failure kept the result incomplete.';
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'The compare finished, but evidence capture still needs to resume before the result is complete.';
}
if (($counts['total'] ?? 0) === 0 && ($counts['processed'] ?? 0) === 0) {
return 'The compare finished, but no decision-grade result is available yet.';
}
if ($proof === false) {
return 'The compare finished, but missing coverage proof suppressed the normal result.';
}
return $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The compare needs follow-up before it can be treated as complete.';
}
private function evidenceSnapshotHeadline(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->freshnessState === 'stale' => 'The snapshot finished processing, but its evidence basis is already stale.',
$artifactTruth?->contentState === 'partial' => 'The snapshot finished processing, but its evidence basis is incomplete.',
$artifactTruth?->contentState === 'missing_input' => 'The snapshot finished processing without a complete evidence basis.',
default => $operatorExplanation?->headline
?? $artifactTruth?->primaryExplanation
?? 'The evidence snapshot needs review before it is relied on.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewComposeHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->contentState === 'partial' && $artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but missing sections and stale evidence keep it from being decision-grade.',
$artifactTruth?->contentState === 'partial'
=> 'The review was generated, but required sections are still incomplete.',
$artifactTruth?->freshnessState === 'stale'
=> 'The review was generated, but it relies on stale evidence.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review needs follow-up before it should guide action.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function reviewPackHeadline(
?ArtifactTruthEnvelope $artifactTruth,
array $dominantCause,
?OperatorExplanationPattern $operatorExplanation,
): string {
return match (true) {
$artifactTruth?->publicationReadiness === 'blocked'
=> 'The pack did not produce a shareable artifact yet.',
$artifactTruth?->publicationReadiness === 'internal_only'
=> 'The pack finished, but it should stay internal until the source review is refreshed.',
default => $operatorExplanation?->headline
?? $dominantCause['explanation']
?? 'The review pack needs follow-up before it is shared.',
};
}
/**
* @param array{code: ?string, label: string, explanation: string} $dominantCause
*/
private function primaryReason(
array $dominantCause,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
return $dominantCause['explanation']
?? $operatorExplanation?->dominantCauseExplanation
?? $reasonEnvelope?->shortExplanation
?? $artifactTruth?->primaryExplanation
?? $operatorExplanation?->reliabilityStatement
?? 'TenantPilot recorded diagnostic detail for this run.';
}
/**
* @param array<string, mixed> $context
*/
private function nextActionCategory(
string $canonicalType,
OperationRun $run,
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
array $context,
): string {
if ($reasonEnvelope?->actionability === 'retryable_transient' || $operatorExplanation?->nextActionCategory === 'retry_later') {
return 'retry_later';
}
if (in_array($canonicalType, ['baseline.capture', 'baseline.compare'], true)) {
$resumeToken = $canonicalType === 'baseline.capture'
? data_get($context, 'baseline_capture.resume_token')
: data_get($context, 'baseline_compare.resume_token');
if (is_string($resumeToken) && trim($resumeToken) !== '') {
return 'resume_capture_or_generation';
}
}
$reasonCode = (string) (data_get($context, 'baseline_compare.reason_code') ?? $reasonEnvelope?->internalCode ?? '');
if (in_array($reasonCode, [
BaselineCompareReasonCode::AmbiguousSubjects->value,
BaselineCompareReasonCode::UnsupportedSubjects->value,
], true)) {
return 'review_scope_or_ambiguous_matches';
}
if ($canonicalType === 'baseline.capture' && $this->intValue(data_get($context, 'baseline_capture.subjects_total')) === 0) {
return 'refresh_prerequisite_data';
}
if ($operatorExplanation?->nextActionCategory === 'none' || trim((string) $operatorExplanation?->nextActionText) === 'No action needed') {
return 'no_further_action';
}
if (
$reasonEnvelope?->actionability === 'prerequisite_missing'
|| in_array($canonicalType, ['tenant.evidence.snapshot.generate', 'tenant.review.compose', 'tenant.review_pack.generate'], true)
) {
return 'refresh_prerequisite_data';
}
return 'manually_validate';
}
private function nextActionText(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): string {
$text = $operatorExplanation?->nextActionText
?? $artifactTruth?->nextStepText()
?? $reasonEnvelope?->firstNextStep()?->label
?? 'No action needed';
return trim(rtrim($text, '.')).'.';
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function affectedScaleCue(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $context,
array $counts,
): ?array {
return match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureScaleCue($context, $counts),
'baseline.compare' => $this->baselineCompareScaleCue($context, $counts),
'tenant.evidence.snapshot.generate' => $this->countDescriptorScaleCue($operatorExplanation?->countDescriptors ?? [], ['Missing dimensions', 'Stale dimensions', 'Evidence dimensions']),
'tenant.review.compose' => $this->reviewScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
'tenant.review_pack.generate' => $this->reviewPackScaleCue($artifactTruth, $operatorExplanation?->countDescriptors ?? []),
default => $this->summaryCountsScaleCue($counts),
};
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCaptureScaleCue(array $context, array $counts): ?array
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still need evidence follow-up.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($subjectsTotal >= 0) {
return [
'label' => 'Capture scope',
'value' => "{$subjectsTotal} governed subjects were in the recorded capture scope.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<string, mixed> $context
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function baselineCompareScaleCue(array $context, array $counts): ?array
{
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_compare.subjects_total'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
if ($gapCount > 0) {
return [
'label' => 'Affected subjects',
'value' => "{$gapCount} governed subjects still have evidence gaps.",
'source' => 'context',
'confidence' => 'exact',
];
}
if ($uncoveredTypes !== []) {
$count = count($uncoveredTypes);
return [
'label' => 'Coverage scope',
'value' => "{$count} policy types were left without proven compare coverage.",
'source' => 'context',
'confidence' => 'bounded',
];
}
if ($subjectsTotal > 0) {
return [
'label' => 'Compare scope',
'value' => "{$subjectsTotal} governed subjects were in scope for this compare run.",
'source' => 'context',
'confidence' => 'exact',
];
}
return $this->summaryCountsScaleCue($counts);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->contentState === 'partial') {
$sections = $this->findCountDescriptor($countDescriptors, 'Sections');
if ($sections instanceof CountDescriptor) {
return [
'label' => 'Review sections',
'value' => "{$sections->value} sections were recorded and still need review for completeness.",
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return [
'label' => 'Review sections',
'value' => 'Required review sections are still incomplete.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
if ($artifactTruth?->freshnessState === 'stale') {
return [
'label' => 'Evidence freshness',
'value' => 'The source evidence is stale for at least part of this review.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Sections', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function reviewPackScaleCue(?ArtifactTruthEnvelope $artifactTruth, array $countDescriptors): ?array
{
if ($artifactTruth?->publicationReadiness === 'internal_only') {
return [
'label' => 'Sharing scope',
'value' => 'The pack is suitable for internal follow-up only in its current state.',
'source' => 'related_artifact_truth',
'confidence' => 'best_available',
];
}
return $this->countDescriptorScaleCue($countDescriptors, ['Reports', 'Findings']);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
* @param list<string> $preferredLabels
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function countDescriptorScaleCue(array $countDescriptors, array $preferredLabels): ?array
{
foreach ($preferredLabels as $label) {
$descriptor = $this->findCountDescriptor($countDescriptors, $label);
if (! $descriptor instanceof CountDescriptor || $descriptor->value <= 0) {
continue;
}
return [
'label' => $descriptor->label,
'value' => "{$descriptor->value} {$this->pluralizeDescriptor($descriptor)}.",
'source' => 'related_artifact_truth',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, int> $counts
* @return array{label: string, value: string, source: string, confidence?: string}|null
*/
private function summaryCountsScaleCue(array $counts): ?array
{
foreach (['total', 'processed', 'failed', 'items', 'finding_count'] as $key) {
$value = (int) ($counts[$key] ?? 0);
if ($value <= 0) {
continue;
}
return [
'label' => SummaryCountsNormalizer::label($key),
'value' => "{$value} recorded in the canonical run counters.",
'source' => 'summary_counts',
'confidence' => 'exact',
];
}
return null;
}
/**
* @param array<string, mixed> $context
* @return list<array{rank: int, code: ?string, label: string, explanation: string}>
*/
private function rankCauseCandidates(
string $canonicalType,
OperationRun $run,
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $context,
): array {
$candidates = [];
$this->pushCandidate(
$candidates,
code: $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
label: $reasonEnvelope?->operatorLabel ?? $operatorExplanation?->dominantCauseLabel ?? $artifactTruth?->primaryLabel,
explanation: $reasonEnvelope?->shortExplanation ?? $operatorExplanation?->dominantCauseExplanation ?? $artifactTruth?->primaryExplanation,
rank: $this->reasonRank($reasonEnvelope, $operatorExplanation),
);
match ($canonicalType) {
'baseline.capture' => $this->baselineCaptureCandidates($candidates, $context),
'baseline.compare' => $this->baselineCompareCandidates($candidates, $context),
'tenant.evidence.snapshot.generate' => $this->evidenceSnapshotCandidates($candidates, $artifactTruth, $operatorExplanation),
'tenant.review.compose' => $this->reviewComposeCandidates($candidates, $artifactTruth),
'tenant.review_pack.generate' => $this->reviewPackCandidates($candidates, $artifactTruth),
default => null,
};
usort($candidates, static function (array $left, array $right): int {
$rank = ($right['rank'] <=> $left['rank']);
if ($rank !== 0) {
return $rank;
}
return strcmp($left['label'], $right['label']);
});
return array_values(array_map(
static fn (array $candidate): array => [
'code' => $candidate['code'],
'label' => $candidate['label'],
'explanation' => $candidate['explanation'],
],
$candidates,
));
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function pushCandidate(array &$candidates, ?string $code, ?string $label, ?string $explanation, int $rank): void
{
$label = is_string($label) ? trim($label) : '';
$explanation = is_string($explanation) ? trim($explanation) : '';
if ($label === '' || $explanation === '') {
return;
}
foreach ($candidates as $candidate) {
if (($candidate['label'] ?? null) === $label && ($candidate['explanation'] ?? null) === $explanation) {
return;
}
}
$candidates[] = [
'code' => $code,
'label' => $label,
'explanation' => $explanation,
'rank' => $rank,
];
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCaptureCandidates(array &$candidates, array $context): void
{
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$resumeToken = data_get($context, 'baseline_capture.resume_token');
if ($subjectsTotal === 0) {
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_capture_gaps', 'Evidence gaps remain', "{$gapCount} governed subjects still need evidence capture before the snapshot is complete.", 82);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_capture_resume', 'Capture can resume', 'TenantPilot recorded a resume point because this capture could not finish in one pass.', 84);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<string, mixed> $context
*/
private function baselineCompareCandidates(array &$candidates, array $context): void
{
$reasonCode = (string) data_get($context, 'baseline_compare.reason_code', '');
$gapCount = $this->intValue(data_get($context, 'baseline_compare.evidence_gaps.count'));
$uncoveredTypes = array_values(array_filter((array) data_get($context, 'baseline_compare.coverage.uncovered_types', []), 'is_string'));
$proof = data_get($context, 'baseline_compare.coverage.proof');
$resumeToken = data_get($context, 'baseline_compare.resume_token');
if ($reasonCode === BaselineCompareReasonCode::AmbiguousSubjects->value) {
$this->pushCandidate($candidates, $reasonCode, 'Ambiguous matches', 'One or more governed subjects stayed ambiguous, so the compare result needs scope review.', 92);
}
if ($reasonCode === BaselineCompareReasonCode::StrategyFailed->value) {
$this->pushCandidate($candidates, $reasonCode, 'Compare strategy failed', 'A compare strategy failed while processing in-scope governed subjects.', 94);
}
if ($gapCount > 0) {
$this->pushCandidate($candidates, 'baseline_compare_gaps', 'Evidence gaps', "{$gapCount} governed subjects still have evidence gaps, so the compare output is incomplete.", 83);
}
if ($proof === false || $uncoveredTypes !== []) {
$count = count($uncoveredTypes);
$explanation = $count > 0
? "{$count} policy types were left without proven compare coverage."
: 'Coverage proof was missing, so TenantPilot suppressed part of the normal compare output.';
$this->pushCandidate($candidates, 'coverage_unproven', 'Coverage proof missing', $explanation, 81);
}
if (is_string($resumeToken) && trim($resumeToken) !== '') {
$this->pushCandidate($candidates, 'baseline_compare_resume', 'Evidence capture needs to resume', 'The compare recorded a resume point because evidence capture did not finish in one pass.', 80);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
* @param array<int, CountDescriptor> $countDescriptors
*/
private function evidenceSnapshotCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth, ?OperatorExplanationPattern $operatorExplanation): void
{
$countDescriptors = $operatorExplanation?->countDescriptors ?? [];
$missing = $this->findCountDescriptor($countDescriptors, 'Missing dimensions');
$stale = $this->findCountDescriptor($countDescriptors, 'Stale dimensions');
if ($missing instanceof CountDescriptor && $missing->value > 0) {
$this->pushCandidate($candidates, 'missing_dimensions', 'Missing dimensions', "{$missing->value} evidence dimensions are still missing from this snapshot.", 88);
}
if ($artifactTruth?->freshnessState === 'stale' || ($stale instanceof CountDescriptor && $stale->value > 0)) {
$value = $stale instanceof CountDescriptor && $stale->value > 0
? "{$stale->value} evidence dimensions are stale and should be refreshed."
: 'Part of the evidence basis is stale and should be refreshed before use.';
$this->pushCandidate($candidates, 'stale_evidence', 'Stale evidence basis', $value, 82);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewComposeCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_missing_sections', 'Missing sections', 'Required review sections are still incomplete for this generated review.', 90);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_stale_evidence', 'Stale evidence basis', 'The review relies on stale evidence and needs a refreshed evidence basis.', 86);
}
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_blocked', 'Publication blocked', 'The review cannot move forward until its blocking prerequisites are cleared.', 95);
}
}
/**
* @param list<array{code: ?string, label: string, explanation: string, rank: int}> $candidates
*/
private function reviewPackCandidates(array &$candidates, ?ArtifactTruthEnvelope $artifactTruth): void
{
if ($artifactTruth?->publicationReadiness === 'blocked') {
$this->pushCandidate($candidates, 'review_pack_blocked', 'Shareable pack not available', 'The pack did not produce a shareable artifact yet.', 94);
}
if ($artifactTruth?->publicationReadiness === 'internal_only') {
$this->pushCandidate($candidates, 'review_pack_internal_only', 'Internal-only outcome', 'The pack can support internal follow-up, but it should not be shared externally yet.', 80);
}
if ($artifactTruth?->freshnessState === 'stale') {
$this->pushCandidate($candidates, 'review_pack_stale_source', 'Source review is stale', 'The pack inherits stale review evidence and needs a refreshed source review.', 84);
}
if ($artifactTruth?->contentState === 'partial') {
$this->pushCandidate($candidates, 'review_pack_partial_source', 'Source review is incomplete', 'The pack inherits incomplete source review content and needs follow-up before sharing.', 86);
}
}
private function reasonRank(
?ReasonResolutionEnvelope $reasonEnvelope,
?OperatorExplanationPattern $operatorExplanation,
): int {
if ($reasonEnvelope?->actionability === 'retryable_transient') {
return 76;
}
return match ($operatorExplanation?->nextActionCategory) {
'fix_prerequisite' => 92,
'retry_later' => 76,
'none' => 40,
default => 85,
};
}
/**
* @param list<array{code: ?string, label: string, explanation: string}> $secondaryCauses
* @return list<array{
* label: string,
* value: string,
* hint?: ?string,
* emphasis?: string
* }>
*/
private function secondaryFacts(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
array $secondaryCauses,
string $nextActionCategory,
string $nextActionText,
): array {
$facts = [];
if ($operatorExplanation instanceof OperatorExplanationPattern) {
$facts[] = [
'label' => 'Result trust',
'value' => $operatorExplanation->trustworthinessLabel(),
'hint' => $this->deduplicateSecondaryFactHint(
$operatorExplanation->reliabilityStatement,
$operatorExplanation->dominantCauseExplanation,
$artifactTruth?->primaryExplanation,
),
'emphasis' => $this->emphasisFromTrust($operatorExplanation->trustworthinessLevel->value),
];
if ($operatorExplanation->evaluationResultLabel() !== '') {
$facts[] = [
'label' => 'Result meaning',
'value' => $operatorExplanation->evaluationResultLabel(),
'hint' => $operatorExplanation->coverageStatement,
'emphasis' => 'neutral',
];
}
}
if ($secondaryCauses !== []) {
$facts[] = [
'label' => 'Secondary causes',
'value' => implode(' · ', array_map(static fn (array $cause): string => $cause['label'], $secondaryCauses)),
'hint' => 'Additional contributing causes stay visible without replacing the dominant cause.',
'emphasis' => 'caution',
];
}
if ($artifactTruth?->relatedArtifactUrl === null && $nextActionCategory !== 'no_further_action') {
$facts[] = [
'label' => 'Related artifact access',
'value' => 'No related artifact link is available from this run.',
'emphasis' => 'neutral',
];
}
return $facts;
}
private function emphasisFromTrust(string $trust): string
{
return match ($trust) {
'unusable' => 'blocked',
'diagnostic_only', 'limited_confidence' => 'caution',
default => 'neutral',
};
}
private function deduplicateSecondaryFactHint(?string $hint, ?string ...$duplicates): ?string
{
$normalizedHint = $this->normalizeFactText($hint);
if ($normalizedHint === null) {
return null;
}
foreach ($duplicates as $duplicate) {
if ($normalizedHint === $this->normalizeFactText($duplicate)) {
return null;
}
}
return trim($hint ?? '');
}
private function fallbackCause(
?ArtifactTruthEnvelope $artifactTruth,
?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
): array {
return [
'code' => $reasonEnvelope?->internalCode ?? $operatorExplanation?->dominantCauseCode,
'label' => $reasonEnvelope?->operatorLabel
?? $operatorExplanation?->dominantCauseLabel
?? $artifactTruth?->primaryLabel
?? 'Follow-up required',
'explanation' => $reasonEnvelope?->shortExplanation
?? $operatorExplanation?->dominantCauseExplanation
?? $artifactTruth?->primaryExplanation
?? 'TenantPilot recorded enough detail to keep this run out of an all-clear state.',
];
}
private function findCountDescriptor(array $countDescriptors, string $label): ?CountDescriptor
{
foreach ($countDescriptors as $descriptor) {
if ($descriptor instanceof CountDescriptor && $descriptor->label === $label) {
return $descriptor;
}
}
return null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function pluralizeDescriptor(CountDescriptor $descriptor): string
{
return match ($descriptor->label) {
'Missing dimensions' => 'evidence dimensions are missing',
'Stale dimensions' => 'evidence dimensions are stale',
'Evidence dimensions' => 'evidence dimensions were recorded',
'Sections' => 'sections were recorded',
'Reports' => 'reports were recorded',
'Findings' => 'findings were recorded',
default => strtolower($descriptor->label).' were recorded',
};
}
private function normalizeFactText(?string $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
if ($normalized === '') {
return null;
}
return mb_strtolower($normalized);
}
}

View File

@ -53,6 +53,34 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
->duration(self::QUEUED_TOAST_DURATION_MS); ->duration(self::QUEUED_TOAST_DURATION_MS);
} }
/**
* Canonical provider-backed dedupe feedback using the shared start vocabulary.
*/
public static function alreadyRunningToast(string $operationType): FilamentNotification
{
$operationLabel = OperationCatalog::label($operationType);
return FilamentNotification::make()
->title("{$operationLabel} already running")
->body('A matching operation is already queued or running. Open the operation for progress and next steps.')
->info()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/**
* Canonical provider-backed protected-scope conflict feedback.
*/
public static function scopeBusyToast(
string $title = 'Scope busy',
string $body = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
return FilamentNotification::make()
->title($title)
->body($body)
->warning()
->duration(self::QUEUED_TOAST_DURATION_MS);
}
/** /**
* Terminal DB notification payload. * Terminal DB notification payload.
* *
@ -322,6 +350,16 @@ public static function governanceOperatorExplanation(OperationRun $run): ?Operat
return self::resolveGovernanceOperatorExplanation($run); return self::resolveGovernanceOperatorExplanation($run);
} }
public static function governanceDiagnosticSummary(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run);
}
public static function governanceDiagnosticSummaryFresh(OperationRun $run): ?GovernanceRunDiagnosticSummary
{
return self::resolveGovernanceDiagnosticSummary($run, fresh: true);
}
public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern public static function governanceOperatorExplanationFresh(OperationRun $run): ?OperatorExplanationPattern
{ {
return self::resolveGovernanceOperatorExplanation($run, fresh: true); return self::resolveGovernanceOperatorExplanation($run, fresh: true);
@ -464,6 +502,29 @@ private static function resolveGovernanceOperatorExplanation(OperationRun $run,
); );
} }
private static function resolveGovernanceDiagnosticSummary(OperationRun $run, bool $fresh = false): ?GovernanceRunDiagnosticSummary
{
if (! $run->supportsOperatorExplanation()) {
return null;
}
return self::memoizeExplanation(
run: $run,
variant: 'governance_diagnostic_summary',
resolver: fn (): ?GovernanceRunDiagnosticSummary => app(GovernanceRunDiagnosticSummaryBuilder::class)->build(
run: $run,
artifactTruth: $fresh
? app(ArtifactTruthPresenter::class)->forOperationRunFresh($run)
: app(ArtifactTruthPresenter::class)->forOperationRun($run),
operatorExplanation: $fresh
? self::resolveGovernanceOperatorExplanation($run, fresh: true)
: self::resolveGovernanceOperatorExplanation($run),
reasonEnvelope: app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
),
fresh: $fresh,
);
}
private static function memoizeGuidance( private static function memoizeGuidance(
OperationRun $run, OperationRun $run,
string $variant, string $variant,

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Support\OpsUx;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunLinks;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonPresenter;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
final class ProviderOperationStartResultPresenter
{
public function __construct(
private readonly ReasonPresenter $reasonPresenter,
) {}
/**
* @param array<int, Action> $extraActions
*/
public function notification(
ProviderOperationStartResult $result,
string $blockedTitle,
string $runUrl,
array $extraActions = [],
string $scopeBusyTitle = 'Scope busy',
string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
): FilamentNotification {
$notification = match ($result->status) {
'started' => OperationUxPresenter::queuedToast((string) $result->run->type),
'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type),
'scope_busy' => OperationUxPresenter::scopeBusyToast($scopeBusyTitle, $scopeBusyBody),
'blocked' => FilamentNotification::make()
->title($blockedTitle)
->body(implode("\n", $this->blockedBodyLines($result)))
->warning(),
default => OperationUxPresenter::queuedToast((string) $result->run->type),
};
return $notification->actions($this->actionsFor($result, $runUrl, $extraActions));
}
/**
* @param array<int, Action> $extraActions
* @return array<int, Action>
*/
private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array
{
$actions = [
Action::make('view_run')
->label(OperationRunLinks::openLabel())
->url($runUrl),
];
if ($result->status === 'blocked') {
$nextStep = $this->firstNextStep($result);
if ($nextStep instanceof NextStepOption && $nextStep->kind === 'link' && is_string($nextStep->destination) && trim($nextStep->destination) !== '') {
$actions[] = Action::make('next_step_0')
->label($nextStep->label)
->url($nextStep->destination);
}
}
return [...$actions, ...$extraActions];
}
/**
* @return array<int, string>
*/
private function blockedBodyLines(ProviderOperationStartResult $result): array
{
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
}
private function firstNextStep(ProviderOperationStartResult $result): ?NextStepOption
{
$nextSteps = is_array($result->run->context['next_steps'] ?? null)
? $result->run->context['next_steps']
: [];
$storedNextStep = NextStepOption::collect($nextSteps)[0] ?? null;
if ($storedNextStep instanceof NextStepOption) {
return $storedNextStep;
}
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
return $reasonEnvelope?->firstNextStep();
}
}

View File

@ -21,6 +21,7 @@ public function __construct(
public ?string $operatorLabel, public ?string $operatorLabel,
public ?string $shortExplanation, public ?string $shortExplanation,
public ?string $diagnosticCode, public ?string $diagnosticCode,
public ?string $actionability,
public string $trustImpact, public string $trustImpact,
public ?string $absencePattern, public ?string $absencePattern,
public array $nextSteps = [], public array $nextSteps = [],
@ -43,6 +44,7 @@ public static function fromReasonResolutionEnvelope(
operatorLabel: $reason->operatorLabel, operatorLabel: $reason->operatorLabel,
shortExplanation: $reason->shortExplanation, shortExplanation: $reason->shortExplanation,
diagnosticCode: $reason->diagnosticCode(), diagnosticCode: $reason->diagnosticCode(),
actionability: $reason->actionability,
trustImpact: $reason->trustImpact, trustImpact: $reason->trustImpact,
absencePattern: $reason->absencePattern, absencePattern: $reason->absencePattern,
nextSteps: array_values(array_map( nextSteps: array_values(array_map(
@ -79,7 +81,8 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
internalCode: $this->reasonCode ?? 'artifact_truth_reason', internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required', operatorLabel: $this->operatorLabel ?? 'Operator attention required',
shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.', shortExplanation: $this->shortExplanation ?? 'Technical diagnostics are available for this result.',
actionability: $this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing', actionability: $this->actionability
?? ($this->absencePattern === 'true_no_result' ? 'non_actionable' : 'prerequisite_missing'),
nextSteps: array_map( nextSteps: array_map(
static fn (string $label): NextStepOption => NextStepOption::instruction($label), static fn (string $label): NextStepOption => NextStepOption::instruction($label),
$this->nextSteps, $this->nextSteps,
@ -98,6 +101,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* operatorLabel: ?string, * operatorLabel: ?string,
* shortExplanation: ?string, * shortExplanation: ?string,
* diagnosticCode: ?string, * diagnosticCode: ?string,
* actionability: ?string,
* trustImpact: string, * trustImpact: string,
* absencePattern: ?string, * absencePattern: ?string,
* nextSteps: array<int, string>, * nextSteps: array<int, string>,
@ -114,6 +118,7 @@ public function toArray(): array
'operatorLabel' => $this->operatorLabel, 'operatorLabel' => $this->operatorLabel,
'shortExplanation' => $this->shortExplanation, 'shortExplanation' => $this->shortExplanation,
'diagnosticCode' => $this->diagnosticCode, 'diagnosticCode' => $this->diagnosticCode,
'actionability' => $this->actionability,
'trustImpact' => $this->trustImpact, 'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern, 'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps, 'nextSteps' => $this->nextSteps,

View File

@ -76,6 +76,14 @@
'direct_failed_bridge' => false, 'direct_failed_bridge' => false,
'scheduled_reconciliation' => true, 'scheduled_reconciliation' => true,
], ],
'backup_set.update' => [
'job_class' => \App\Jobs\AddPoliciesToBackupSetJob::class,
'queued_stale_after_seconds' => 300,
'running_stale_after_seconds' => 900,
'expected_max_runtime_seconds' => 240,
'direct_failed_bridge' => false,
'scheduled_reconciliation' => true,
],
'backup_schedule_run' => [ 'backup_schedule_run' => [
'job_class' => \App\Jobs\RunBackupScheduleJob::class, 'job_class' => \App\Jobs\RunBackupScheduleJob::class,
'queued_stale_after_seconds' => 300, 'queued_stale_after_seconds' => 300,

View File

@ -11,44 +11,6 @@
<div <div
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif @if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
> >
<x-filament::section heading="Monitoring detail" class="mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
@if ($contextBanner !== null) @if ($contextBanner !== null)
@php @php
$bannerClasses = match ($contextBanner['tone']) { $bannerClasses = match ($contextBanner['tone']) {
@ -117,5 +79,43 @@
@endif @endif
{{ $this->infolist }} {{ $this->infolist }}
<x-filament::section heading="Monitoring detail" class="mt-6">
<p class="text-sm text-gray-600 dark:text-gray-400">
Scope context, return navigation, utility, related drilldowns, and run-specific follow-up stay in separate lanes on this viewer.
</p>
<div class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Scope context</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['scope_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['scope_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Navigation lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['navigation_label'] }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['navigation_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Utility lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Refresh</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['utility_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Related drilldown</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">Open</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['related_body'] }}</p>
</div>
<div class="rounded-xl border border-gray-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400">Follow-up lane</p>
<p class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">{{ $monitoringDetail['follow_up_label'] ?? 'No follow-up action' }}</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ $monitoringDetail['follow_up_body'] }}</p>
</div>
</div>
</x-filament::section>
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -123,11 +123,11 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
$basisRunUrl = OperationRunLinks::view($run, $tenant); $basisRunUrl = OperationRunLinks::view($run, $tenant);
$inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); $inventoryItemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant)); $searchPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant));
$searchPage $searchPage
->waitForText('Inventory Items') ->waitForText('Inventory Items')

View File

@ -239,7 +239,7 @@ function spec192ApprovedFindingException(Tenant $tenant, User $requester)
OperationRun::factory()->forTenant($tenant)->create([ OperationRun::factory()->forTenant($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\BaselineCompareLanding; use App\Filament\Pages\BaselineCompareLanding;
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
@ -15,6 +16,8 @@
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('returns 404 for non-members on the baseline compare explanation surface', function (): void { it('returns 404 for non-members on the baseline compare explanation surface', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner'); [$member, $tenant] = createUserWithTenant(role: 'owner');
@ -99,3 +102,65 @@
->get(ReviewRegister::getUrl(panel: 'admin')) ->get(ReviewRegister::getUrl(panel: 'admin'))
->assertNotFound(); ->assertNotFound();
}); });
it('renders governance summary facts for entitled viewers on the canonical run detail surface', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'ambiguous_subjects',
'evidence_gaps' => [
'count' => 2,
],
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Ambiguous matches');
});
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => 'active',
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'succeeded',
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -49,7 +49,7 @@
$opRun = OperationRun::query() $opRun = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->latest('id') ->latest('id')
->first(); ->first();

View File

@ -49,7 +49,7 @@
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $user->id, 'user_id' => $user->id,
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'context' => [ 'context' => [
@ -202,7 +202,7 @@
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $user->id, 'user_id' => $user->id,
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'context' => [ 'context' => [

View File

@ -28,7 +28,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_set.remove_policies', 'type' => 'backup_set.update',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'run_identity_hash' => 'remove-hash-1', 'run_identity_hash' => 'remove-hash-1',

View File

@ -50,7 +50,7 @@
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.remove_policies') ->where('type', 'backup_set.update')
->latest('id') ->latest('id')
->first(); ->first();

View File

@ -79,6 +79,24 @@ protected function makePartialArtifactTruthEvidenceSnapshot(
); );
} }
protected function makeMissingArtifactTruthEvidenceSnapshot(
Tenant $tenant,
array $snapshotOverrides = [],
array $summaryOverrides = [],
): EvidenceSnapshot {
$snapshot = $this->makeArtifactTruthEvidenceSnapshot($tenant, $snapshotOverrides, $summaryOverrides);
return $this->restateArtifactTruthEvidenceSnapshot(
$snapshot,
EvidenceCompletenessState::Missing,
array_replace([
'dimension_count' => 0,
'missing_dimensions' => 1,
'stale_dimensions' => 0,
], $summaryOverrides),
);
}
protected function makeArtifactTruthReview( protected function makeArtifactTruthReview(
Tenant $tenant, Tenant $tenant,
User $user, User $user,
@ -115,6 +133,32 @@ protected function makeArtifactTruthReview(
return TenantReview::query()->create(array_replace($defaults, $reviewOverrides)); return TenantReview::query()->create(array_replace($defaults, $reviewOverrides));
} }
protected function makePartialArtifactTruthReview(
Tenant $tenant,
User $user,
?EvidenceSnapshot $snapshot = null,
array $reviewOverrides = [],
array $summaryOverrides = [],
): TenantReview {
return $this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: array_replace([
'status' => TenantReviewStatus::Ready->value,
'completeness_state' => TenantReviewCompletenessState::Partial->value,
], $reviewOverrides),
summaryOverrides: array_replace_recursive([
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
], $summaryOverrides),
);
}
protected function makeBlockedArtifactTruthReview( protected function makeBlockedArtifactTruthReview(
Tenant $tenant, Tenant $tenant,
User $user, User $user,

View File

@ -0,0 +1,77 @@
<?php
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Jobs\EntraGroupSyncJob;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('starts directory group sync with explicit provider connection context', function (): void {
Queue::fake();
$this->mock(GraphClientInterface::class, function ($mock): void {
$mock->shouldReceive('listPolicies')->never();
$mock->shouldReceive('getPolicy')->never();
$mock->shouldReceive('getOrganization')->never();
$mock->shouldReceive('applyPolicy')->never();
$mock->shouldReceive('getServicePrincipalPermissions')->never();
$mock->shouldReceive('request')->never();
});
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class)
->callAction('sync_groups');
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'entra_group_sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued');
expect($run?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($run): bool {
return $job->providerConnectionId === ($run?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run);
});
});
it('blocks role definitions sync before queue when no provider connection is available', function (): void {
Bus::fake();
$tenant = Tenant::factory()->create([
'app_client_id' => 'client-123',
'app_client_secret' => 'secret',
'status' => 'active',
]);
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
$result = app(RoleDefinitionsSyncService::class)->startManualSync($tenant, $user);
expect($result->status)->toBe('blocked');
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(SyncRoleDefinitionsJob::class);
});

View File

@ -38,11 +38,13 @@
expect($opRun)->not->toBeNull(); expect($opRun)->not->toBeNull();
expect($opRun?->status)->toBe('queued'); expect($opRun?->status)->toBe('queued');
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all'); expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
expect($opRun?->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool { Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
return $job->tenantId === (int) $tenant->getKey() return $job->tenantId === (int) $tenant->getKey()
&& $job->selectionKey === 'groups-v1:all' && $job->selectionKey === 'groups-v1:all'
&& $job->runId === null && $job->runId === null
&& $job->providerConnectionId === ($opRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey(); && (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
}); });

View File

@ -1,8 +1,8 @@
<?php <?php
use App\Jobs\EntraGroupSyncJob; use App\Jobs\EntraGroupSyncJob;
use App\Models\OperationRun;
use App\Services\Directory\EntraGroupSyncService; use App\Services\Directory\EntraGroupSyncService;
use App\Services\Providers\ProviderOperationStartResult;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
it('starts a manual group sync by creating a run and dispatching a job', function () { it('starts a manual group sync by creating a run and dispatching a job', function () {
@ -12,14 +12,19 @@
$service = app(EntraGroupSyncService::class); $service = app(EntraGroupSyncService::class);
$run = $service->startManualSync($tenant, $user); $result = $service->startManualSync($tenant, $user);
$run = $result->run;
expect($run)->toBeInstanceOf(OperationRun::class) expect($result)->toBeInstanceOf(ProviderOperationStartResult::class)
->and($result->status)->toBe('started');
expect($run)
->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey()) ->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe('entra_group_sync') ->and($run->type)->toBe('entra_group_sync')
->and($run->status)->toBe('queued') ->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all'); ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
Queue::assertPushed(EntraGroupSyncJob::class); Queue::assertPushed(EntraGroupSyncJob::class);
}); });

View File

@ -74,7 +74,7 @@
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'backup_set.remove_policies') ->where('type', 'backup_set.update')
->latest('id') ->latest('id')
->first(); ->first();

View File

@ -43,7 +43,7 @@
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -62,7 +62,7 @@
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->latest('id') ->latest('id')
->first(); ->first();
@ -71,7 +71,7 @@
expect($run?->outcome)->toBe('pending'); expect($run?->outcome)->toBe('pending');
expect($run?->context['backup_set_id'] ?? null)->toBe($backupSet->getKey()); expect($run?->context['backup_set_id'] ?? null)->toBe($backupSet->getKey());
expect($run?->context['policy_count'] ?? null)->toBe(count($policyIds)); expect($run?->context['policy_count'] ?? null)->toBe(count($policyIds));
expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.add_policies'); expect($run?->context['operation']['type'] ?? null)->toBe('backup_set.update');
expect($run?->context['selection']['kind'] ?? null)->toBe('ids'); expect($run?->context['selection']['kind'] ?? null)->toBe('ids');
expect($run?->context['idempotency']['fingerprint'] ?? null)->not->toBeNull(); expect($run?->context['idempotency']['fingerprint'] ?? null)->not->toBeNull();
@ -122,13 +122,13 @@
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->count())->toBe(1); ->count())->toBe(1);
Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1); Queue::assertPushed(AddPoliciesToBackupSetJob::class, 1);
$notifications = session('filament.notifications', []); $notifications = session('filament.notifications', []);
$expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); $expectedToast = OperationUxPresenter::alreadyQueuedToast('backup_set.update');
expect($notifications)->not->toBeEmpty(); expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle()); expect(collect($notifications)->last()['title'] ?? null)->toBe($expectedToast->getTitle());
@ -173,7 +173,7 @@
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenant->id) ->where('tenant_id', $tenant->id)
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->exists())->toBeFalse(); ->exists())->toBeFalse();
}); });
@ -223,12 +223,12 @@
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenantA->id) ->where('tenant_id', $tenantA->id)
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->exists())->toBeFalse(); ->exists())->toBeFalse();
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', $tenantB->id) ->where('tenant_id', $tenantB->id)
->where('type', 'backup_set.add_policies') ->where('type', 'backup_set.update')
->exists())->toBeFalse(); ->exists())->toBeFalse();
}); });

View File

@ -19,7 +19,7 @@
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -18,7 +18,7 @@
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -66,8 +66,8 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
$basisRun = seedInventoryCoverageBasis($tenant); $basisRun = seedInventoryCoverageBasis($tenant);
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant); $itemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant); $coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
$this->actingAs($user) $this->actingAs($user)
->get($itemsUrl) ->get($itemsUrl)
@ -102,7 +102,7 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user) $this->actingAs($user)
->get(InventoryCoverage::getUrl(tenant: $tenant)) ->get(InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk() ->assertOk()
->assertSee('No current coverage basis') ->assertSee('No current coverage basis')
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.') ->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')

View File

@ -77,7 +77,7 @@ function visibleLivewireText(Testable $component): string
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee('Execution failed') ->assertSee('Execution failed')
->assertSee($explanation?->headline ?? '') ->assertSee('The baseline capture finished without a usable snapshot.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Artifact not usable') ->assertSee('Artifact not usable')
@ -136,11 +136,10 @@ function visibleLivewireText(Testable $component): string
->assertSee('Result trust') ->assertSee('Result trust')
->assertSee('Primary next step') ->assertSee('Primary next step')
->assertSee('Artifact truth details') ->assertSee('Artifact truth details')
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '') ->assertSee($explanation?->nextActionText ?? '')
->assertSee('The run completed, but normal output was intentionally suppressed.')
->assertSee('Resume or rerun evidence capture before relying on this compare result.') ->assertSee('Resume or rerun evidence capture before relying on this compare result.')
->assertDontSee('Artifact next step'); ->assertDontSee('Artifact next step');
@ -206,7 +205,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but a compare strategy failure kept the result incomplete.')
->assertSee($explanation?->nextActionText ?? '') ->assertSee($explanation?->nextActionText ?? '')
->assertSee('Compare strategy') ->assertSee('Compare strategy')
->assertSee('Intune Policy') ->assertSee('Intune Policy')
@ -314,7 +313,7 @@ function visibleLivewireText(Testable $component): string
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee($explanation?->headline ?? '') ->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertDontSee('No confirmed drift in the latest baseline compare.'); ->assertDontSee('No confirmed drift in the latest baseline compare.');

View File

@ -173,7 +173,7 @@ function baselineCompareGapContext(array $overrides = []): array
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => OperationRunStatus::Completed->value, 'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value, 'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [ 'context' => [

View File

@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Schemas\Components\Text;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders explicit responsibility states on findings list and detail surfaces', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
$ownerOnly = tenantFindingUser($tenant, 'Owner Only');
$listOwner = tenantFindingUser($tenant, 'List Owner');
$listAssignee = tenantFindingUser($tenant, 'List Assignee');
$assigneeOnly = tenantFindingUser($tenant, 'Assignee Only');
$samePerson = tenantFindingUser($tenant, 'Same Person');
$ownerOnlyFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $ownerOnly->getKey(),
'assignee_user_id' => null,
]);
$assignedFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $listOwner->getKey(),
'assignee_user_id' => (int) $listAssignee->getKey(),
]);
$assigneeOnlyFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => null,
'assignee_user_id' => (int) $assigneeOnly->getKey(),
]);
$bothNullFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => null,
'assignee_user_id' => null,
]);
$sameUserFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $samePerson->getKey(),
'assignee_user_id' => (int) $samePerson->getKey(),
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertCanSeeTableRecords([
$ownerOnlyFinding,
$assignedFinding,
$assigneeOnlyFinding,
$bothNullFinding,
$sameUserFinding,
])
->assertSee('Responsibility')
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('owned but unassigned')
->assertSee('assigned')
->assertSee('orphaned accountability')
->assertSee('Owner Only')
->assertSee('List Owner')
->assertSee('List Assignee')
->assertSee('Assignee Only')
->assertSee('Same Person');
Livewire::test(ViewFinding::class, ['record' => $assigneeOnlyFinding->getKey()])
->assertSee('Responsibility state')
->assertSee('orphaned accountability')
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('Assignee Only');
Livewire::test(ViewFinding::class, ['record' => $sameUserFinding->getKey()])
->assertSee('assigned')
->assertSee('Same Person');
});
it('isolates owner accountability and assigned work with separate personal filters', function (): void {
[$viewer, $tenant] = createUserWithTenant(role: 'manager');
$otherOwner = tenantFindingUser($tenant, 'Other Owner');
$otherAssignee = tenantFindingUser($tenant, 'Other Assignee');
$assignedToMe = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $otherOwner->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
]);
$ownedByMe = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$bothMine = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
]);
$neitherMine = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $otherOwner->getKey(),
'assignee_user_id' => (int) $otherAssignee->getKey(),
]);
$this->actingAs($viewer);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->filterTable('my_assigned', true)
->assertCanSeeTableRecords([$assignedToMe, $bothMine])
->assertCanNotSeeTableRecords([$ownedByMe, $neitherMine])
->removeTableFilter('my_assigned')
->filterTable('my_accountability', true)
->assertCanSeeTableRecords([$ownedByMe, $bothMine])
->assertCanNotSeeTableRecords([$assignedToMe, $neitherMine]);
});
it('keeps exception ownership visibly separate from finding ownership', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$findingOwner = tenantFindingUser($tenant, 'Finding Owner');
$findingAssignee = tenantFindingUser($tenant, 'Finding Assignee');
$exceptionOwner = tenantFindingUser($tenant, 'Exception Owner');
$findingWithException = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $findingOwner->getKey(),
'assignee_user_id' => (int) $findingAssignee->getKey(),
]);
FindingException::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $findingWithException->getKey(),
'requested_by_user_id' => (int) $owner->getKey(),
'owner_user_id' => (int) $exceptionOwner->getKey(),
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => 'Needs temporary governance coverage.',
'requested_at' => now(),
'review_due_at' => now()->addDays(7),
'evidence_summary' => ['reference_count' => 0],
]);
$requestFinding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $findingOwner->getKey(),
]);
$this->actingAs($owner);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$this->get(FindingResource::getUrl('view', ['record' => $findingWithException], panel: 'tenant', tenant: $tenant))
->assertSuccessful()
->assertSee('Accountable owner')
->assertSee('Active assignee')
->assertSee('Exception owner')
->assertSee('Finding Owner')
->assertSee('Finding Assignee')
->assertSee('Exception Owner');
$component = Livewire::test(ViewFinding::class, ['record' => $requestFinding->getKey()])
->mountAction('request_exception');
$method = new \ReflectionMethod($component->instance(), 'getMountedActionForm');
$method->setAccessible(true);
$form = $method->invoke($component->instance());
$field = collect($form?->getFlatFields(withHidden: true) ?? [])
->first(fn (Field $field): bool => $field->getName() === 'owner_user_id');
$helperText = collect($field?->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
->filter(fn (mixed $schemaComponent): bool => $schemaComponent instanceof Text)
->map(fn (Text $schemaComponent): string => (string) $schemaComponent->getContent())
->implode(' ');
expect($field?->getLabel())->toBe('Exception owner')
->and($helperText)->toContain('Owns the exception record')
->and($helperText)->toContain('not the finding outcome');
});
it('allows in-scope members and returns 404 for non-members on tenant findings routes', function (): void {
$tenant = Tenant::factory()->create();
[$member, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$finding = Finding::factory()->for($tenant)->create();
$this->actingAs($member)
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->assertSuccessful();
$this->actingAs($member)
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertSuccessful();
$tenantInSameWorkspace = Tenant::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
[$outsider] = createUserWithTenant(tenant: $tenantInSameWorkspace, role: 'owner');
$this->actingAs($outsider)
->get(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->assertNotFound();
$this->actingAs($outsider)
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
function tenantFindingUser(Tenant $tenant, string $name): User
{
$user = User::factory()->create([
'name' => $name,
]);
createUserWithTenant(tenant: $tenant, user: $user, role: 'operator');
return $user;
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use Illuminate\Auth\Access\AuthorizationException;
it('classifies assignment audit metadata for owner assignee and clear mutations', function (
string $ownerTarget,
string $assigneeTarget,
string $expectedClassification,
string $expectedSummary,
): void {
[$actor, $tenant] = createUserWithTenant(role: 'owner');
$existingOwner = User::factory()->create(['name' => 'Existing Owner']);
createUserWithTenant(tenant: $tenant, user: $existingOwner, role: 'manager');
$existingAssignee = User::factory()->create(['name' => 'Existing Assignee']);
createUserWithTenant(tenant: $tenant, user: $existingAssignee, role: 'operator');
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
$replacementAssignee = User::factory()->create(['name' => 'Replacement Assignee']);
createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator');
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
'owner_user_id' => (int) $existingOwner->getKey(),
'assignee_user_id' => (int) $existingAssignee->getKey(),
]);
$ownerUserId = match ($ownerTarget) {
'replacement' => (int) $replacementOwner->getKey(),
'clear' => null,
default => (int) $existingOwner->getKey(),
};
$assigneeUserId = match ($assigneeTarget) {
'replacement' => (int) $replacementAssignee->getKey(),
'clear' => null,
default => (int) $existingAssignee->getKey(),
};
$updated = app(FindingWorkflowService::class)->assign(
finding: $finding,
tenant: $tenant,
actor: $actor,
assigneeUserId: $assigneeUserId,
ownerUserId: $ownerUserId,
);
$audit = $this->latestFindingAudit($updated, 'finding.assigned');
expect($audit)->not->toBeNull();
expect(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe($expectedClassification)
->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe($expectedSummary);
expect($updated->owner_user_id)->toBe($ownerUserId)
->and($updated->assignee_user_id)->toBe($assigneeUserId);
})->with([
'owner only' => ['replacement', 'existing', 'owner_only', 'Updated the accountable owner and kept the active assignee unchanged.'],
'assignee only' => ['existing', 'replacement', 'assignee_only', 'Updated the active assignee and kept the accountable owner unchanged.'],
'owner and assignee' => ['replacement', 'replacement', 'owner_and_assignee', 'Updated the accountable owner and the active assignee.'],
'clear owner' => ['clear', 'existing', 'clear_owner', 'Cleared the accountable owner and kept the active assignee unchanged.'],
'clear assignee' => ['existing', 'clear', 'clear_assignee', 'Cleared the active assignee and kept the accountable owner unchanged.'],
]);
it('preserves 403 semantics for in-scope members without assignment capability', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
expect(fn () => app(FindingWorkflowService::class)->assign(
finding: $finding,
tenant: $tenant,
actor: $readonly,
assigneeUserId: null,
ownerUserId: (int) $owner->getKey(),
))->toThrow(AuthorizationException::class);
});

View File

@ -9,8 +9,12 @@
use App\Models\User; use App\Models\User;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Text;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -107,41 +111,113 @@
->and($exception?->request_reason)->toBe('accepted by security'); ->and($exception?->request_reason)->toBe('accepted by security');
}); });
it('assigns owners and assignees via row action and rejects non-member ids', function (): void { it('keeps unchanged roles intact and exposes explicit assignment help text on row actions', function (): void {
[$manager, $tenant] = createUserWithTenant(role: 'manager'); [$manager, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($manager); $this->actingAs($manager);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$assignee = User::factory()->create(); $initialOwner = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); createUserWithTenant(tenant: $tenant, user: $initialOwner, role: 'manager');
$initialAssignee = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $initialAssignee, role: 'operator');
$replacementOwner = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
$replacementAssignee = User::factory()->create();
createUserWithTenant(tenant: $tenant, user: $replacementAssignee, role: 'operator');
$outsider = User::factory()->create(); $outsider = User::factory()->create();
$finding = Finding::factory()->for($tenant)->create([ $finding = Finding::factory()->for($tenant)->create([
'status' => Finding::STATUS_NEW, 'status' => Finding::STATUS_NEW,
'owner_user_id' => (int) $initialOwner->getKey(),
'assignee_user_id' => (int) $initialAssignee->getKey(),
]); ]);
$component = Livewire::test(ListFindings::class); $component = Livewire::test(ListFindings::class)
->mountTableAction('assign', $finding)
->assertFormFieldExists('owner_user_id', function (Select $field): bool {
$helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
->filter(fn (mixed $component): bool => $component instanceof Text)
->map(fn (Text $component): string => (string) $component->getContent())
->implode(' ');
return $field->getLabel() === 'Accountable owner'
&& str_contains($helperText, 'accountable for ensuring the finding reaches a governed outcome');
})
->assertFormFieldExists('assignee_user_id', function (Select $field): bool {
$helperText = collect($field->getChildSchema(Field::BELOW_CONTENT_SCHEMA_KEY)?->getComponents() ?? [])
->filter(fn (mixed $component): bool => $component instanceof Text)
->map(fn (Text $component): string => (string) $component->getContent())
->implode(' ');
return $field->getLabel() === 'Active assignee'
&& str_contains($helperText, 'currently expected to perform or coordinate the remediation work');
});
$component $component
->callTableAction('assign', $finding, [ ->callTableAction('assign', $finding, [
'assignee_user_id' => (int) $assignee->getKey(), 'assignee_user_id' => (int) $replacementAssignee->getKey(),
'owner_user_id' => (int) $manager->getKey(), 'owner_user_id' => (int) $initialOwner->getKey(),
]) ])
->assertHasNoTableActionErrors(); ->assertHasNoTableActionErrors();
$finding->refresh(); $finding->refresh();
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()) expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
->and((int) $finding->owner_user_id)->toBe((int) $manager->getKey()); ->and((int) $finding->owner_user_id)->toBe((int) $initialOwner->getKey());
$component
->callTableAction('assign', $finding, [
'assignee_user_id' => (int) $replacementAssignee->getKey(),
'owner_user_id' => (int) $replacementOwner->getKey(),
])
->assertHasNoTableActionErrors();
$finding->refresh();
expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey());
$component $component
->callTableAction('assign', $finding, [ ->callTableAction('assign', $finding, [
'assignee_user_id' => (int) $outsider->getKey(), 'assignee_user_id' => (int) $outsider->getKey(),
'owner_user_id' => (int) $manager->getKey(), 'owner_user_id' => (int) $replacementOwner->getKey(),
]); ]);
$finding->refresh(); $finding->refresh();
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey()); expect((int) $finding->assignee_user_id)->toBe((int) $replacementAssignee->getKey())
->and((int) $finding->owner_user_id)->toBe((int) $replacementOwner->getKey());
});
it('returns 404 when a forged foreign-tenant assign row action is mounted', function (): void {
$tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
$foreignFinding = Finding::factory()->for($tenantB)->create([
'status' => Finding::STATUS_NEW,
]);
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
]);
$component = Livewire::actingAs($user)->test(ListFindings::class);
expect(fn () => $component->instance()->mountTableAction('assign', (string) $foreignFinding->getKey()))
->toThrow(NotFoundHttpException::class);
}); });
it('keeps the admin workflow surface scoped to the canonical tenant', function (): void { it('keeps the admin workflow surface scoped to the canonical tenant', function (): void {

View File

@ -7,6 +7,7 @@
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
it('enforces the canonical transition matrix for service-driven status changes', function (): void { it('enforces the canonical transition matrix for service-driven status changes', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -57,9 +58,13 @@
ownerUserId: (int) $owner->getKey(), ownerUserId: (int) $owner->getKey(),
); );
$audit = $this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned);
expect((int) $assignedFinding->assignee_user_id)->toBe((int) $assignee->getKey()) expect((int) $assignedFinding->assignee_user_id)->toBe((int) $assignee->getKey())
->and((int) $assignedFinding->owner_user_id)->toBe((int) $owner->getKey()) ->and((int) $assignedFinding->owner_user_id)->toBe((int) $owner->getKey())
->and($this->latestFindingAudit($assignedFinding, AuditActionId::FindingAssigned))->not->toBeNull(); ->and($audit)->not->toBeNull()
->and(data_get($audit?->metadata, 'responsibility_change_classification'))->toBe('owner_and_assignee')
->and(data_get($audit?->metadata, 'responsibility_change_summary'))->toBe('Updated the accountable owner and the active assignee.');
expect(fn () => $service->assign( expect(fn () => $service->assign(
finding: $assignedFinding, finding: $assignedFinding,
@ -70,6 +75,31 @@
))->toThrow(\InvalidArgumentException::class, 'assignee_user_id must reference a current tenant member.'); ))->toThrow(\InvalidArgumentException::class, 'assignee_user_id must reference a current tenant member.');
}); });
it('keeps 404 and 403 semantics distinct for assignment authorization', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$outsider = User::factory()->create();
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
$service = app(FindingWorkflowService::class);
expect(fn () => $service->assign(
finding: $finding,
tenant: $tenant,
actor: $outsider,
assigneeUserId: null,
ownerUserId: (int) $owner->getKey(),
))->toThrow(NotFoundHttpException::class);
expect(fn () => $service->assign(
finding: $finding,
tenant: $tenant,
actor: $readonly,
assigneeUserId: null,
ownerUserId: (int) $owner->getKey(),
))->toThrow(AuthorizationException::class);
});
it('requires explicit reasons for resolve close and risk accept mutations', function (): void { it('requires explicit reasons for resolve close and risk accept mutations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
function providerDispatchGateSlice(string $source, string $startAnchor, ?string $endAnchor = null): string
{
$start = strpos($source, $startAnchor);
expect($start)->not->toBeFalse();
$start = is_int($start) ? $start : 0;
if ($endAnchor === null) {
return substr($source, $start);
}
$end = strpos($source, $endAnchor, $start + strlen($startAnchor));
expect($end)->not->toBeFalse();
$end = is_int($end) ? $end : strlen($source);
return substr($source, $start, $end - $start);
}
it('keeps first-slice route-bounded provider starts on canonical gate-owned entry points', function (): void {
$root = SourceFileScanner::projectRoot();
$checks = [
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckForTenant(',
'end' => 'public function providerConnectionCheckUsingConnection(',
'required' => [
'return $this->providers->start(',
"operationType: 'provider.connection.check'",
"'required_capability' => Capabilities::PROVIDER_RUN",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Verification/StartVerification.php',
'start' => 'public function providerConnectionCheckUsingConnection(',
'end' => 'private function dispatchConnectionHealthCheck(',
'required' => [
'$result = $this->providers->start(',
"operationType: 'provider.connection.check'",
'ProviderVerificationStatus::Pending',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/EntraGroupSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'entra_group_sync'",
'EntraGroupSyncJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Services/Directory/RoleDefinitionsSyncService.php',
'start' => 'public function startManualSync(',
'end' => 'public function sync(',
'required' => [
'return $this->providerStarts->start(',
"operationType: 'directory_role_definitions.sync'",
'SyncRoleDefinitionsJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/RestoreRunResource.php',
'start' => 'private static function startQueuedRestoreExecution(',
'end' => 'private static function detailPreviewState(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
"operationType: 'restore.execute'",
'ExecuteRestoreRunJob::dispatch(',
'->afterCommit()',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
'start' => "Action::make('sync_groups')",
'end' => '->requireCapability(Capabilities::TENANT_SYNC)',
'required' => [
'$syncService->startManualSync($tenant, $user)',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/TenantResource.php',
'start' => 'public static function syncRoleDefinitionsAction(): Actions\\Action',
'end' => null,
'required' => [
'$result = $syncService->startManualSync($record, $user);',
'ProviderOperationStartResultPresenter::class',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleCheckConnectionAction(',
'end' => 'private static function handleProviderOperationAction(',
'required' => [
'$verification->providerConnectionCheck(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
'start' => 'private static function handleProviderOperationAction(',
'end' => 'public static function getEloquentQuery(): Builder',
'required' => [
'$result = $gate->start(',
'ProviderOperationStartResultPresenter::class',
'OperationRunLinks::view($result->run, $tenant)',
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
[
'file' => $root.'/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'start' => 'public function startBootstrap(array $operationTypes): void',
'end' => 'private function dispatchBootstrapJob(',
'required' => [
'app(ProviderOperationStartGate::class)->start(',
'ProviderOperationStartResultPresenter::class',
"'bootstrap_operation_types'",
],
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
],
];
foreach ($checks as $check) {
$source = SourceFileScanner::read($check['file']);
$slice = providerDispatchGateSlice($source, $check['start'], $check['end']);
foreach ($check['required'] as $needle) {
expect($slice)->toContain($needle);
}
foreach ($check['forbidden'] as $needle) {
expect($slice)->not->toContain($needle);
}
}
})->group('surface-guard');

View File

@ -136,6 +136,7 @@
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue() ->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue() ->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
->and($families->has('workspace-settings-slice-management'))->toBeTrue() ->and($families->has('workspace-settings-slice-management'))->toBeTrue()
->and($families->has('provider-dispatch-gate-coverage'))->toBeTrue()
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue() ->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
->and($families->has('browser-smoke'))->toBeTrue(); ->and($families->has('browser-smoke'))->toBeTrue();
@ -159,7 +160,7 @@
expect($familyBudgets)->not->toBeEmpty() expect($familyBudgets)->not->toBeEmpty()
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds']) ->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
->and(collect($familyBudgets)->pluck('familyId')->all()) ->and(collect($familyBudgets)->pluck('familyId')->all())
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management'); ->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'provider-dispatch-gate-coverage', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
}); });
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void { it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {

View File

@ -5,11 +5,24 @@
use App\Models\InventoryLink; use App\Models\InventoryLink;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Str; use Illuminate\Support\Str;
function inventoryItemAdminSession(Tenant $tenant): array
{
return [
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenant->workspace_id => (int) $tenant->getKey(),
],
];
}
it('shows zero-state when no dependencies and shows missing badge when applicable', function () { it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -19,7 +32,7 @@
// Zero state // Zero state
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url)->assertOk()->assertSee('No dependencies found'); $this->withSession($session)->get($url)->assertOk()->assertSee('No dependencies found');
// Create a missing edge and assert badge appears // Create a missing edge and assert badge appears
InventoryLink::factory()->create([ InventoryLink::factory()->create([
@ -35,7 +48,7 @@
], ],
]); ]);
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Missing') ->assertSee('Missing')
->assertSee('Last known: Ghost Target'); ->assertSee('Last known: Ghost Target');
@ -44,6 +57,8 @@
it('renders native dependency controls in place instead of a GET apply workflow', function () { it('renders native dependency controls in place instead of a GET apply workflow', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -82,7 +97,7 @@
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Direction') ->assertSee('Direction')
->assertSee('Inbound') ->assertSee('Inbound')
@ -95,6 +110,8 @@
it('ignores legacy relationship query state while preserving visible target safety', function () { it('ignores legacy relationship query state while preserving visible target safety', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -126,7 +143,7 @@
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin') $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by'; .'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Scoped Target') ->assertSee('Scoped Target')
->assertSee('Assigned Target'); ->assertSee('Assigned Target');
@ -135,6 +152,8 @@
it('does not show edges from other tenants (tenant isolation)', function () { it('does not show edges from other tenants (tenant isolation)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -156,7 +175,7 @@
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertDontSee('Other Tenant Edge'); ->assertDontSee('Other Tenant Edge');
}); });
@ -164,6 +183,8 @@
it('shows masked identifier when last known name is missing', function () { it('shows masked identifier when last known name is missing', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -185,7 +206,7 @@
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Group (external): 123456…'); ->assertSee('Group (external): 123456…');
}); });
@ -193,6 +214,8 @@
it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () { it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
/** @var InventoryItem $item */ /** @var InventoryItem $item */
$item = InventoryItem::factory()->create([ $item = InventoryItem::factory()->create([
@ -254,7 +277,7 @@
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Scope Tag: Finance (6…)') ->assertSee('Scope Tag: Finance (6…)')
->assertSee('Assignment Filter: VIP Devices (62fb77…)') ->assertSee('Assignment Filter: VIP Devices (62fb77…)')
@ -264,6 +287,8 @@
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () { it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
setAdminPanelContext();
$session = inventoryItemAdminSession($tenant);
$graph = \Mockery::mock(GraphClientInterface::class); $graph = \Mockery::mock(GraphClientInterface::class);
$graph->shouldNotReceive('listPolicies'); $graph->shouldNotReceive('listPolicies');
@ -301,7 +326,7 @@
]); ]);
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
$this->get($url) $this->withSession($session)->get($url)
->assertOk() ->assertOk()
->assertSee('Scope Tag: Finance'); ->assertSee('Scope Tag: Finance');
}); });

View File

@ -992,7 +992,7 @@
expect($session->completed_at)->not->toBeNull(); expect($session->completed_at)->not->toBeNull();
}); });
it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void { it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void {
Bus::fake(); Bus::fake();
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -1048,17 +1048,105 @@
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); $component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1); Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->whereIn('type', ['inventory_sync', 'compliance.snapshot']) ->where('type', 'inventory_sync')
->count())->toBe(2); ->count())->toBe(1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
$session->refresh(); $session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? []; $runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs)->toBeArray(); expect($runs)->toBeArray();
expect($runs['inventory_sync'] ?? null)->toBeInt(); expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
});
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
Bus::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user);
$tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'is_default' => true,
]);
$verificationRun = OperationRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'succeeded',
'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()),
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->firstOrFail();
$session->update([
'state' => array_merge($session->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
]),
]);
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->firstOrFail();
$inventoryRun->forceFill([
'status' => 'completed',
'outcome' => 'succeeded',
])->save();
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(1);
$session->refresh();
$runs = $session->state['bootstrap_operation_runs'] ?? [];
expect($runs['inventory_sync'] ?? null)->toBeInt();
expect($runs['compliance.snapshot'] ?? null)->toBeInt(); expect($runs['compliance.snapshot'] ?? null)->toBeInt();
}); });

View File

@ -104,6 +104,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
->get(route('admin.evidence.overview')) ->get(route('admin.evidence.overview'))
->assertOk() ->assertOk()
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA)) ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA, panel: 'tenant'))
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied)); ->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied, panel: 'tenant'));
}); });

View File

@ -43,7 +43,7 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]) ->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Artifact truth') ->assertSee('Artifact truth')
->assertSee($explanation?->headline ?? '') ->assertSee('The snapshot finished processing, but its evidence basis is incomplete.')
->assertSee($explanation?->evaluationResultLabel() ?? '') ->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Partially complete') ->assertSee('Partially complete')

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
use Tests\TestCase;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
function governanceVisibleText(Testable $component): string
{
$html = $component->html();
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
}
function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, OperationRun $run): Testable
{
Filament::setTenant(null, true);
$testCase->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
return Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('renders a summary-first hierarchy for zero-output baseline compare runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_compare',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'baseline_compare' => [
'reason_code' => 'coverage_unproven',
'coverage' => [
'proof' => false,
],
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'errors_recorded' => 1,
],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Decision')
->assertSee('Artifact impact')
->assertSee('Dominant cause')
->assertSee('Primary next step')
->assertSee('The compare finished, but no decision-grade result is available yet.')
->assertSee('Artifact truth details')
->assertSee('Monitoring detail');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'))
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Monitoring detail'))
->and($pageText)->toContain('no decision-grade result is available yet');
});
it('keeps blocked baseline capture summaries ahead of diagnostics without adding new run-detail actions', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => 'missing_capability',
'baseline_capture' => [
'subjects_total' => 0,
'gaps' => [
'count' => 0,
],
],
],
'failure_summary' => [[
'reason_code' => 'missing_capability',
'message' => 'A required capability is missing for this run.',
]],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertActionVisible('operate_hub_back_to_operations')
->assertActionVisible('refresh')
->assertSee('Blocked by prerequisite')
->assertSee('No baseline was captured')
->assertSee('Artifact impact')
->assertSee('Dominant cause');
$pageText = governanceVisibleText($component);
expect(mb_substr_count($pageText, 'No baseline was captured'))->toBe(1)
->and(mb_strpos($pageText, 'No baseline was captured'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows processing outcome separately from artifact impact for stale evidence snapshot runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.evidence.snapshot.generate');
$this->makeStaleArtifactTruthEvidenceSnapshot(
tenant: $tenant,
snapshotOverrides: [
'operation_run_id' => (int) $run->getKey(),
],
);
governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Outcome')
->assertSee('Artifact impact')
->assertSee('Completed successfully')
->assertSee('The snapshot finished processing, but its evidence basis is already stale.')
->assertSee('Result trust');
});
it('preserves a dominant cause plus secondary causes for degraded review composition runs', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = $this->makeArtifactTruthRun($tenant, 'tenant.review.compose');
$snapshot = $this->makeStaleArtifactTruthEvidenceSnapshot($tenant);
$this->makeArtifactTruthReview(
tenant: $tenant,
user: $user,
snapshot: $snapshot,
reviewOverrides: [
'operation_run_id' => (int) $run->getKey(),
'completeness_state' => 'partial',
],
summaryOverrides: [
'section_state_counts' => [
'complete' => 4,
'partial' => 1,
'missing' => 1,
'stale' => 0,
],
],
);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('Dominant cause')
->assertSee('Missing sections')
->assertSee('Secondary causes')
->assertSee('Stale evidence basis');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
->and($pageText)->toContain('stale evidence');
});

View File

@ -19,7 +19,7 @@
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -19,7 +19,7 @@
$run = OperationRun::factory()->for($tenant)->create([ $run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => (int) $backupSet->getKey(), 'backup_set_id' => (int) $backupSet->getKey(),
], ],

View File

@ -107,7 +107,7 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
$run = OperationRun::factory()->minimal()->forTenant($tenant)->create([ $run = OperationRun::factory()->minimal()->forTenant($tenant)->create([
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'context' => [ 'context' => [
'backup_set_id' => 123, 'backup_set_id' => 123,
], ],

View File

@ -182,7 +182,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'context' => [ 'context' => [

View File

@ -397,8 +397,8 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('Complete onboarding') ->assertSee('Complete onboarding')
->assertDontSee('Activate tenant') ->assertDontSee('Activate tenant')
->assertDontSee('Restore') ->assertDontSeeText('Restore tenant')
->assertDontSee('Archive') ->assertDontSeeText('Archive tenant')
->assertSee('After completion'); ->assertSee('After completion');
}); });

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun;
use App\Notifications\OperationRunCompleted;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeProviderBlockedRun(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
return [$user, $tenant, $run];
}
it('reuses translated provider-backed blocker language on the canonical run detail page', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$component = Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
$banner = $component->instance()->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});
it('keeps terminal notification reason translation aligned with the canonical provider-backed run detail', function (): void {
[$user, , $run] = makeProviderBlockedRun();
$this->actingAs($user);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
$payload = (new OperationRunCompleted($run))->toDatabase($user);
expect($payload['title'] ?? null)->toBe('Inventory sync blocked by prerequisite');
expect($payload['body'] ?? null)->toContain('Provider connection required');
expect($payload['body'] ?? null)->toContain('usable provider connection');
expect($payload['reason_translation']['operator_label'] ?? null)->toContain('Provider connection required');
expect($payload['reason_translation']['short_explanation'] ?? null)->toContain('usable provider connection');
expect($payload['diagnostic_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
expect($banner['body'] ?? '')->toContain('Provider connection required');
});
it('keeps the same blocked provider-backed vocabulary for system-initiated runs on canonical detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'user_id' => null,
'initiator_name' => 'Scheduled automation',
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'provider' => 'microsoft',
'module' => 'inventory',
'provider_connection_id' => 999999,
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
'blocked_by' => 'provider_preflight',
'target_scope' => [
'entra_tenant_id' => $tenant->tenant_id,
],
],
]);
$banner = Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->instance()
->blockedExecutionBanner();
expect($banner)->not->toBeNull();
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
expect($banner['body'] ?? null)->toContain('Provider connection required');
expect($banner['body'] ?? null)->toContain('usable provider connection');
});

View File

@ -496,7 +496,7 @@
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => OperationRunStatus::Completed->value, 'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value, 'outcome' => OperationRunOutcome::Succeeded->value,
]); ]);

View File

@ -54,7 +54,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'backup_set.add_policies', 'type' => 'backup_set.update',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'context' => ['options' => ['include_foundations' => true]], 'context' => ['options' => ['include_foundations' => true]],

View File

@ -45,3 +45,30 @@
expect($runs->pluck('status')->unique()->values()->all())->toEqualCanonicalizing(['queued', 'running']); expect($runs->pluck('status')->unique()->values()->all())->toEqualCanonicalizing(['queued', 'running']);
expect($runs->pluck('user_id')->all())->toContain($otherUser->id); expect($runs->pluck('user_id')->all())->toContain($otherUser->id);
})->group('ops-ux'); })->group('ops-ux');
it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => $tenant->id,
'workspace_id' => $tenant->workspace_id,
'user_id' => $user->id,
'type' => $operationType,
'status' => 'queued',
'outcome' => 'pending',
'started_at' => null,
'created_at' => now()->subMinutes(20),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
expect($component->get('runs'))->toBeInstanceOf(Collection::class)
->and($component->get('runs'))->toHaveCount(0)
->and($component->get('hasActiveRuns'))->toBeFalse();
})->with([
'backup set update' => 'backup_set.update',
])->group('ops-ux');

View File

@ -22,7 +22,7 @@
})->group('ops-ux'); })->group('ops-ux');
it('builds canonical already-queued toast copy', function (): void { it('builds canonical already-queued toast copy', function (): void {
$toast = OperationUxPresenter::alreadyQueuedToast('backup_set.add_policies'); $toast = OperationUxPresenter::alreadyQueuedToast('backup_set.update');
expect($toast->getTitle())->toBe('Backup set update already queued'); expect($toast->getTitle())->toBe('Backup set update already queued');
expect($toast->getBody())->toBe('A matching operation is already queued or running. No action needed unless it stays stuck.'); expect($toast->getBody())->toBe('A matching operation is already queued or running. No action needed unless it stays stuck.');

View File

@ -0,0 +1,90 @@
<?php
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\OperationRunLinks;
use App\Support\Providers\ProviderReasonCodes;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns scope-busy semantics when a different provider operation is already active for the same connection', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'consent_status' => 'granted',
]);
$component = Livewire::test(ListProviderConnections::class);
$component->callTableAction('inventory_sync', $connection);
$component->callTableAction('compliance_snapshot', $connection);
$inventoryRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'inventory_sync')
->latest('id')
->first();
expect($inventoryRun)->not->toBeNull();
expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'compliance.snapshot')
->count())->toBe(0);
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0);
$notifications = session('filament.notifications', []);
expect($notifications)->not->toBeEmpty();
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
->toBe(OperationRunLinks::view($inventoryRun, $tenant));
});
it('blocks provider connection checks with shared guidance and does not enqueue work', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'consent_status' => 'granted',
'is_default' => true,
]);
Livewire::test(ListProviderConnections::class)
->callTableAction('check_connection', $connection);
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect($run?->outcome)->toBe('blocked');
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
Queue::assertNothingPushed();
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
});

View File

@ -150,5 +150,5 @@
Queue::assertNothingPushed(); Queue::assertNothingPushed();
expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse();
}); });

View File

@ -136,6 +136,6 @@
Queue::assertNothingPushed(); Queue::assertNothingPushed();
Queue::assertNotPushed(RemovePoliciesFromBackupSetJob::class); Queue::assertNotPushed(RemovePoliciesFromBackupSetJob::class);
expect(OperationRun::query()->where('type', 'backup_set.remove_policies')->exists())->toBeFalse(); expect(OperationRun::query()->where('type', 'backup_set.update')->exists())->toBeFalse();
}); });
}); });

View File

@ -0,0 +1,163 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\RestoreRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
function seedRestoreStartContext(bool $withProviderConnection = true): array
{
$tenant = Tenant::create([
'tenant_id' => fake()->uuid(),
'name' => 'Restore Tenant',
'metadata' => [],
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
if ($withProviderConnection) {
ensureDefaultProviderConnection($tenant, 'microsoft');
}
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'display_name' => 'Device Config Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => [
'displayName' => 'Backup Policy',
],
]);
$user = User::factory()->create([
'email' => 'restore@example.com',
'name' => 'Restore Operator',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
return [$tenant, $backupSet, $backupItem, $user];
}
it('starts restore execution with explicit provider connection context', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext();
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Restore Tenant',
])
->call('create')
->assertHasNoFormErrors();
$restoreRun = RestoreRun::query()->latest('id')->first();
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($restoreRun)->not->toBeNull();
expect($restoreRun?->status)->toBe(RestoreRunStatus::Queued->value);
expect($operationRun)->not->toBeNull();
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($restoreRun, $operationRun): bool {
return $job->restoreRunId === (int) $restoreRun?->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($operationRun);
});
});
it('blocks restore reruns before queue when no provider connection is available', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(withProviderConnection: false);
$this->actingAs($user);
$run = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'status' => 'failed',
'is_dry_run' => false,
'requested_items' => [$backupItem->id],
'group_mapping' => [],
]);
Livewire::test(ListRestoreRuns::class)
->callTableAction('rerun', $run);
expect(RestoreRun::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
$operationRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($operationRun)->not->toBeNull();
expect($operationRun?->outcome)->toBe('blocked');
expect($operationRun?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
Bus::assertNotDispatched(ExecuteRestoreRunJob::class);
});

View File

@ -189,10 +189,12 @@
expect($operationRun)->not->toBeNull(); expect($operationRun)->not->toBeNull();
expect($operationRun?->status)->toBe('queued'); expect($operationRun?->status)->toBe('queued');
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey()); expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0)); expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool { Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
return $job->restoreRunId === (int) $run->getKey() return $job->restoreRunId === (int) $run->getKey()
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
&& $job->operationRun instanceof OperationRun && $job->operationRun instanceof OperationRun
&& $job->operationRun->getKey() === $operationRun?->getKey(); && $job->operationRun->getKey() === $operationRun?->getKey();
}); });

View File

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Providers\ProviderOperationStartResult;
use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -20,19 +21,31 @@
'status' => 'active', 'status' => 'active',
]); ]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: 'owner',
fixtureProfile: 'credential-enabled',
);
$service = app(RoleDefinitionsSyncService::class); $service = app(RoleDefinitionsSyncService::class);
$run = $service->startManualSync($tenant, $user); $result = $service->startManualSync($tenant, $user);
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class);
expect($result->status)->toBe('started');
$run = $result->run;
expect($run->type)->toBe('directory_role_definitions.sync'); expect($run->type)->toBe('directory_role_definitions.sync');
expect($run->context['provider_connection_id'] ?? null)->toBeInt();
$url = OperationRunLinks::tenantlessView($run); $url = OperationRunLinks::tenantlessView($run);
expect($url)->toContain('/admin/operations/'); expect($url)->toContain('/admin/operations/');
Bus::assertDispatched( Bus::assertDispatched(
App\Jobs\SyncRoleDefinitionsJob::class, App\Jobs\SyncRoleDefinitionsJob::class,
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run) fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey()
&& $job->providerConnectionId === ($run->context['provider_connection_id'] ?? null)
&& $job->operationRun?->is($run)
); );
}); });

Some files were not shown because too many files have changed in this diff Show More