Compare commits
3 Commits
216-provid
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bd06b479e1 | |||
| c86b399b43 | |||
| a089350f98 |
26
.github/agents/copilot-instructions.md
vendored
26
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 ?? '');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|
||||||
|
|||||||
@ -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([
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.'),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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' => [
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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.')
|
||||||
|
|||||||
@ -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.');
|
||||||
|
|||||||
@ -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' => [
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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' => [
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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]],
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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.');
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user