Compare commits
2 Commits
216-provid
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c86b399b43 | |||
| a089350f98 |
22
.github/agents/copilot-instructions.md
vendored
22
.github/agents/copilot-instructions.md
vendored
@ -216,8 +216,12 @@ ## 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -252,10 +256,20 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||||
|
- 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`
|
||||||
- 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
|
- 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
|
||||||
- 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
|
|
||||||
- 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
|
|
||||||
- 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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
foreach ($types as $operationType) {
|
|
||||||
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
|
||||||
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
|
||||||
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
|
||||||
|
|
||||||
$toast = $wasCreated
|
|
||||||
? OperationUxPresenter::queuedToast($operationType)
|
|
||||||
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
|
||||||
|
|
||||||
if ($runUrl !== null) {
|
|
||||||
$toast->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$toast->send();
|
$notification->send();
|
||||||
|
|
||||||
|
if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Continue bootstrap after this run finishes')
|
||||||
|
->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
|
||||||
|
->info()
|
||||||
|
->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);
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
$runUrl = OperationRunLinks::view($result->run, $tenant);
|
||||||
Notification::make()
|
$extraActions = $result->status === 'started'
|
||||||
->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')
|
Actions\Action::make('manage_connections')
|
||||||
->label('Manage Provider Connections')
|
->label('Manage Provider Connections')
|
||||||
->url(static::getUrl('index', tenant: $tenant)),
|
->url(static::getUrl('index', tenant: $tenant)),
|
||||||
])
|
];
|
||||||
->send();
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Connection check blocked',
|
||||||
|
runUrl: $runUrl,
|
||||||
|
extraActions: $extraActions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
$notification->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')
|
|
||||||
->body('Reusing the active restore run.');
|
|
||||||
|
|
||||||
if ($existingOpRun) {
|
|
||||||
$toast->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'restore.queued',
|
action: 'restore.queued',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'restore_run_id' => $restoreRun->id,
|
'restore_run_id' => $queuedRestoreRun->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
resourceType: 'restore_run',
|
resourceType: 'restore_run',
|
||||||
resourceId: (string) $restoreRun->id,
|
resourceId: (string) $queuedRestoreRun->id,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
);
|
);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
|
||||||
$runs = app(OperationRunService::class);
|
? (int) $context['provider_connection_id']
|
||||||
$initiator = auth()->user();
|
: null;
|
||||||
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
|
||||||
|
|
||||||
$opRun = $runs->ensureRun(
|
ExecuteRestoreRunJob::dispatch(
|
||||||
|
restoreRunId: (int) $queuedRestoreRun->getKey(),
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
operationRun: $run,
|
||||||
|
providerConnectionId: $providerConnectionId,
|
||||||
|
)->afterCommit();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'restore.execute',
|
connection: null,
|
||||||
inputs: [
|
operationType: 'restore.execute',
|
||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
dispatcher: $dispatcher,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
'is_dry_run' => false,
|
||||||
'execution_authority_mode' => 'actor_bound',
|
'execution_authority_mode' => 'actor_bound',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'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,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
if ($run->wasRecentlyCreated) {
|
||||||
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
|
$dispatcher($run);
|
||||||
|
|
||||||
|
$result = ProviderOperationStartResult::started($run, true);
|
||||||
|
} else {
|
||||||
|
$result = ProviderOperationStartResult::deduped($run);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
|
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
|
||||||
|
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
if (is_numeric($restoreRunId)) {
|
||||||
->actions([
|
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
|
||||||
Actions\Action::make('view_run')
|
}
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return $restoreRun->refresh();
|
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$idempotencyKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$result, $queuedRestoreRun?->refresh()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
*/
|
||||||
|
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
|
||||||
|
{
|
||||||
|
$query = $backupSet->items()->select(['id', 'policy_type']);
|
||||||
|
|
||||||
|
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->contains(function (BackupItem $item): bool {
|
||||||
|
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
|
||||||
|
|
||||||
|
return $restoreMode !== 'preview-only';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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 {
|
||||||
'selection_key' => $selectionKey,
|
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||||
'trigger' => 'manual',
|
? (int) $run->context['provider_connection_id']
|
||||||
],
|
: null;
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
EntraGroupSyncJob::dispatch(
|
||||||
return $opRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
selectionKey: $selectionKey,
|
selectionKey: $selectionKey,
|
||||||
slotKey: null,
|
slotKey: null,
|
||||||
runId: null,
|
runId: null,
|
||||||
operationRun: $opRun,
|
providerConnectionId: $providerConnectionId,
|
||||||
));
|
operationRun: $run,
|
||||||
|
)->afterCommit();
|
||||||
return $opRun;
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'trigger' => 'manual',
|
||||||
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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
|
||||||
{
|
{
|
||||||
|
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');
|
$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',
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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.')
|
||||||
|
|||||||
@ -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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
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('starts tenant verification with explicit connection context and dedupes repeat starts', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$component = Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]);
|
||||||
|
|
||||||
|
$component->callAction('verify');
|
||||||
|
$component->callAction('verify');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context)->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'health_check',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks tenant verification before queue when no provider connection is available', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->callAction('verify');
|
||||||
|
|
||||||
|
$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::ProviderConnectionMissing);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns scope-busy semantics for onboarding verification when another run is active for the same connection scope', 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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenantGuid = '10101010-1010-1010-1010-101010101010';
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||||
|
$component->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Acme',
|
||||||
|
'primary_domain' => 'acme.example',
|
||||||
|
'notes' => 'Provider start test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
|
||||||
|
$component->call('startVerification');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', 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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenantGuid = '20202020-2020-2020-2020-202020202020';
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||||
|
$component->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Acme',
|
||||||
|
'primary_domain' => 'acme.example',
|
||||||
|
'notes' => 'Provider start test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->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-provider-start-'.(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();
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
$inventoryRun->forceFill([
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1);
|
||||||
|
Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1);
|
||||||
|
});
|
||||||
@ -71,7 +71,7 @@
|
|||||||
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
|
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant routes when workspace context is missing', function (): void {
|
it('uses the routed tenant workspace on tenant routes when workspace context is missing', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(TenantDashboard::getUrl(tenant: $tenant))
|
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
->assertNotFound();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
|
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
|
||||||
|
|||||||
@ -535,6 +535,34 @@ public static function families(): array
|
|||||||
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
|
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
|
||||||
'validationStatus' => 'guarded',
|
'validationStatus' => 'guarded',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'familyId' => 'provider-dispatch-gate-coverage',
|
||||||
|
'classificationId' => 'surface-guard',
|
||||||
|
'purpose' => 'Keep the first-slice provider-backed start hosts on canonical ProviderOperationStartGate-owned entry points instead of route-bounded direct-dispatch bypasses.',
|
||||||
|
'currentLaneId' => 'heavy-governance',
|
||||||
|
'targetLaneId' => 'heavy-governance',
|
||||||
|
'selectors' => [
|
||||||
|
[
|
||||||
|
'selectorType' => 'group',
|
||||||
|
'selectorValue' => 'surface-guard',
|
||||||
|
'selectorRole' => 'include',
|
||||||
|
'sourceOfTruth' => 'pest-group',
|
||||||
|
'rationale' => 'The bypass guard spans multiple route-bounded start surfaces and belongs with heavy governance checks.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selectorType' => 'file',
|
||||||
|
'selectorValue' => 'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
|
||||||
|
'selectorRole' => 'inventory-only',
|
||||||
|
'sourceOfTruth' => 'manifest',
|
||||||
|
'rationale' => 'Canonical guard for first-slice provider-backed dispatch-gate coverage.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'hotspotFiles' => [
|
||||||
|
'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
|
||||||
|
],
|
||||||
|
'costSignals' => ['route-bounded surface scan', 'start-host governance breadth', 'gate adoption regression detection'],
|
||||||
|
'validationStatus' => 'guarded',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'familyId' => 'policy-resource-admin-search-parity',
|
'familyId' => 'policy-resource-admin-search-parity',
|
||||||
'classificationId' => 'discovery-heavy',
|
'classificationId' => 'discovery-heavy',
|
||||||
@ -1175,6 +1203,16 @@ public static function budgetTargets(): array
|
|||||||
'lifecycleState' => 'documented',
|
'lifecycleState' => 'documented',
|
||||||
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'budgetId' => 'family-provider-dispatch-gate-coverage',
|
||||||
|
'targetType' => 'family',
|
||||||
|
'targetId' => 'provider-dispatch-gate-coverage',
|
||||||
|
'thresholdSeconds' => 20,
|
||||||
|
'baselineSource' => 'measured-current-suite',
|
||||||
|
'enforcement' => 'warn',
|
||||||
|
'lifecycleState' => 'documented',
|
||||||
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'budgetId' => 'family-policy-resource-admin-search-parity',
|
'budgetId' => 'family-policy-resource-admin-search-parity',
|
||||||
'targetType' => 'family',
|
'targetType' => 'family',
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
'policy.sync_one',
|
'policy.sync_one',
|
||||||
'entra_group_sync',
|
'entra_group_sync',
|
||||||
'directory_role_definitions.sync',
|
'directory_role_definitions.sync',
|
||||||
|
'backup_set.update',
|
||||||
'backup_schedule_run',
|
'backup_schedule_run',
|
||||||
'restore.execute',
|
'restore.execute',
|
||||||
'tenant.review_pack.generate',
|
'tenant.review_pack.generate',
|
||||||
@ -40,6 +41,8 @@
|
|||||||
|
|
||||||
expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300)
|
expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300)
|
||||||
->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue()
|
->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue()
|
||||||
|
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
|
||||||
|
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
|
||||||
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
|
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)
|
||||||
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
|
->and($validator->jobFailsOnTimeout('restore.execute'))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
@ -157,3 +158,123 @@
|
|||||||
expect($result->run->context['verification_report'] ?? null)->toBeArray();
|
expect($result->run->context['verification_report'] ?? null)->toBeArray();
|
||||||
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts restore execution with explicit provider connection binding and operation capability metadata', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'restore-entra-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'restore.execute',
|
||||||
|
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
|
||||||
|
expect($run->type)->toBe('restore.execute');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(1);
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
expect($result->dispatched)->toBeTrue();
|
||||||
|
|
||||||
|
$run = $result->run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->context)->toMatchArray([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => 'restore-entra-tenant-id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'entra_group_sync',
|
||||||
|
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
|
||||||
|
expect($run->type)->toBe('entra_group_sync');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(1);
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
expect($result->dispatched)->toBeTrue();
|
||||||
|
|
||||||
|
$run = $result->run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->context)->toMatchArray([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$blocking = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'compliance.snapshot',
|
||||||
|
dispatcher: function () use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(0);
|
||||||
|
expect($result->status)->toBe('scope_busy');
|
||||||
|
expect($result->run->getKey())->toBe($blocking->getKey());
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('builds queued notifications for accepted provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'queued',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::started($run, true),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
extraActions: [
|
||||||
|
Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url('/provider-connections'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$actions = collect($notification->getActions());
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Provider connection check queued')
|
||||||
|
->and($notification->getBody())->toBe('Queued for execution. Open the operation for progress and next steps.')
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
|
||||||
|
'view_run',
|
||||||
|
'manage_connections',
|
||||||
|
])
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
|
||||||
|
'Open operation',
|
||||||
|
'Manage Provider Connections',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds already-running notifications for deduped provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::deduped($run),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Provider connection check already running')
|
||||||
|
->and($notification->getBody())->toBe('A matching operation is already queued or running. Open the operation for progress and next steps.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds scope-busy notifications for conflicting provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::scopeBusy($run),
|
||||||
|
blockedTitle: 'Inventory sync blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Scope busy')
|
||||||
|
->and($notification->getBody())->toBe('Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds blocked notifications from translated reason detail and first next step', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$reasonEnvelope = new ReasonResolutionEnvelope(
|
||||||
|
internalCode: 'provider_consent_missing',
|
||||||
|
operatorLabel: 'Admin consent required',
|
||||||
|
shortExplanation: 'Grant admin consent for this provider connection before retrying.',
|
||||||
|
actionability: 'prerequisite_missing',
|
||||||
|
nextSteps: [
|
||||||
|
NextStepOption::link('Grant admin consent', '/provider-connections/1/consent'),
|
||||||
|
NextStepOption::link('Open provider settings', '/provider-connections/1'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'reason_code' => 'provider_consent_missing',
|
||||||
|
'reason_translation' => $reasonEnvelope->toArray(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::blocked($run),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
extraActions: [
|
||||||
|
Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url('/provider-connections'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$actions = collect($notification->getActions());
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Verification blocked')
|
||||||
|
->and($notification->getBody())->toBe("Admin consent required\nGrant admin consent for this provider connection before retrying.\nNext step: Grant admin consent.")
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
|
||||||
|
'view_run',
|
||||||
|
'next_step_0',
|
||||||
|
'manage_connections',
|
||||||
|
])
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
|
||||||
|
'Open operation',
|
||||||
|
'Grant admin consent',
|
||||||
|
'Manage Provider Connections',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -16,17 +16,31 @@
|
|||||||
->toContain('inventory_sync', 'provider.inventory.sync');
|
->toContain('inventory_sync', 'provider.inventory.sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves canonical backup set update values without treating them as legacy aliases', function (): void {
|
||||||
|
$resolution = OperationCatalog::resolve('backup_set.update');
|
||||||
|
|
||||||
|
expect($resolution->canonical->canonicalCode)->toBe('backup_set.update')
|
||||||
|
->and($resolution->canonical->displayLabel)->toBe('Backup set update')
|
||||||
|
->and($resolution->aliasStatus)->toBe('canonical')
|
||||||
|
->and($resolution->wasLegacyAlias)->toBeFalse()
|
||||||
|
->and(array_map(static fn ($alias): string => $alias->rawValue, $resolution->aliasesConsidered))
|
||||||
|
->toBe(['backup_set.update']);
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void {
|
it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void {
|
||||||
expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([
|
expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([
|
||||||
'inventory.sync' => 'Inventory sync',
|
'inventory.sync' => 'Inventory sync',
|
||||||
'policy.sync' => 'Policy sync',
|
'policy.sync' => 'Policy sync',
|
||||||
])->and(OperationCatalog::rawValuesForCanonical('inventory.sync'))
|
])->and(OperationCatalog::rawValuesForCanonical('inventory.sync'))
|
||||||
->toContain('inventory_sync', 'provider.inventory.sync');
|
->toContain('inventory_sync', 'provider.inventory.sync')
|
||||||
|
->and(OperationCatalog::rawValuesForCanonical('backup_set.update'))
|
||||||
|
->toBe(['backup_set.update']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps enum-backed storage values to canonical operation codes', function (): void {
|
it('maps enum-backed storage values to canonical operation codes', function (): void {
|
||||||
expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare')
|
expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare')
|
||||||
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync');
|
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync')
|
||||||
|
->and(OperationRunType::BackupSetUpdate->canonicalCode())->toBe('backup_set.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
|
||||||
|
|||||||
@ -87,7 +87,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(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Roadmap
|
|||||||
> Strategic thematic blocks and release trajectory.
|
> Strategic thematic blocks and release trajectory.
|
||||||
> This is the "big picture" — not individual specs.
|
> This is the "big picture" — not individual specs.
|
||||||
|
|
||||||
**Last updated**: 2026-04-17
|
**Last updated**: 2026-04-20
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -25,8 +25,8 @@ ### Governance & Architecture Hardening
|
|||||||
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands.
|
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands.
|
||||||
|
|
||||||
**Active specs**: 144
|
**Active specs**: 144
|
||||||
**Next wave candidates**: queued execution reauthorization and scope continuity, tenant-owned query canon and wrong-tenant guards, findings workflow enforcement and audit backstop, Livewire context locking and trusted-state reduction
|
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate)
|
||||||
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy → Reason Code Translation → Artifact Truth Semantics → Governance Operator Outcome Compression; Provider Dispatch Gate Unification continues as the adjacent hardening lane (see spec-candidates.md — "Operator Truth Initiative" sequencing note)
|
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations remains the next open adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
|
||||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||||
|
|
||||||
### UI & Product Maturity Polish
|
### UI & Product Maturity Polish
|
||||||
@ -72,8 +72,8 @@ ## Planned (Next Quarter)
|
|||||||
|
|
||||||
### R2 Completion — Evidence & Exception Workflows
|
### R2 Completion — Evidence & Exception Workflows
|
||||||
- Review pack export (Spec 109 — done)
|
- Review pack export (Spec 109 — done)
|
||||||
- Exception/risk-acceptance workflow for Findings → **Not yet specced**
|
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
||||||
- Formal "evidence pack" entity → **Not yet specced**
|
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
|
||||||
- Workspace-level PII override for review packs → deferred from 109
|
- Workspace-level PII override for review packs → deferred from 109
|
||||||
|
|
||||||
### Findings Workflow v2 / Execution Layer
|
### Findings Workflow v2 / Execution Layer
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-19 (cleaned candidates already promoted to specs)
|
**Last reviewed**: 2026-04-20 (reconciled promoted candidates with current specs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -41,6 +41,8 @@ ## Promoted to Spec
|
|||||||
- Operator Explanation Layer for Degraded / Partial / Suppressed Results → Spec 161 (`operator-explanation-layer`)
|
- Operator Explanation Layer for Degraded / Partial / Suppressed Results → Spec 161 (`operator-explanation-layer`)
|
||||||
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
|
- Request-Scoped Derived State and Resolver Memoization → Spec 167 (`derived-state-memoization`)
|
||||||
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
|
- Tenant Governance Aggregate Contract → Spec 168 (`tenant-governance-aggregate-contract`)
|
||||||
|
- Governance Operator Outcome Compression → Spec 214 (`governance-outcome-compression`)
|
||||||
|
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||||
- Governance Friction & Operator Vocabulary Hardening → Spec 194 (`governance-friction-hardening`)
|
- Governance Friction & Operator Vocabulary Hardening → Spec 194 (`governance-friction-hardening`)
|
||||||
@ -142,91 +144,6 @@ ### Baseline Capture Truthful Outcomes and Upstream Guardrails
|
|||||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
- **Dependencies**: Baseline drift engine stable (Specs 116–119), inventory coverage context, canonical operation-run presentation work (Specs 054, 114, 144), audit log foundation (Spec 134), tenant operability and execution legitimacy direction (Specs 148, 149)
|
||||||
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
- **Related specs / candidates**: Spec 101 (baseline governance), Specs 116–119 (baseline drift engine), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization), `discoveries.md` entry "Drift engine hard-fail when no Inventory Sync exists"
|
||||||
|
|
||||||
### Provider-Backed Action Preflight and Dispatch Gate Unification
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: prerequisite-handling architecture analysis, provider dispatch gate architecture review, semantic clarity audit 2026-03-21
|
|
||||||
- **Problem**: TenantPilot has two generations of provider-backed action dispatch patterns that produce inconsistent operator experiences for the same class of problem (missing prerequisites, blocked execution, concurrency collision):
|
|
||||||
- **Gen 2 pattern** (correct, ~3 job types): `ProviderInventorySyncJob`, `ProviderConnectionHealthCheckJob`, `ProviderComplianceSnapshotJob` receive an explicit `providerConnectionId`, pass through `ProviderOperationStartGate` at dispatch time, and produce structured `ProviderOperationStartResult` envelopes with 4 clear states (`started`, `deduped`, `scope_busy`, `blocked`) plus structured reason codes and next-steps. Operators learn about blocked conditions **before** the job is queued.
|
|
||||||
- **Gen 1 pattern** (inconsistent, ~20 services + their jobs): `ExecuteRestoreRunJob`, `EntraGroupSyncJob`, `SyncRoleDefinitionsJob`, policy sync jobs, and approximately 17 other services resolve connections implicitly at runtime via `MicrosoftGraphOptionsResolver::resolveForTenant()` or internal `resolveProviderConnection()` methods. These services bypass the gate entirely — prerequisites are not checked before dispatch. Blocked conditions are discovered asynchronously during job execution, producing runtime exceptions (`ProviderConfigurationRequiredException`, `RuntimeException`, `InvalidArgumentException`) that surface to operators as after-the-fact failed runs rather than preventable preflight blocks.
|
|
||||||
- **Operator impact**: the same class of problem (missing provider connection, expired consent, invalid credentials, scope busy) produces two different operator experiences depending on which action triggered it. Gen 2 actions produce a clear "blocked" result with reason code and next-step guidance at the moment the operator clicks the button. Gen 1 actions silently queue, then fail asynchronously — the operator discovers the problem only when checking the operation run later, with a raw error message instead of structured guidance.
|
|
||||||
- **Concurrency and deduplication gaps**: the `ProviderOperationStartGate` handles scope_busy / deduplication for Gen 2 operations, but Gen 1 operations have no equivalent deduplication — multiple restore or sync jobs for the same tenant/scope can be queued simultaneously, competing for the same provider connection without coordination.
|
|
||||||
- **Notification inconsistency**: Gen 2 blocked results produce immediate toast/notification via `ProviderOperationStartResult` rendering in Filament actions. Gen 1 failures produce terminal `OperationRunCompleted` notifications with sanitized but still technical failure messages. The operator receives different feedback patterns for equivalent problems.
|
|
||||||
- **Why it matters now**: As TenantPilot adds more provider domains (Entra roles, enterprise apps, SharePoint sharing), more operation types (baseline capture, drift detection, evidence generation), and more governance workflows (restore, review, compliance snapshot), every new provider-backed action that follows the Gen 1 implicit pattern reproduces the same operator experience gap. The Gen 2 pattern is proven, architecturally correct, and already handles the hard problems (connection locking, stale run detection, structured reason codes). The gap is not design — it is incomplete adoption. Additionally, the "Provider Connection Resolution Normalization" candidate addresses the backend plumbing problem (explicit connection ID passing), but does not address the operator-facing preflight/dispatch gate UX pattern. This candidate addresses the operator experience layer: ensuring that all provider-backed actions follow one canonical start path and that operators receive consistent, structured, before-dispatch feedback about prerequisites.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- **Canonical dispatch entry point for all provider-backed actions**: all operator-triggered provider-backed actions (sync, backup, restore, health check, compliance snapshot, baseline capture, evidence generation, and future provider operations) must pass through a canonical preflight/dispatch gate before queuing. The existing `ProviderOperationStartGate` is the reference implementation; this candidate extends its scope to cover all provider-backed operation types, not just the current 3.
|
|
||||||
- **Structured preflight result presentation contract**: define a shared Filament action result-rendering pattern for `ProviderOperationStartResult` states (`started`, `deduped`, `scope_busy`, `blocked`) so that every provider-backed action button produces the same UX feedback pattern. Currently, each Gen 2 consumer renders gate results with local if/else blocks — this should be a shared presenter or action mixin.
|
|
||||||
- **Pre-queue prerequisite detection**: blocked conditions (missing connection, expired consent, invalid credentials, tenant not operable, scope busy, missing required permissions) must be detected and surfaced to the operator **before** the job is dispatched to the queue. Operators should never discover a preventable prerequisite failure only after checking a terminal `OperationRun` record.
|
|
||||||
- **Dispatch-time connection locking for all operation types**: extend the `FOR UPDATE` row-locking pattern from Gen 2 to all provider-backed operations, preventing concurrent conflicting operations on the same provider connection.
|
|
||||||
- **Deduplication/scope-busy enforcement for all operation types**: extend scope_busy/dedup detection to Gen 1 operations (restore, group sync, role sync, etc.) that currently lack it. Operators should receive "An operation of this type is already running for this tenant" feedback at click time, not discover it through a failed run.
|
|
||||||
- **Unified next-steps for all blocked states**: extend the `ProviderNextStepsRegistry` pattern (or its successor from the Reason Code Translation candidate) to cover all provider-backed operation blocked states, not just provider connection codes. Every "blocked" gate result includes cause-specific next-action guidance.
|
|
||||||
- **Operator notification alignment**: terminal notifications for provider-backed operations must follow the same structured pattern regardless of which generation of plumbing dispatched them. The notification should include: translated reason code (per Reason Code Translation contract), structured next-action guidance, and a link to the relevant resolution surface.
|
|
||||||
- **Key decisions to encode**:
|
|
||||||
- `ProviderOperationStartGate` (or its evolved successor) is the single canonical dispatch entry point — no provider-backed action bypasses it
|
|
||||||
- Pre-queue prerequisite detection is a product guarantee for all provider operations — async-only failure discovery is an anti-pattern
|
|
||||||
- Scope-busy / deduplication is mandatory for all provider operations, not just Gen 2
|
|
||||||
- The gate result presentation is a shared UI contract, not a per-action local rendering decision
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: extending `ProviderOperationStartGate` scope to all provider-backed operation types, shared gate result presenter for Filament actions, pre-queue prerequisite detection for Gen 1 operations, scope-busy/dedup extension, next-steps enrichment for all gate blocked states, notification alignment for gate results, dispatch-time connection locking extension
|
|
||||||
- **Out of scope**: backend connection resolution refactoring (tracked separately as "Provider Connection Resolution Normalization" — that candidate handles explicit `providerConnectionId` passing; this candidate handles the operator-facing gate/preflight layer), provider connection UX label changes (tracked as "Provider Connection UX Clarity"), legacy credential cleanup (tracked as "Provider Connection Legacy Cleanup"), adding new provider domains (domain expansion specs own that), operation naming vocabulary (tracked separately), reason code translation contract definition (tracked as "Operator Reason Code Translation" — this candidate consumes translated labels)
|
|
||||||
- **Affected workflow families / surfaces**: All provider-backed Filament actions across TenantResource, ProviderConnectionResource, onboarding wizard, and future governance action surfaces. Approximately 20 services currently using Gen 1 implicit resolution. Notification templates for provider-backed operation terminal states. System console triage views for provider-related failures.
|
|
||||||
- **Why this should be one coherent candidate rather than per-action fixes**: The Gen 1 pattern is used by ~20 services. Fixing each service independently would produce 20 local preflight implementations with inconsistent result rendering, different dedup logic, and incompatible notification patterns. The value is the unified contract: one gate, one result envelope, one presenter, one notification pattern. Per-action fixes cannot deliver this convergence.
|
|
||||||
- **Dependencies**: Provider Connection Resolution Normalization (soft dependency — gate unification is more coherent when all services receive explicit connection IDs, but the gate itself can be extended before full backend normalization is complete since `ProviderConnectionResolver::resolveDefault()` already exists). Operator Reason Code Translation (for translated blocked-reason labels in gate results). Operator Outcome Taxonomy (for consistent outcome vocabulary in gate result presentation).
|
|
||||||
- **Related specs / candidates**: Provider Connection Resolution Normalization (backend plumbing), Provider Connection UX Clarity (UX labels), Provider Connection Legacy Cleanup (post-normalization cleanup), Queued Execution Reauthorization (execution-time revalidation — complementary to dispatch-time gating), Baseline Capture Truthful Outcomes (consumes gate results for baseline-specific preconditions), Operator Presentation & Lifecycle Action Hardening (rendering conventions for gate results)
|
|
||||||
- **Strategic sequencing**: Recommended as the adjacent hardening lane after the shared taxonomy and translation work are in place, while governance-surface adoption proceeds through Spec 158 and the governance compression follow-up. It benefits from the vocabulary defined by the Outcome Taxonomy and the humanized labels from Reason Code Translation, but much of the backend extension of `ProviderOperationStartGate` scope can proceed independently and in parallel with governance-surface work.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Governance Operator Outcome Compression
|
|
||||||
- **Type**: hardening
|
|
||||||
- **Source**: product follow-up recommendation 2026-03-23; direct continuation of Spec 158 (`artifact-truth-semantics`)
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Problem**: Spec 158 establishes the correct internal truth model for governance artifacts, but several governance-facing list and summary surfaces still risk exposing too many internal semantic axes as first-class UI language. On baseline, evidence, review, and pack surfaces the product can still read as academically correct but operator-heavy: multiple adjacent status badges, architecture-derived labels, and equal treatment of existence, readiness, freshness, completeness, and publication semantics. Normal operators are forced to synthesize the answer to three simple workflow questions themselves: Is this artifact usable, why not, and what should I do next?
|
|
||||||
- **Why it matters**: This is the cockpit follow-up to Spec 158's engine work. Without it, TenantPilot preserves semantic correctness internally but leaks too much of that structure directly into governance UX. The result is lower scanability, weaker operator confidence, and a real risk that baseline, evidence, review, and pack domains each evolve their own local status dialect despite sharing the same truth foundation. Shipping this follow-up before broader governance expansion stabilizes operator language where MSP admins actually work.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- Introduce a **compressed operator outcome layer** for governance artifacts that consumes the existing `ArtifactTruthEnvelope`, outcome taxonomy, and reason translation contracts without discarding any internal truth dimensions
|
|
||||||
- Define rendering rules that classify each truth dimension as **primary operator view**, **secondary explanatory detail**, or **diagnostics only**
|
|
||||||
- Make list and overview rows answer three questions first: **primary state**, **short reason**, **next action**
|
|
||||||
- Normalize visible operator language so internal architectural terms such as `artifact truth`, `missing_input`, `metadata_only`, or `publication truth` do not dominate primary workflow surfaces
|
|
||||||
- Clarify where **publication readiness** is the primary business statement versus where it is only one secondary dimension, especially for tenant reviews and review packs
|
|
||||||
- Keep diagnostics available on detail and run-detail pages, but demote raw reason structures, fidelity sub-axes, JSON context, and renderer/support facts behind the primary operator explanation
|
|
||||||
- **Primary adoption surfaces**:
|
|
||||||
- Baseline snapshot lists and detail pages
|
|
||||||
- Evidence snapshot lists and detail pages
|
|
||||||
- Evidence overview
|
|
||||||
- Tenant review lists and detail pages
|
|
||||||
- Review register
|
|
||||||
- Review pack lists and detail pages
|
|
||||||
- Shared governance detail templates and artifact-truth presenter surfaces
|
|
||||||
- Artifact-oriented run-detail pages only where the run is explaining baseline, evidence, review, or review-pack truth
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: visible operator labels, list-column hierarchy, detail-page information hierarchy, mapping from artifact-truth envelopes to compressed operator states, explicit separation between default operator view and diagnostic detail, review/pack publication-readiness primacy rules, governance run-detail explanation hierarchy
|
|
||||||
- **Out of scope**: full operations-list redesign, broad visual polish, color or spacing retuning as the primary goal, new semantic foundation axes, broad findings or workspace overview rewrites, compliance/audit PDF output changes, alert routing or notification copy rewrites, domain-model refactors that change the underlying truth representation
|
|
||||||
- **Core product principles to encode**:
|
|
||||||
- One primary operator statement per artifact on scan surfaces
|
|
||||||
- No truth loss: internal artifact truth, reason structures, APIs, audit context, and JSON diagnostics remain intact and available
|
|
||||||
- Diagnostics are second-layer, not the default operator language
|
|
||||||
- Context-specific business language beats architecture-first vocabulary on primary governance surfaces
|
|
||||||
- Lists are scan surfaces, not diagnosis surfaces
|
|
||||||
- **Candidate requirements**:
|
|
||||||
- **R1 Composite operator outcome**: governance artifacts expose a compressed operator-facing outcome derived from the existing truth and reason model
|
|
||||||
- **R2 Primary / secondary / diagnostic rendering rules**: the system defines which semantic dimensions may appear in each rendering tier
|
|
||||||
- **R3 List-surface simplification**: governance lists stop defaulting to multi-column badge explosions for separate semantic axes
|
|
||||||
- **R4 Detail-surface hierarchy**: details lead with outcome, explanation, and next action before diagnostics
|
|
||||||
- **R5 Operator language normalization**: internal architecture terms are translated or removed from primary governance UI
|
|
||||||
- **R6 Review / pack publication clarity**: review and pack surfaces explicitly state when publishability is the main business decision and when it is not
|
|
||||||
- **R7 No truth loss**: APIs, audit, diagnostics, and raw context remain available even when the primary presentation is compressed
|
|
||||||
- **Acceptance points**:
|
|
||||||
- Governance lists no longer present multiple equal-weight semantic badge columns as the default mental model
|
|
||||||
- `artifact truth` and sibling architecture-first labels stop dominating primary operator surfaces
|
|
||||||
- Governance detail pages clearly separate primary state, explanatory reason, next action, and diagnostics
|
|
||||||
- Review and pack surfaces clearly answer whether the artifact is ready to publish or share
|
|
||||||
- Baseline and evidence surfaces clearly answer whether the artifact is trustworthy and usable
|
|
||||||
- Governance run-detail pages make the dominant problem and next action understandable without reading raw JSON
|
|
||||||
- The internal truth model remains fully usable for diagnostics, audit, and downstream APIs
|
|
||||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), shared governance detail templates, review-layer and evidence-domain adoption surfaces already in flight
|
|
||||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Baseline Snapshot Fidelity Semantics candidate, Compliance Readiness & Executive Review Packs candidate
|
|
||||||
- **Strategic sequencing**: Recommended immediately after Spec 158 and before any major additional governance-surface expansion. This is the adoption layer that turns the truth semantics foundation into an operator-tolerable cockpit instead of a direct dump of internal semantic richness.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Humanized Diagnostic Summaries for Governance Operations
|
### Humanized Diagnostic Summaries for Governance Operations
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
- **Source**: operator-facing governance run-detail gap analysis 2026-03-23; follow-up to Specs 156, 157, and 158
|
||||||
@ -254,8 +171,8 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
|||||||
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
- Guidance distinguishes retryable or resumable situations from structural prerequisite problems when the data supports that distinction
|
||||||
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
- Raw JSON remains present for audit and debugging but is clearly secondary in the page hierarchy
|
||||||
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
- **Dependencies**: Spec 156 (operator-outcome-taxonomy), Spec 157 (reason-code-translation), Spec 158 (artifact-truth-semantics), canonical operation viewer work, shared governance detail templates
|
||||||
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Governance Operator Outcome Compression candidate
|
- **Related specs / candidates**: Spec 144 (canonical operation viewer context decoupling), Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Spec 158 (artifact-truth-semantics), Spec 214 (Governance Operator Outcome Compression)
|
||||||
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Governance Operator Outcome Compression. Compression improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
- **Strategic sequencing**: Best treated as the run-detail explainability companion to Spec 214 (Governance Operator Outcome Compression). Spec 214 improves governance artifact scan surfaces; this candidate makes governance run detail self-explanatory once an operator drills in.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
> **Operator Truth Initiative — Sequencing Note**
|
> **Operator Truth Initiative — Sequencing Note**
|
||||||
@ -267,9 +184,9 @@ ### Humanized Diagnostic Summaries for Governance Operations
|
|||||||
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
> 2. **Operator Reason Code Translation and Humanization Contract** — defines the translation bridge from internal codes to operator-facing labels using the Outcome Taxonomy's vocabulary. Can begin in parallel with the taxonomy using pragmatic interim labels, but final convergence depends on the taxonomy.
|
||||||
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
> 3. **Governance Artifact Truthful Outcomes & Fidelity Semantics** — establishes the full internal truth model for governance artifacts, keeping existence, usability, freshness, completeness, publication readiness, and actionability distinct.
|
||||||
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
> 4. **Operator Explanation Layer for Degraded / Partial / Suppressed Results** — defines the cross-cutting interpretation layer that turns internal truth dimensions into operator-readable explanation: multi-dimensional outcome separation (execution, evaluation, reliability, coverage, action), shared explanation patterns for degraded/partial/suppressed states, count semantics rules, "why no findings" patterns, and reliability visibility. Consumes the taxonomy, translation, and artifact-truth foundations; provides the shared explanation pattern library that compression and humanized summaries adopt on their respective surfaces. Baseline Compare is the golden-path reference implementation.
|
||||||
> 5. **Governance Operator Outcome Compression** — applies the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first, while preserving diagnostics as second-layer detail.
|
> 5. **Governance Operator Outcome Compression** — now promoted to Spec 214, applying the foundation and explanation layer to governance workflow surfaces so lists and details answer the operator's primary question first while preserving diagnostics as second-layer detail.
|
||||||
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
> 6. **Humanized Diagnostic Summaries for Governance Operations** — the run-detail explainability companion to compression; makes governance run detail self-explanatory using the explanation patterns established in step 4.
|
||||||
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — extends the proven Gen 2 gate pattern to all provider-backed operations and establishes a shared result presenter. Benefits from the shared vocabulary work but remains a parallel hardening lane rather than a governance-surface adoption slice.
|
> 7. **Provider-Backed Action Preflight and Dispatch Gate Unification** — now promoted to Spec 216, extending the proven Gen 2 gate pattern to all provider-backed operations and establishing a shared result presenter as the adjacent hardening lane.
|
||||||
>
|
>
|
||||||
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
|
> **Why this order rather than the inverse:** The semantic-clarity audit classified the taxonomy problem as P0 (60% of warning badges are false alarms — actively damages operator trust). Reason code translation creates the shared human-facing language. Spec 158 establishes the correct internal truth engine for governance artifacts. The Operator Explanation Layer then defines the cross-cutting interpretation patterns that all downstream surfaces need — the systemwide rules for how degraded, partial, suppressed, and incomplete results are separated, explained, and acted upon. Compression and humanized summaries are adoption slices that apply those patterns to specific surface families (governance artifact lists and governance run details respectively). Gate unification remains highly valuable but is a neighboring hardening lane.
|
||||||
>
|
>
|
||||||
@ -327,7 +244,7 @@ ### Operation Run Active-State Visibility & Stale Escalation
|
|||||||
- tenant-scoped surfaces never show another tenant's runs
|
- tenant-scoped surfaces never show another tenant's runs
|
||||||
- operations list clearly surfaces problematic active runs for fast scan
|
- operations list clearly surfaces problematic active runs for fast scan
|
||||||
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
|
- **Dependencies**: existing operation-run lifecycle policy and stale detection foundations, canonical run viewer work, tenant-local active-run surfaces, operations monitoring list surfaces
|
||||||
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Provider-Backed Action Preflight and Dispatch Gate Unification (neighboring operational hardening lane)
|
- **Related specs / candidates**: Spec 114 (system console / operations monitoring foundations), Spec 144 (canonical operation viewer context decoupling), Spec 149 (queued execution reauthorization and lifecycle continuity), Spec 161 (operator-explanation-layer), Spec 216 (Provider-Backed Action Preflight and Dispatch Gate Unification)
|
||||||
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
@ -380,8 +297,8 @@ ### Baseline Compare Scope Guardrails & Ambiguity Guidance
|
|||||||
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
- R2: too much guidance without technical guardrails might let operators keep building bad baselines → mitigation: conservative framing and later evolution via real usage data
|
||||||
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
- R3: team reads this spec as a starting point for large identity architecture → mitigation: non-goals are explicitly and strictly scoped
|
||||||
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
- **Roadmap fit**: Aligns directly with Release 1 — Golden Master Governance (R1.1 BaselineProfile, R1.3 baseline.compare, R1.4 Drift UI: Soll vs Ist). Improves V1 sellability without domain-model expansion: less confusing drift communication, clearer Golden Master story, no false operator blame, better trust basis for compare results.
|
||||||
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate (complementary — governance artifact presentation)
|
- **Dependencies**: Baseline drift engine stable (Specs 116–119), Baseline Snapshot Fidelity Semantics candidate (complementary — snapshot-level fidelity clarity), Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression) as the complementary governance artifact presentation layer
|
||||||
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Governance Operator Outcome Compression candidate
|
- **Related specs / candidates**: Specs 116–119 (baseline drift engine), Spec 101 (baseline governance), Baseline Capture Truthful Outcomes candidate, Baseline Snapshot Fidelity Semantics candidate, Spec 161 (operator-explanation-layer), Spec 214 (Governance Operator Outcome Compression)
|
||||||
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
- **Recommendation**: Treat before any large matching/identity extension. Small enough for V1, reduces real operator confusion, protects against scope creep, and sharpens the product message: TenantPilot compares curated governance baselines — not blindly every generic tenant default content.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
@ -463,7 +380,7 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
|
|||||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
||||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
||||||
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
||||||
- **Boundary with Governance Operator Outcome Compression**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
||||||
@ -1432,6 +1349,37 @@ ### Run Log Inspect Affordance Alignment
|
|||||||
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
|
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
|
||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### Selected-Record Monitoring Host Alignment
|
||||||
|
- **Type**: workflow compression
|
||||||
|
- **Source**: enterprise UX review 2026-04-19 — Finding Exceptions Queue and Audit Log selected-record monitoring surfaces
|
||||||
|
- **Problem**: Specs 193 and 198 correctly established the semantics for `queue_workbench` and `selected_record_monitoring`, but they intentionally stopped at action hierarchy and page-state transport. The remaining gap is the active review host shape. `FindingExceptionsQueue` and `AuditLog` both preserve selection via query parameter and `inspect_action`, yet the current host experience still sits awkwardly between a list page, an inline expanded detail block, and a modal-style inspect affordance. That is technically valid, but it does not read as an enterprise-grade workbench. Operators get shareable URLs and refresh-safe state, but not a clearly expressed review mode with one deliberate place for context, next step, and close/return behavior.
|
||||||
|
- **Why it matters**: Enterprise operators working through queues or history need one of two unmistakable behaviors: either remain in a stable workbench where list context and active record review coexist intentionally, or leave the list for a canonical detail route with explicit return continuity. The current halfway pattern preserves state better than a slide-over, but it still weakens scanability, makes the active review lane feel bolted on, and leaves too much room for future local variations across monitoring surfaces.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Define two allowed enterprise host models for `selected_record_monitoring` surfaces:
|
||||||
|
- **Split-pane workbench**: the list, filters, and queue context remain continuously visible while the selected record occupies a dedicated persistent review pane
|
||||||
|
- **Canonical detail route**: the list remains list-first, and inspect opens a standalone detail page with explicit back/return continuity and optional preserved filter state
|
||||||
|
- Allow **quick-peek overlays** only as optional preview affordances, never as the sole canonical inspect or deep-link contract
|
||||||
|
- Add host-selection criteria so surfaces choose deliberately between split-pane and canonical detail route instead of drifting into full-page inline "focused lane above the table" patterns
|
||||||
|
- Pilot the rule on `FindingExceptionsQueue` and `AuditLog`, keeping current query-param addressability while upgrading the actual review host ergonomics
|
||||||
|
- Codify close/back/new-tab/reload semantics and invalid-selection fallback per host model so URL durability and review ergonomics are aligned rather than accidental
|
||||||
|
- **Smallest enterprise-capable version**: Limit the first slice to the two already-real `selected_record_monitoring` surfaces in Monitoring: `FindingExceptionsQueue` and `AuditLog`. The spec should choose and implement one clear host model per surface, document the decision rule, and stop there. No generic pane framework, no broad monitoring IA rewrite, and no rollout to unrelated list/detail pages.
|
||||||
|
- **Explicit non-goals**: Not a full Monitoring redesign, not a new modal framework, not a replacement for Spec 198 page-state semantics, not a generic shared-detail engine, not a broad action-surface retrofit outside `selected_record_monitoring`, and not a rewrite of finding or audit domain truth.
|
||||||
|
- **Permanent complexity imported**: One small host-pattern contract for `selected_record_monitoring`, explicit decision criteria for split-pane vs canonical detail route, focused regression coverage for two surfaces, and a small amount of new vocabulary around host model choice. No new persisted truth, no new provider/runtime architecture, and no new generalized UI platform are justified.
|
||||||
|
- **Why now**: The product already has at least two real consumers of the same selected-record monitoring pattern, and one of them is visible enough that the UX gap is now obvious. Leaving the gap open means future monitoring surfaces will keep re-solving the same question locally, and the currently correct page-state work will continue to feel less enterprise than it should.
|
||||||
|
- **Why not local**: A one-off polish pass on `FindingExceptionsQueue` would not answer what `AuditLog` should do, nor would it define when a selected-record monitoring surface should stay list-first versus move to canonical detail. The missing artifact is not just layout polish; it is the host decision rule for a small but real surface family.
|
||||||
|
- **Approval class**: Workflow Compression
|
||||||
|
- **Red flags triggered**: One red flag: this introduces a cross-surface host-model rule. The scope must stay bounded to the already-real `selected_record_monitoring` family and must not grow into a general monitoring-shell framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
- **Acceptance points**:
|
||||||
|
- Each `selected_record_monitoring` surface declares one deliberate host model instead of expressing active review as an ad hoc inline expansion
|
||||||
|
- Deep links, refresh, and invalid-selection fallback remain stable after the host upgrade
|
||||||
|
- Operators can keep queue/history context while reviewing a record, or return to it predictably when the chosen host model uses a dedicated detail route
|
||||||
|
- Close, back, related drilldowns, and "open in full detail" semantics become consistent enough that selected-record monitoring feels like a product pattern instead of a local layout choice
|
||||||
|
- **Dependencies**: Spec 193 (`monitoring-action-hierarchy`), Spec 198 (`monitoring-page-state`), and the existing Monitoring page-state guards already in the repo
|
||||||
|
- **Related specs / candidates**: Spec 197 (`shared-detail-contract`), Action Surface Contract v1.1, Admin Visual Language Canon, Record Page Header Discipline & Contextual Navigation (for return semantics only; not a direct dependency)
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
|
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
|
||||||
- **Type**: foundation
|
- **Type**: foundation
|
||||||
- **Source**: admin UI consistency analysis 2026-03-17
|
- **Source**: admin UI consistency analysis 2026-03-17
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user