Compare commits
40 Commits
dev
...
265-decisi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5671cbf47 | ||
|
|
df5a0e067d | ||
|
|
15af199d4f | ||
| 11247c1537 | |||
| b05d5c52d4 | |||
| 8f1ceb70ec | |||
| 25e1f69513 | |||
| feeaadd5ad | |||
| bcabb14480 | |||
| eae06bfe05 | |||
| 866875559f | |||
|
|
0517305381 | ||
| 966b7af472 | |||
|
|
1bf369b561 | ||
|
|
a2bb5b7729 | ||
|
|
bb78049271 | ||
| 7d17d39060 | |||
|
|
a35cd88bff | ||
| 926b0fe4f3 | |||
|
|
a74a6791ad | ||
| 52ebf63af1 | |||
|
|
2e2b125107 | ||
|
|
4b0dc2a62e | ||
|
|
34351a281d | ||
| 51ea80ca05 | |||
|
|
e36bd3ca9c | ||
| b511b08371 | |||
|
|
f53f149f99 | ||
| 2fa8fc0f87 | |||
|
|
44e6a1eb05 | ||
|
|
4f7c1a6c94 | ||
|
|
4325e1ed8d | ||
|
|
4ae4c2ee95 | ||
|
|
32b6dcb937 | ||
|
|
f7bc4f2787 | ||
|
|
0739018ee5 | ||
|
|
9a02261f5c | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -266,6 +266,10 @@ ## Active Technologies
|
||||
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities` (253-remove-findings-backfill-runtime-surfaces)
|
||||
- PostgreSQL existing `findings`, `operation_runs`, `audit_logs`, and related runtime tables only; no new persistence, migration, or data backfill is planned (253-remove-findings-backfill-runtime-surfaces)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams (259-compliance-evidence-mapping)
|
||||
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `evidence_snapshots`, `evidence_snapshot_items`, `findings`, `finding_exceptions`, memberships, and `audit_logs`; no new persistence table planned (259-compliance-evidence-mapping)
|
||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure (260-governance-service-packaging)
|
||||
- PostgreSQL via existing `tenant_reviews`, `tenant_review_sections`, `review_packs`, `evidence_snapshots`, `evidence_snapshot_items`, `stored_reports`, `findings`, `finding_exceptions`, `finding_exception_decisions`, memberships, and `audit_logs`; no new persistence planned (260-governance-service-packaging)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -300,9 +304,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 260-governance-service-packaging: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing `TenantReviewComposer`, `TenantReviewSectionFactory`, `ComplianceEvidenceMappingV1`, `ReviewPackService`, `ArtifactTruthPresenter`, capability helpers, localization copy, and shared audit infrastructure
|
||||
- 259-compliance-evidence-mapping: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing canonical-control, evidence, tenant-review, RBAC, localization, and audit seams
|
||||
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
|
||||
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
||||
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
2
.github/skills/giteaflow/SKILL.md
vendored
2
.github/skills/giteaflow/SKILL.md
vendored
@ -5,4 +5,4 @@
|
||||
|
||||
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||
|
||||
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||
comit all changes, push to remote, and create a pull request against platform-dev with gitea mcp
|
||||
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
92
.github/skills/spec-kit-next-best-prep/SKILL.md
vendored
@ -85,6 +85,9 @@ ## Hard Rules
|
||||
- Do not run destructive commands.
|
||||
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
|
||||
- Do not overwrite existing specs.
|
||||
- Do not rewrite completed specs back into preparation state.
|
||||
- Do not remove or normalize implementation history, close-out notes, validation results, completed task markers, smoke results, or post-implementation review language from completed specs.
|
||||
- Treat completed-spec close-out and validation language as intentional repository history, not preparation drift.
|
||||
- Do not move from preparation to an implementation step inside this skill.
|
||||
|
||||
## Required Inputs
|
||||
@ -119,6 +122,32 @@ ## Required Repository Checks
|
||||
|
||||
Do not edit application code.
|
||||
|
||||
## Completed-Spec Guardrail
|
||||
|
||||
Before selecting an existing spec package as a `next-best-prep` target, explicitly check whether the spec is already completed, implementation-closed, or validated.
|
||||
|
||||
A spec must be treated as completed if any of the following signals are present in `spec.md`, `plan.md`, `tasks.md`, `quickstart.md`, checklist artifacts, or related Spec Kit package files:
|
||||
|
||||
- `Implementation Close-Out`
|
||||
- `Implementation completed on`
|
||||
- `Implementation Validation Results`
|
||||
- `Implemented and validated`
|
||||
- `Review Outcome` or `Implementation Review Outcome`
|
||||
- passed validation, smoke, browser, or guardrail results
|
||||
- completed task checklist markers for the implementation tasks
|
||||
- post-implementation review or close-out language
|
||||
- a status marker indicating implemented, completed, closed, or validated
|
||||
|
||||
If a spec is completed:
|
||||
|
||||
- exclude it from `next-best-prep` candidate selection
|
||||
- do not patch, normalize, rewrite, or convert it back to preparation-only state
|
||||
- do not remove close-out sections, validation results, completed task markers, smoke results, or post-implementation review language
|
||||
- treat those artifacts as historical implementation evidence
|
||||
- only use the completed spec as context for dependency or roadmap reasoning
|
||||
|
||||
If all high-priority candidates are already specced, active, or completed, stop and report `no safe next prep target` instead of modifying existing completed specs.
|
||||
|
||||
## Git and Branch Safety
|
||||
|
||||
Before running any Spec Kit command:
|
||||
@ -143,6 +172,7 @@ ### Gate 1: Candidate Selection Gate
|
||||
|
||||
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
|
||||
- The selected candidate is not already covered by an existing active or completed spec.
|
||||
- The selected target is not a completed spec package with implementation close-out, validation results, completed tasks, smoke results, or post-implementation review history.
|
||||
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
|
||||
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
|
||||
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
|
||||
@ -150,6 +180,7 @@ ### Gate 1: Candidate Selection Gate
|
||||
Fail behavior:
|
||||
|
||||
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
|
||||
- If the only plausible targets are completed specs, stop and report `no safe next prep target`; do not modify those completed specs.
|
||||
- Do not invent a new roadmap direction to force progress.
|
||||
|
||||
### Gate 2: Spec Readiness Gate
|
||||
@ -180,6 +211,8 @@ ## Candidate Selection Rules
|
||||
- Read `docs/product/spec-candidates.md`.
|
||||
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
|
||||
- Check existing specs to avoid duplicates.
|
||||
- Check existing specs for completed-spec signals before selecting an existing package as a refresh target.
|
||||
- Exclude completed specs from next-best-prep selection, even if their artifacts contain close-out, validation, or completed-task language that would look like drift in a preparation-only package.
|
||||
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
|
||||
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
|
||||
- Prefer small, implementation-ready slices over broad platform rewrites.
|
||||
@ -198,6 +231,7 @@ ## Candidate Selection Rules
|
||||
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
|
||||
6. **Risk Reduction**: Does it reduce current architectural or product risk?
|
||||
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
|
||||
8. **Completion Safety**: Is the target genuinely unprepared or incomplete, rather than an already completed spec whose historical close-out artifacts should be preserved?
|
||||
|
||||
## Required Selection Output Before Spec Kit Execution
|
||||
|
||||
@ -208,6 +242,7 @@ ## Required Selection Output Before Spec Kit Execution
|
||||
- why it was selected
|
||||
- why close alternatives were deferred
|
||||
- roadmap relationship
|
||||
- completed-spec check result for related existing specs
|
||||
- smallest viable implementation slice
|
||||
- proposed concise feature description to feed into `specify`
|
||||
|
||||
@ -296,7 +331,7 @@ ### Step 5: Run preparation `analyze`
|
||||
|
||||
### Step 6: Fix preparation-artifact issues only
|
||||
|
||||
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
|
||||
If preparation analyze finds issues, first confirm that the selected package is not completed. Then fix only Spec Kit preparation artifacts such as:
|
||||
|
||||
- `spec.md`
|
||||
- `plan.md`
|
||||
@ -322,6 +357,10 @@ ### Step 6: Fix preparation-artifact issues only
|
||||
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
|
||||
- running implementation or test-fix loops
|
||||
- changing runtime behavior
|
||||
- removing implementation close-out history from completed specs
|
||||
- converting completed specs back to preparation-only wording
|
||||
- changing passed validation or smoke results into planned validation commands
|
||||
- unchecking completed implementation tasks in a completed spec
|
||||
|
||||
### Step 7: Evaluate the Spec Readiness Gate
|
||||
|
||||
@ -478,23 +517,33 @@ ## Failure Handling
|
||||
2. Report the current branch and relevant uncommitted files.
|
||||
3. Ask the user to commit, stash, or move to a clean worktree.
|
||||
|
||||
If a completed spec is accidentally selected or modified:
|
||||
|
||||
1. Stop immediately.
|
||||
2. Report that the selected spec is completed and therefore not a valid preparation target.
|
||||
3. Revert only the changes made by this operation to that completed spec package, if they are isolated and safe to revert.
|
||||
4. Run `git status --short` and report remaining changes.
|
||||
5. Re-run candidate selection excluding completed specs.
|
||||
6. If no safe unprepared candidate exists, report `no safe next prep target`.
|
||||
|
||||
## Final Response Requirements
|
||||
|
||||
Respond with:
|
||||
|
||||
1. Selected candidate and why it was chosen
|
||||
2. Why close alternatives were deferred
|
||||
3. Current branch after Spec Kit execution, if changed
|
||||
4. Generated spec path
|
||||
5. Files created or updated by Spec Kit
|
||||
6. Preparation analyze result summary
|
||||
7. Preparation-artifact fixes applied after analyze
|
||||
8. Assumptions made
|
||||
9. Open questions, if any
|
||||
10. Candidate Selection Gate result
|
||||
11. Spec Readiness Gate result
|
||||
12. Recommended next implementation prompt
|
||||
13. Explicit statement that no application implementation was performed
|
||||
3. Completed-spec guardrail result for related existing specs
|
||||
4. Current branch after Spec Kit execution, if changed
|
||||
5. Generated spec path
|
||||
6. Files created or updated by Spec Kit
|
||||
7. Preparation analyze result summary
|
||||
8. Preparation-artifact fixes applied after analyze
|
||||
9. Assumptions made
|
||||
10. Open questions, if any
|
||||
11. Candidate Selection Gate result
|
||||
12. Spec Readiness Gate result
|
||||
13. Recommended next implementation prompt
|
||||
14. Explicit statement that no application implementation was performed
|
||||
|
||||
Keep the final response concise, but include enough detail for the user to continue immediately.
|
||||
|
||||
@ -550,13 +599,14 @@ ## Example Invocation
|
||||
2. Check branch and working tree safety.
|
||||
3. Compare candidate suitability.
|
||||
4. Select the next best candidate.
|
||||
5. Evaluate the Candidate Selection Gate.
|
||||
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
7. Run the repository's real Spec Kit `plan` flow.
|
||||
8. Run the repository's real Spec Kit `tasks` flow.
|
||||
9. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||
10. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
11. Evaluate the Spec Readiness Gate.
|
||||
12. Stop before application implementation.
|
||||
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||
5. Exclude already completed specs from preparation or refresh targets, preserving their close-out and validation history.
|
||||
6. Evaluate the Candidate Selection Gate.
|
||||
7. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
|
||||
8. Run the repository's real Spec Kit `plan` flow.
|
||||
9. Run the repository's real Spec Kit `tasks` flow.
|
||||
10. Run the repository's real Spec Kit preparation `analyze` flow.
|
||||
11. Fix analyze issues only in Spec Kit preparation artifacts.
|
||||
12. Evaluate the Spec Readiness Gate.
|
||||
13. Stop before application implementation.
|
||||
14. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
|
||||
```
|
||||
@ -50,7 +50,7 @@ public function handle(): int
|
||||
|
||||
$changedVersions = 0;
|
||||
$changedPolicies = 0;
|
||||
$ignoredPolicies = 0;
|
||||
$providerMissingPolicies = 0;
|
||||
|
||||
foreach ($candidates as $policy) {
|
||||
$latestVersion = $policy->versions()->latest('version_number')->first();
|
||||
@ -86,14 +86,15 @@ public function handle(): int
|
||||
->first();
|
||||
|
||||
if ($existingTarget) {
|
||||
$policy->forceFill(['ignored_at' => now()])->save();
|
||||
$ignoredPolicies++;
|
||||
$policy->forceFill(['missing_from_provider_at' => now()])->save();
|
||||
$providerMissingPolicies++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policy->forceFill([
|
||||
'policy_type' => 'windowsEnrollmentStatusPage',
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
$changedPolicies++;
|
||||
|
||||
@ -106,7 +107,7 @@ public function handle(): int
|
||||
$this->info('Done.');
|
||||
$this->info('PolicyVersions changed: '.$changedVersions);
|
||||
$this->info('Policies changed: '.$changedPolicies);
|
||||
$this->info('Policies ignored: '.$ignoredPolicies);
|
||||
$this->info('Policies marked provider-missing: '.$providerMissingPolicies);
|
||||
$this->info('Mode: '.($dryRun ? 'dry-run' : 'write'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@ -12,8 +12,13 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\PortfolioCompare\CrossTenantPromotionExecutionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
|
||||
@ -23,13 +28,16 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use DomainException;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
@ -192,6 +200,7 @@ protected function getHeaderActions(): array
|
||||
->label('Generate promotion preflight')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => ! is_array($this->preflight))
|
||||
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->preflightDisabledReason())
|
||||
->action(fn (): mixed => $this->generatePromotionPreflight());
|
||||
@ -201,6 +210,7 @@ protected function getHeaderActions(): array
|
||||
fn (): ?Workspace => $this->workspace(),
|
||||
)
|
||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
|
||||
->apply()
|
||||
@ -223,6 +233,19 @@ protected function getHeaderActions(): array
|
||||
|
||||
$actions[] = $preflightAction;
|
||||
|
||||
$actions[] = Action::make('executePromotion')
|
||||
->label('Execute promotion')
|
||||
->icon('heroicon-o-play')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => is_array($this->preflight))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Execute promotion')
|
||||
->modalDescription(fn (): string => $this->executePromotionConfirmationDescription())
|
||||
->modalSubmitActionLabel('Queue promotion')
|
||||
->disabled(fn (): bool => $this->executePromotionDisabledReason() !== null)
|
||||
->tooltip(fn (): ?string => $this->executePromotionDisabledReason())
|
||||
->action(fn (): mixed => $this->executePromotion());
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
@ -282,6 +305,74 @@ public function generatePromotionPreflight(): void
|
||||
}
|
||||
}
|
||||
|
||||
public function executePromotion(): void
|
||||
{
|
||||
$this->authorizePageAccess();
|
||||
$this->authorizePromotionExecution();
|
||||
|
||||
if (! is_array($this->preview) || ! is_array($this->preflight)) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Generate a current promotion preflight before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selection = $this->compareSelection();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $selection instanceof CrossTenantCompareSelection || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body('Refresh the compare selection before executing promotion.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = app(CrossTenantPromotionExecutionService::class)->start(
|
||||
selection: $selection,
|
||||
preview: $this->preview,
|
||||
preflight: $this->preflight,
|
||||
actor: $user,
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
} catch (DomainException|InvalidArgumentException $exception) {
|
||||
Notification::make()
|
||||
->title('Promotion execution unavailable')
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||
result: $result,
|
||||
blockedTitle: 'Promotion execution blocked',
|
||||
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||
scopeBusyTitle: 'Promotion scope busy',
|
||||
scopeBusyBody: 'Another promotion or restore operation is already active for this target scope. Open the active operation for progress and next steps.',
|
||||
);
|
||||
|
||||
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
}
|
||||
|
||||
$notification->send();
|
||||
}
|
||||
|
||||
public function clearSelectionUrl(): string
|
||||
{
|
||||
return static::getUrl($this->routeParameters([
|
||||
@ -453,6 +544,30 @@ private function authorizePreflightExecution(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function authorizePromotionExecution(): void
|
||||
{
|
||||
$this->authorizePreflightExecution();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if (! $targetTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
private function compareSelection(): ?CrossTenantCompareSelection
|
||||
{
|
||||
$sourceTenant = $this->selectedSourceTenant();
|
||||
@ -593,6 +708,73 @@ private function preflightDisabledReason(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionDisabledReason(): ?string
|
||||
{
|
||||
if ($this->selectionMessage !== null) {
|
||||
return $this->selectionMessage;
|
||||
}
|
||||
|
||||
if (! is_array($this->preview)) {
|
||||
return 'Run compare preview before executing promotion.';
|
||||
}
|
||||
|
||||
if (! is_array($this->preflight)) {
|
||||
return 'Generate a current promotion preflight before executing promotion.';
|
||||
}
|
||||
|
||||
if ((int) data_get($this->preflight, 'summary.ready', 0) <= 0) {
|
||||
return 'Current promotion preflight has no ready governed subjects to execute.';
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||
/** @var WorkspaceCapabilityResolver $workspaceResolver */
|
||||
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if ($workspaceResolver->isMember($user, $workspace)
|
||||
&& ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||
return 'You need workspace baseline manage access to execute promotion.';
|
||||
}
|
||||
|
||||
$targetTenant = $this->selectedTargetTenant();
|
||||
|
||||
if ($targetTenant instanceof Tenant) {
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $targetTenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You need target tenant manage access to execute promotion.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executePromotionConfirmationDescription(): string
|
||||
{
|
||||
$selection = $this->compareSelection();
|
||||
$ready = (int) data_get($this->preflight, 'summary.ready', 0);
|
||||
$blocked = (int) data_get($this->preflight, 'summary.blocked', 0);
|
||||
$manualMappingRequired = (int) data_get($this->preflight, 'summary.manual_mapping_required', 0);
|
||||
$excluded = $blocked + $manualMappingRequired;
|
||||
|
||||
$sourceTenantName = $selection?->sourceTenant->name ?? 'Source tenant';
|
||||
$targetTenantName = $selection?->targetTenant->name ?? 'Target tenant';
|
||||
|
||||
return sprintf(
|
||||
'Queue one promotion run from %s to %s for %d ready governed subject%s. %d subject%s remain excluded on the compare page.',
|
||||
$sourceTenantName,
|
||||
$targetTenantName,
|
||||
$ready,
|
||||
$ready === 1 ? '' : 's',
|
||||
$excluded,
|
||||
$excluded === 1 ? '' : 's',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
|
||||
719
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
719
apps/platform/app/Filament/Pages/Governance/DecisionRegister.php
Normal file
@ -0,0 +1,719 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Governance;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\GovernanceDecisions\GovernanceDecisionRegisterBuilder;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
class DecisionRegister extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Decision register';
|
||||
|
||||
protected static ?int $navigationSort = 6;
|
||||
|
||||
protected static ?string $title = 'Decision register';
|
||||
|
||||
protected static ?string $slug = 'governance/decisions';
|
||||
|
||||
protected string $view = 'filament.pages.governance.decision-register';
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $authorizedTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $visibleDecisionTenants = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $registerPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $unfilteredRegisterPayload = null;
|
||||
|
||||
/**
|
||||
* @var array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private ?array $rowPayloadByExceptionId = null;
|
||||
|
||||
private ?Workspace $workspace = null;
|
||||
|
||||
public ?int $tenantId = null;
|
||||
|
||||
public string $registerState = 'open';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep tenant and register-state scope visible without introducing a second mutation surface.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The decision register keeps one dominant row action and avoids a More menu in v1.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The decision register is read-only and intentionally omits bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Filtered empty states stay truthful and provide one path back to the broader register scope.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The register owns no local detail surface; existing exception detail remains the action owner.');
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = static::resolveWorkspaceFromRequest();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (static::hasRequestedTenantPrefilter()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visibleTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace);
|
||||
|
||||
if ($visibleTenants === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request()->query('register_state') === 'recently_closed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (int) (app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $visibleTenants,
|
||||
registerState: 'open',
|
||||
)['counts']['open'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->mountInteractsWithTable();
|
||||
$this->authorizeWorkspaceMembership();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->registerState = $this->resolveRequestedRegisterState();
|
||||
$this->ensureRegisterIsVisible();
|
||||
}
|
||||
|
||||
public function pageUrl(array $overrides = []): string
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
||||
? $overrides['tenant']
|
||||
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
||||
$resolvedRegisterState = array_key_exists('register_state', $overrides)
|
||||
? $overrides['register_state']
|
||||
: $this->registerState;
|
||||
|
||||
return static::getUrl(
|
||||
panel: 'admin',
|
||||
parameters: array_filter([
|
||||
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
||||
'register_state' => is_string($resolvedRegisterState) && $resolvedRegisterState !== 'open' ? $resolvedRegisterState : null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
public function appliedScope(): array
|
||||
{
|
||||
return [
|
||||
'workspace_label' => $this->workspace()?->name,
|
||||
'tenant_label' => $this->selectedTenant()?->name,
|
||||
'register_state_label' => $this->registerStateLabel($this->registerState),
|
||||
'visible_count' => $this->registerPayload()['counts'][$this->registerState] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, label: string, count: int}>
|
||||
*/
|
||||
public function availableRegisterStates(): array
|
||||
{
|
||||
$counts = $this->registerPayload()['counts'] ?? ['open' => 0, 'recently_closed' => 0];
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'open',
|
||||
'label' => 'Open decisions',
|
||||
'count' => (int) ($counts['open'] ?? 0),
|
||||
],
|
||||
[
|
||||
'key' => 'recently_closed',
|
||||
'label' => 'Recently closed',
|
||||
'count' => (int) ($counts['recently_closed'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function hasTenantPrefilter(): bool
|
||||
{
|
||||
return $this->selectedTenant() instanceof Tenant;
|
||||
}
|
||||
|
||||
public function isActiveRegisterState(string $registerState): bool
|
||||
{
|
||||
return $this->registerState === $registerState;
|
||||
}
|
||||
|
||||
public function emptyStateHeading(): string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'This tenant filter is hiding other visible decision follow-through';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'No recently closed decisions match this filter right now.';
|
||||
}
|
||||
|
||||
return 'No open decisions match this filter right now.';
|
||||
}
|
||||
|
||||
public function emptyStateDescription(): string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'The current tenant scope is calm, but other visible tenants in this workspace still have open governance decisions.';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'Switch back to open decisions to continue the current follow-through lane, or widen the tenant scope if you were filtering the register.';
|
||||
}
|
||||
|
||||
return 'Try widening the tenant scope or switch to recently closed decisions if you are checking what was just finished.';
|
||||
}
|
||||
|
||||
public function emptyStateActionLabel(): ?string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return 'Clear tenant filter';
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return 'Open current decisions';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function emptyStateActionUrl(): ?string
|
||||
{
|
||||
if ($this->tenantFilterAloneExcludesRows()) {
|
||||
return $this->pageUrl(['tenant' => null]);
|
||||
}
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return $this->pageUrl(['register_state' => 'open']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->tableQuery())
|
||||
->defaultSort('review_due_at', 'asc')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(null)
|
||||
->columns([
|
||||
TextColumn::make('tenant.name')
|
||||
->label('Tenant')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
||||
->sortable(),
|
||||
TextColumn::make('current_validity_state')
|
||||
->label('Impact')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
||||
TextColumn::make('owner.name')
|
||||
->label('Owner')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
TextColumn::make('review_due_at')
|
||||
->label('Review due')
|
||||
->dateTime()
|
||||
->since()
|
||||
->placeholder('—')
|
||||
->tooltip(fn (FindingException $record): ?string => $record->review_due_at?->toDayDateTimeString())
|
||||
->sortable(),
|
||||
TextColumn::make('proof_availability')
|
||||
->label('Proof')
|
||||
->state(function (FindingException $record): string {
|
||||
$referenceCount = (int) data_get($record->evidence_summary ?? [], 'reference_count', 0);
|
||||
|
||||
return $referenceCount > 0
|
||||
? $referenceCount.' evidence linked'
|
||||
: 'No linked proof';
|
||||
})
|
||||
->wrap(),
|
||||
TextColumn::make('next_action_label')
|
||||
->label('Next action')
|
||||
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['next_action_label'] ?? null)
|
||||
->visible(fn (): bool => $this->registerState === 'open')
|
||||
->wrap(),
|
||||
TextColumn::make('closure_reason')
|
||||
->label('Closure reason')
|
||||
->state(fn (FindingException $record): ?string => $this->rowPayload($record)['closure_reason'] ?? null)
|
||||
->placeholder('—')
|
||||
->visible(fn (): bool => $this->registerState === 'recently_closed')
|
||||
->wrap(),
|
||||
])
|
||||
->actions([
|
||||
Action::make('open_decision')
|
||||
->label('Open decision')
|
||||
->color('gray')
|
||||
->url(fn (FindingException $record): ?string => $this->decisionUrl($record)),
|
||||
])
|
||||
->emptyStateHeading($this->emptyStateHeading())
|
||||
->emptyStateDescription($this->emptyStateDescription())
|
||||
->emptyStateActions($this->emptyStateActions());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Tables\Actions\Action>
|
||||
*/
|
||||
private function emptyStateActions(): array
|
||||
{
|
||||
$label = $this->emptyStateActionLabel();
|
||||
$url = $this->emptyStateActionUrl();
|
||||
|
||||
if (! is_string($label) || ! is_string($url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Action::make('empty_state_scope_action')
|
||||
->label($label)
|
||||
->url($url),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<FindingException>
|
||||
*/
|
||||
private function tableQuery(): Builder
|
||||
{
|
||||
$tenantIds = array_values(array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$this->currentScopeTenants(),
|
||||
));
|
||||
|
||||
$query = FindingException::query()
|
||||
->where('workspace_id', (int) $this->workspace()?->getKey())
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->with(['tenant', 'owner', 'currentDecision']);
|
||||
|
||||
if ($this->registerState === 'recently_closed') {
|
||||
return $query
|
||||
->whereIn('status', [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
])
|
||||
->whereHas('currentDecision', function (Builder $decisionQuery): void {
|
||||
$decisionQuery->where('decided_at', '>=', now()->startOfDay()->subDays(30));
|
||||
});
|
||||
}
|
||||
|
||||
return $query
|
||||
->whereNotIn('status', [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceMembership(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureRegisterIsVisible(): void
|
||||
{
|
||||
if ($this->visibleDecisionTenants() === []) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($this->tenantId !== null || $this->registerState !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($this->registerPayload()['counts']['open'] ?? 0) === 0) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function authorizedTenants(): array
|
||||
{
|
||||
if ($this->authorizedTenants !== null) {
|
||||
return $this->authorizedTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return $this->authorizedTenants = [];
|
||||
}
|
||||
|
||||
return $this->authorizedTenants = static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function visibleDecisionTenants(): array
|
||||
{
|
||||
if ($this->visibleDecisionTenants !== null) {
|
||||
return $this->visibleDecisionTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
$tenants = $this->authorizedTenants();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace || $tenants === []) {
|
||||
return $this->visibleDecisionTenants = [];
|
||||
}
|
||||
|
||||
return $this->visibleDecisionTenants = static::resolveVisibleDecisionTenantsFor($user, $workspace, $tenants);
|
||||
}
|
||||
|
||||
private function applyRequestedTenantPrefilter(): void
|
||||
{
|
||||
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
||||
|
||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->authorizedTenants() as $tenant) {
|
||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->tenantId = (int) $tenant->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function resolveRequestedRegisterState(): string
|
||||
{
|
||||
$registerState = request()->query('register_state');
|
||||
|
||||
if (! is_string($registerState)) {
|
||||
return 'open';
|
||||
}
|
||||
|
||||
return in_array($registerState, ['open', 'recently_closed'], true)
|
||||
? $registerState
|
||||
: 'open';
|
||||
}
|
||||
|
||||
private static function hasRequestedTenantPrefilter(): bool
|
||||
{
|
||||
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
||||
|
||||
return is_string($requestedTenant) || is_numeric($requestedTenant);
|
||||
}
|
||||
|
||||
private static function resolveWorkspaceFromRequest(): ?Workspace
|
||||
{
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private static function resolveAuthorizedTenantsFor(User $user, Workspace $workspace): array
|
||||
{
|
||||
return $user->tenants()
|
||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
||||
->where('tenants.status', 'active')
|
||||
->orderBy('tenants.name')
|
||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, Tenant>|null $authorizedTenants
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private static function resolveVisibleDecisionTenantsFor(User $user, Workspace $workspace, ?array $authorizedTenants = null): array
|
||||
{
|
||||
$tenants = $authorizedTenants ?? static::resolveAuthorizedTenantsFor($user, $workspace);
|
||||
|
||||
if ($tenants === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$resolver->primeMemberships(
|
||||
$user,
|
||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
||||
);
|
||||
|
||||
return array_values(array_filter(
|
||||
$tenants,
|
||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW),
|
||||
));
|
||||
}
|
||||
|
||||
private function workspace(): ?Workspace
|
||||
{
|
||||
if ($this->workspace instanceof Workspace) {
|
||||
return $this->workspace;
|
||||
}
|
||||
|
||||
return $this->workspace = static::resolveWorkspaceFromRequest();
|
||||
}
|
||||
|
||||
private function selectedTenant(): ?Tenant
|
||||
{
|
||||
if (! is_int($this->tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->visibleDecisionTenants() as $tenant) {
|
||||
if ((int) $tenant->getKey() === $this->tenantId) {
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function currentScopeTenants(): array
|
||||
{
|
||||
$selectedTenant = $this->selectedTenant();
|
||||
|
||||
if ($selectedTenant instanceof Tenant) {
|
||||
return [$selectedTenant];
|
||||
}
|
||||
|
||||
return $this->visibleDecisionTenants();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function registerPayload(): array
|
||||
{
|
||||
if (is_array($this->registerPayload)) {
|
||||
return $this->registerPayload;
|
||||
}
|
||||
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return $this->registerPayload = [
|
||||
'rows' => [],
|
||||
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->registerPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $this->currentScopeTenants(),
|
||||
registerState: $this->registerState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function unfilteredRegisterPayload(): array
|
||||
{
|
||||
if (is_array($this->unfilteredRegisterPayload)) {
|
||||
return $this->unfilteredRegisterPayload;
|
||||
}
|
||||
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return $this->unfilteredRegisterPayload = [
|
||||
'rows' => [],
|
||||
'counts' => ['open' => 0, 'recently_closed' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->unfilteredRegisterPayload = app(GovernanceDecisionRegisterBuilder::class)->build(
|
||||
workspace: $workspace,
|
||||
visibleTenants: $this->visibleDecisionTenants(),
|
||||
registerState: 'open',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowPayload(FindingException $record): array
|
||||
{
|
||||
if (! is_array($this->rowPayloadByExceptionId)) {
|
||||
$this->rowPayloadByExceptionId = collect($this->registerPayload()['rows'] ?? [])
|
||||
->keyBy('exception_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
return $this->rowPayloadByExceptionId[(int) $record->getKey()] ?? [];
|
||||
}
|
||||
|
||||
private function tenantFilterAloneExcludesRows(): bool
|
||||
{
|
||||
if (! is_int($this->tenantId) || $this->registerState !== 'open') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($this->registerPayload()['rows'] ?? []) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) ($this->unfilteredRegisterPayload()['counts']['open'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
private function registerStateLabel(string $registerState): string
|
||||
{
|
||||
return match ($registerState) {
|
||||
'recently_closed' => 'Recently closed',
|
||||
default => 'Open decisions',
|
||||
};
|
||||
}
|
||||
|
||||
public function decisionUrl(FindingException $record): ?string
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant),
|
||||
$this->navigationContext()->toQuery(),
|
||||
);
|
||||
}
|
||||
|
||||
private function navigationContext(): CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::forDecisionRegister(
|
||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
||||
tenantId: $this->tenantId,
|
||||
backLinkUrl: $this->pageUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function appendQuery(string $url, array $query): string
|
||||
{
|
||||
$queryString = http_build_query($query);
|
||||
|
||||
if ($queryString === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url.$separator.$queryString;
|
||||
}
|
||||
}
|
||||
@ -5,18 +5,23 @@
|
||||
namespace App\Filament\Pages\Reviews;
|
||||
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -36,6 +41,7 @@
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
|
||||
@ -45,7 +51,7 @@ class CustomerReviewWorkspace extends Page implements HasTable
|
||||
|
||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
||||
|
||||
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
public const string SOURCE_SURFACE = 'customer_review_workspace';
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
@ -67,10 +73,10 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::PrimaryLinkColumn->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The dedicated open link column opens the latest published review detail instead of an inline canonical detail panel.');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
@ -109,6 +115,7 @@ public function mount(): void
|
||||
$this->authorizePageAccess();
|
||||
$this->applyRequestedTenantPrefilter();
|
||||
$this->mountInteractsWithTable();
|
||||
$this->auditWorkspaceOpen();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
@ -146,34 +153,41 @@ public function table(Table $table): Table
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->recordUrl(null)
|
||||
->columns([
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
|
||||
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable(),
|
||||
TextColumn::make('package_availability')
|
||||
->label(__('localization.review.governance_package'))
|
||||
->width('9rem')
|
||||
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->governancePackageAvailabilityLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->governancePackageAvailabilityColor($record))
|
||||
->tooltip(fn (Tenant $record): string => $this->governancePackageAvailability($record)['description']),
|
||||
TextColumn::make('latest_review')
|
||||
->label(__('localization.review.latest_review'))
|
||||
->label(__('localization.review.status'))
|
||||
->width('9rem')
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)),
|
||||
TextColumn::make('evidence_proof_state')
|
||||
->label(__('localization.review.evidence_status'))
|
||||
->width('8rem')
|
||||
->badge()
|
||||
->getStateUsing(fn (Tenant $record): string => $this->evidenceStatusLabel($record))
|
||||
->color(fn (Tenant $record): string => $this->evidenceStatusColor($record)),
|
||||
TextColumn::make('recommended_next_action')
|
||||
->label(__('localization.review.next_step'))
|
||||
->width('10rem')
|
||||
->extraHeaderAttributes(['class' => 'whitespace-normal'])
|
||||
->getStateUsing(fn (Tenant $record): string => $this->controlRecommendedNextAction($record))
|
||||
->wrap(),
|
||||
TextColumn::make('finding_summary')
|
||||
->label(__('localization.review.key_findings'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('accepted_risk_summary')
|
||||
->label(__('localization.review.accepted_risks'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('published_at')
|
||||
->label(__('localization.review.published'))
|
||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('review_pack_state')
|
||||
->label(__('localization.review.review_pack'))
|
||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
||||
TextColumn::make('open_review')
|
||||
->label(__('localization.review.open'))
|
||||
->width('8rem')
|
||||
->getStateUsing(fn (): string => __('localization.review.open_review'))
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->color('primary'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
@ -189,24 +203,12 @@ public function table(Table $table): Table
|
||||
})
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Action::make('open_latest_review')
|
||||
->label(__('localization.review.open_latest_review'))
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
||||
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
||||
Action::make('download_review_pack')
|
||||
->label(__('localization.review.download_review_pack'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
|
||||
->emptyStateHeading(__('localization.review.no_released_customer_reviews'))
|
||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
||||
? __('localization.review.clear_filters_description')
|
||||
: __('localization.review.adjust_filters_description'))
|
||||
: __('localization.review.no_released_customer_reviews_description'))
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters_empty')
|
||||
->label(__('localization.review.clear_filters'))
|
||||
@ -260,6 +262,34 @@ private function authorizePageAccess(): void
|
||||
}
|
||||
}
|
||||
|
||||
private function auditWorkspaceOpen(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CustomerReviewWorkspaceOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
'entitled_tenant_count' => count($this->authorizedTenants()),
|
||||
'interpretation_version' => $this->currentTenantFilterInterpretationVersion(),
|
||||
'interpretation_versions' => $this->visibleInterpretationVersions(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'customer_review_workspace',
|
||||
resourceId: (string) $workspace->getKey(),
|
||||
targetLabel: __('localization.review.customer_review_workspace'),
|
||||
);
|
||||
}
|
||||
|
||||
private function workspaceQuery(): Builder
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -361,47 +391,19 @@ private function latestReviewUrl(Tenant $tenant): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
|
||||
$query = array_filter(
|
||||
array_replace(
|
||||
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
|
||||
[
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
],
|
||||
$this->navigationContext()?->toQuery() ?? [],
|
||||
),
|
||||
static fn (mixed $value): bool => $value !== null && $value !== '',
|
||||
);
|
||||
}
|
||||
|
||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
$pack = $review?->currentExportReviewPack;
|
||||
|
||||
return $pack instanceof ReviewPack ? $pack : null;
|
||||
}
|
||||
|
||||
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
|
||||
{
|
||||
$user = auth()->user();
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
|
||||
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
]);
|
||||
return $this->appendQuery(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'), $query);
|
||||
}
|
||||
|
||||
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
||||
@ -434,12 +436,34 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
||||
|
||||
private function latestReviewStateLabel(Tenant $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review');
|
||||
}
|
||||
|
||||
return $this->workspaceReviewNeedsAttention($tenant)
|
||||
? __('localization.review.review_requires_attention')
|
||||
: __('localization.review.ready_for_release');
|
||||
}
|
||||
|
||||
private function latestReviewStateColor(Tenant $tenant): string
|
||||
{
|
||||
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$packageState = $this->governancePackageAvailability($tenant)['state'];
|
||||
|
||||
if (! $this->workspaceReviewNeedsAttention($tenant)) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
return in_array($packageState, ['blocked', 'expired'], true)
|
||||
? 'danger'
|
||||
: 'warning';
|
||||
}
|
||||
|
||||
private function latestReviewStateIcon(Tenant $tenant): ?string
|
||||
@ -477,6 +501,342 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
|
||||
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function controlReadinessLabel(Tenant $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_readiness_unmapped');
|
||||
}
|
||||
|
||||
$label = $control['readiness_label'] ?? null;
|
||||
|
||||
return is_string($label) && trim($label) !== ''
|
||||
? $label
|
||||
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function governancePackageSummary(Tenant $tenant): array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||
|
||||
return $package;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,label:string,description:string}
|
||||
*/
|
||||
private function governancePackageAvailability(Tenant $tenant): array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.no_published_review_available'),
|
||||
];
|
||||
}
|
||||
|
||||
$pack = $review->currentExportReviewPack;
|
||||
$user = auth()->user();
|
||||
$limitations = is_array($review->controlInterpretation()['limitations'] ?? null) ? $review->controlInterpretation()['limitations'] : [];
|
||||
$isPartialReview = in_array((string) $review->completeness_state, [
|
||||
TenantReviewCompletenessState::Partial->value,
|
||||
TenantReviewCompletenessState::Stale->value,
|
||||
], true) || $limitations !== [];
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return [
|
||||
'state' => 'blocked',
|
||||
'label' => __('localization.review.governance_package_blocked'),
|
||||
'description' => __('localization.review.governance_package_blocked_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||
return [
|
||||
'state' => 'expired',
|
||||
'label' => __('localization.review.governance_package_expired'),
|
||||
'description' => __('localization.review.governance_package_expired_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPartialReview) {
|
||||
return [
|
||||
'state' => 'partial',
|
||||
'label' => __('localization.review.governance_package_partial'),
|
||||
'description' => __('localization.review.governance_package_partial_description'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'available',
|
||||
'label' => __('localization.review.governance_package_available'),
|
||||
'description' => __('localization.review.governance_package_available_description'),
|
||||
];
|
||||
}
|
||||
|
||||
private function governancePackageAvailabilityLabel(Tenant $tenant): string
|
||||
{
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available' => __('localization.review.available'),
|
||||
'partial' => __('localization.review.partial'),
|
||||
'blocked' => __('localization.review.blocked'),
|
||||
'expired' => __('localization.review.expired'),
|
||||
default => __('localization.review.unavailable'),
|
||||
};
|
||||
}
|
||||
|
||||
private function governancePackageAvailabilityColor(Tenant $tenant): string
|
||||
{
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available' => 'success',
|
||||
'partial' => 'warning',
|
||||
'blocked', 'expired' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function governancePackageTeaser(Tenant $tenant): string
|
||||
{
|
||||
$package = $this->governancePackageSummary($tenant);
|
||||
|
||||
$executiveSummary = $package['executive_summary'] ?? null;
|
||||
|
||||
if (is_string($executiveSummary) && trim($executiveSummary) !== '') {
|
||||
return $executiveSummary;
|
||||
}
|
||||
|
||||
return $this->governancePackageAvailability($tenant)['description'];
|
||||
}
|
||||
|
||||
private function controlReadinessColor(Tenant $tenant): string
|
||||
{
|
||||
return match ((string) ($this->primaryControlSummary($tenant)['readiness_bucket'] ?? 'unmapped')) {
|
||||
'follow_up_required' => 'warning',
|
||||
'review_recommended' => 'info',
|
||||
'evidence_on_record' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function controlReadinessDescription(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
$controls = $review->controlInterpretationControls();
|
||||
|
||||
if ($controls === []) {
|
||||
return __('localization.review.control_readiness_unmapped_description');
|
||||
}
|
||||
|
||||
$summary = collect($controls)
|
||||
->take(2)
|
||||
->map(function (array $control): string {
|
||||
$name = is_string($control['control_name'] ?? null) ? $control['control_name'] : __('localization.review.control');
|
||||
$label = is_string($control['readiness_label'] ?? null)
|
||||
? $control['readiness_label']
|
||||
: ComplianceEvidenceMappingV1::readinessLabel((string) ($control['readiness_bucket'] ?? 'review_recommended'));
|
||||
|
||||
return $name.': '.$label;
|
||||
})
|
||||
->implode(' · ');
|
||||
|
||||
$remaining = count($controls) - 2;
|
||||
|
||||
if ($remaining > 0) {
|
||||
$summary .= ' · '.__('localization.review.additional_controls', ['count' => $remaining]);
|
||||
}
|
||||
|
||||
$limitations = $this->controlLimitationSummary($review);
|
||||
|
||||
return trim($summary.($limitations !== null ? ' '.$limitations : ''));
|
||||
}
|
||||
|
||||
private function controlEvidenceBasisSummary(Tenant $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_evidence_unmapped');
|
||||
}
|
||||
|
||||
$summary = $control['evidence_basis_summary'] ?? null;
|
||||
|
||||
return is_string($summary) && trim($summary) !== ''
|
||||
? $summary
|
||||
: __('localization.review.control_evidence_unavailable');
|
||||
}
|
||||
|
||||
private function controlRecommendedNextAction(Tenant $tenant): string
|
||||
{
|
||||
if ($this->primaryControlSummary($tenant) === null) {
|
||||
return __('localization.review.workspace_next_step_control_mapping');
|
||||
}
|
||||
|
||||
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||
return __('localization.review.workspace_next_step_evidence_review');
|
||||
}
|
||||
|
||||
return match ($this->governancePackageAvailability($tenant)['state']) {
|
||||
'available', 'partial' => __('localization.review.workspace_next_step_package_review'),
|
||||
default => __('localization.review.workspace_next_step_review_open'),
|
||||
};
|
||||
}
|
||||
|
||||
private function workspaceReviewNeedsAttention(Tenant $tenant): bool
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->primaryControlSummary($tenant) === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->evidenceStatusState($tenant) !== 'available') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->governancePackageAvailability($tenant)['state'] !== 'available';
|
||||
}
|
||||
|
||||
private function evidenceStatusState(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return 'restricted';
|
||||
}
|
||||
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
return 'available';
|
||||
}
|
||||
|
||||
private function evidenceStatusLabelForState(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'available' => __('localization.review.available'),
|
||||
'restricted' => __('localization.review.restricted'),
|
||||
'expired' => __('localization.review.expired'),
|
||||
default => __('localization.review.pending'),
|
||||
};
|
||||
}
|
||||
|
||||
private function evidenceStatusColorForState(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'available' => 'success',
|
||||
'restricted', 'expired' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function controlRecommendedNextActionDescription(Tenant $tenant): string
|
||||
{
|
||||
$control = $this->primaryControlSummary($tenant);
|
||||
|
||||
if ($control === null) {
|
||||
return __('localization.review.control_recommendation_unmapped');
|
||||
}
|
||||
|
||||
$action = $control['recommended_next_action'] ?? null;
|
||||
|
||||
return is_string($action) && trim($action) !== ''
|
||||
? $action
|
||||
: __('localization.review.no_action_needed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function primaryControlSummary(Tenant $tenant): ?array
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $review instanceof TenantReview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$controls = collect($review->controlInterpretationControls());
|
||||
|
||||
return $controls
|
||||
->sortBy(static fn (array $control): int => match ((string) ($control['readiness_bucket'] ?? '')) {
|
||||
'follow_up_required' => 0,
|
||||
'review_recommended' => 1,
|
||||
'evidence_on_record' => 2,
|
||||
default => 3,
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
private function controlLimitationSummary(TenantReview $review): ?string
|
||||
{
|
||||
$counts = $review->controlInterpretationLimitationCounts();
|
||||
|
||||
if ($counts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$labels = collect($counts)
|
||||
->filter(static fn (int $count): bool => $count > 0)
|
||||
->keys()
|
||||
->map(static fn (string $flag): string => ComplianceEvidenceMappingV1::limitationLabel($flag))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $labels === []
|
||||
? null
|
||||
: __('localization.review.control_limitations_summary', ['limitations' => implode(', ', $labels)]);
|
||||
}
|
||||
|
||||
private function findingSummary(Tenant $tenant): string
|
||||
{
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
@ -518,31 +878,142 @@ private function acceptedRiskSummary(Tenant $tenant): string
|
||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
||||
|
||||
return match (true) {
|
||||
$countSummary = match (true) {
|
||||
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
|
||||
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
|
||||
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
|
||||
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
|
||||
};
|
||||
|
||||
$accountability = $this->acceptedRiskAccountability($tenant);
|
||||
|
||||
return $accountability === null
|
||||
? $countSummary
|
||||
: $countSummary.' '.$accountability;
|
||||
}
|
||||
|
||||
private function reviewPackAvailability(Tenant $tenant): string
|
||||
private function evidenceProofAvailability(Tenant $tenant): string
|
||||
{
|
||||
$pack = $this->latestReviewPack($tenant);
|
||||
$review = $this->latestPublishedReview($tenant);
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.unavailable');
|
||||
if (! $review instanceof TenantReview) {
|
||||
return __('localization.review.no_published_review_available');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.unavailable');
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_proof_absent');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.unavailable');
|
||||
if (! $user instanceof User || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_proof_access_unavailable');
|
||||
}
|
||||
|
||||
return __('localization.review.available');
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return __('localization.review.evidence_proof_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.evidence_proof_available');
|
||||
}
|
||||
|
||||
private function evidenceStatusLabel(Tenant $tenant): string
|
||||
{
|
||||
return $this->evidenceStatusLabelForState($this->evidenceStatusState($tenant));
|
||||
}
|
||||
|
||||
private function evidenceStatusColor(Tenant $tenant): string
|
||||
{
|
||||
return $this->evidenceStatusColorForState($this->evidenceStatusState($tenant));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function visibleInterpretationVersions(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
|
||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return app(TenantReviewRegisterService::class)
|
||||
->latestPublishedQuery($user, $workspace)
|
||||
->get()
|
||||
->map(static fn (TenantReview $review): ?string => $review->controlInterpretationVersion())
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function currentTenantFilterInterpretationVersion(): ?string
|
||||
{
|
||||
$tenantId = $this->currentTenantFilterId();
|
||||
|
||||
if ($tenantId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $this->authorizedTenants()[$tenantId] ?? null;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant->tenantReviews()->published()
|
||||
->latest('published_at')
|
||||
->latest('generated_at')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->controlInterpretationVersion();
|
||||
}
|
||||
|
||||
private function acceptedRiskAccountability(Tenant $tenant): ?string
|
||||
{
|
||||
$exception = FindingException::query()
|
||||
->with(['owner', 'approver', 'currentDecision'])
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->current()
|
||||
->orderByRaw("case when current_validity_state in ('valid', 'expiring') then 0 else 1 end")
|
||||
->latest('approved_at')
|
||||
->latest('requested_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$accountable = $exception->owner?->name
|
||||
?? $exception->approver?->name;
|
||||
$decisionType = $exception->currentDecision?->decision_type;
|
||||
$reviewDue = $exception->review_due_at ?? $exception->expires_at;
|
||||
$reason = is_string($exception->request_reason) ? trim($exception->request_reason) : '';
|
||||
$parts = [];
|
||||
|
||||
if (is_string($accountable) && trim($accountable) !== '') {
|
||||
$parts[] = $reviewDue === null
|
||||
? __('localization.review.accepted_risk_accountable', ['name' => $accountable])
|
||||
: __('localization.review.accepted_risk_accountable_until', [
|
||||
'name' => $accountable,
|
||||
'date' => $reviewDue->toDateString(),
|
||||
]);
|
||||
} elseif (is_string($decisionType) && trim($decisionType) !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_partial_accountability');
|
||||
}
|
||||
|
||||
if ($reason !== '') {
|
||||
$parts[] = __('localization.review.accepted_risk_reason', [
|
||||
'reason' => Str::limit($reason, 160),
|
||||
]);
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
|
||||
@ -174,9 +174,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
@ -222,6 +225,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
@ -236,7 +240,7 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
if (! static::isCustomerWorkspaceFlow() && is_numeric($record->operation_run_id)) {
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'operation_run',
|
||||
label: 'Operation',
|
||||
@ -255,12 +259,18 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
->first();
|
||||
|
||||
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||
$packUrl = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
|
||||
if (static::isCustomerWorkspaceFlow()) {
|
||||
$packUrl = static::appendQuery($packUrl, static::customerWorkspaceContextQuery());
|
||||
}
|
||||
|
||||
$entries[] = RelatedContextEntry::available(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
value: sprintf('#%d', (int) $pack->getKey()),
|
||||
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||
targetUrl: $packUrl,
|
||||
targetKind: 'direct_record',
|
||||
priority: 20,
|
||||
actionLabel: 'View review pack',
|
||||
@ -285,6 +295,36 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function customerWorkspaceContextQuery(): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => request()->query('review_id'),
|
||||
'interpretation_version' => request()->query('interpretation_version'),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
|
||||
@ -5,8 +5,13 @@
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
@ -20,6 +25,13 @@ class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$this->auditCustomerWorkspaceProofOpen();
|
||||
}
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
@ -27,6 +39,10 @@ protected function resolveRecord(int|string $key): Model
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refreshRule = GovernanceActionCatalog::rule('refresh_evidence');
|
||||
$expireRule = GovernanceActionCatalog::rule('expire_snapshot');
|
||||
|
||||
@ -90,4 +106,44 @@ protected function getHeaderActions(): array
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function auditCustomerWorkspaceProofOpen(): void
|
||||
{
|
||||
if (! EvidenceSnapshotResource::isCustomerWorkspaceFlow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = $this->record;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $record instanceof EvidenceSnapshot || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(WorkspaceAuditLogger::class)->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotOpened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'evidence_snapshot_id' => (int) $record->getKey(),
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => request()->query('review_id'),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => request()->query('interpretation_version'),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $record->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $record->getKey()),
|
||||
tenant: $tenant,
|
||||
operationRunId: $record->operation_run_id !== null ? (int) $record->operation_run_id : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
@ -36,7 +37,18 @@ protected function getHeaderActions(): array
|
||||
$renewRule = GovernanceActionCatalog::rule('renew_exception');
|
||||
$revokeRule = GovernanceActionCatalog::rule('revoke_exception');
|
||||
|
||||
return [
|
||||
$actions = [];
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
|
||||
$actions[] = Action::make('return_to_decision_register')
|
||||
->label($navigationContext->backLinkLabel)
|
||||
->icon('heroicon-o-arrow-left')
|
||||
->color('gray')
|
||||
->url($navigationContext->backLinkUrl);
|
||||
}
|
||||
|
||||
return array_merge($actions, [
|
||||
Action::make('renew_exception')
|
||||
->label($renewRule->canonicalLabel)
|
||||
->icon('heroicon-o-arrow-path')
|
||||
@ -159,7 +171,18 @@ protected function getHeaderActions(): array
|
||||
|
||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
||||
}),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$navigationContext = $this->navigationContext();
|
||||
|
||||
if ($navigationContext?->sourceSurface === 'governance.decision_register') {
|
||||
return 'Opened from the workspace decision register. Use the back action to return to the same register scope.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,4 +222,9 @@ private function canManageRecord(): bool
|
||||
&& $user->canAccessTenant($record->tenant)
|
||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
||||
}
|
||||
|
||||
private function navigationContext(): ?CanonicalNavigationContext
|
||||
{
|
||||
return CanonicalNavigationContext::fromRequest(request());
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,6 +72,16 @@ class PolicyResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return static::text('common.policy');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return static::text('common.policies');
|
||||
}
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
@ -100,7 +110,7 @@ public static function canViewAny(): bool
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::CrudListFirstResource)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync policies.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More" in helper-first, workflow-next, destructive-last order.')
|
||||
@ -112,12 +122,12 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make($name)
|
||||
->label('Sync from Intune')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Sync policies from Intune')
|
||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||
->modalHeading(static::text('resource.sync_modal_heading'))
|
||||
->modalDescription(static::text('resource.sync_modal_description').' '.static::text('common.source_microsoft_intune'))
|
||||
->action(function (Pages\ListPolicies $livewire): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -150,7 +160,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -165,14 +175,14 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to sync policies.')
|
||||
->tooltip(static::text('resource.sync_permission_tooltip'))
|
||||
->apply();
|
||||
}
|
||||
|
||||
@ -185,16 +195,31 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Policy Details')
|
||||
Section::make(static::text('resource.details_section'))
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Policy'),
|
||||
TextEntry::make('policy_type')->label('Type'),
|
||||
TextEntry::make('platform'),
|
||||
TextEntry::make('external_id')->label('External ID'),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label('Last synced'),
|
||||
TextEntry::make('created_at')->since(),
|
||||
TextEntry::make('display_name')->label(static::text('common.policy')),
|
||||
TextEntry::make('policy_type')->label(static::text('common.type')),
|
||||
TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
TextEntry::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->helperText(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_backup_items')
|
||||
: null),
|
||||
TextEntry::make('external_id')->label(static::text('common.external_id')),
|
||||
TextEntry::make('last_synced_at')->dateTime()->label(static::text('common.last_synced')),
|
||||
TextEntry::make('created_at')->since()->label(static::text('common.created')),
|
||||
TextEntry::make('latest_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicySnapshotMode))
|
||||
@ -211,8 +236,8 @@ public static function infolist(Schema $schema): Schema
|
||||
$status = $meta['original_status'] ?? null;
|
||||
|
||||
return sprintf(
|
||||
'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
$status ?? 'an error'
|
||||
static::text('resource.snapshot_metadata_only_helper'),
|
||||
$status ?? static::text('resource.graph_error_fallback')
|
||||
);
|
||||
})
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
@ -225,7 +250,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->activeTab(1)
|
||||
->persistTabInQueryString()
|
||||
->tabs([
|
||||
Tab::make('General')
|
||||
Tab::make(static::text('resource.tab_general'))
|
||||
->id('general')
|
||||
->schema([
|
||||
ViewEntry::make('policy_general')
|
||||
@ -236,7 +261,7 @@ public static function infolist(Schema $schema): Schema
|
||||
}),
|
||||
])
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
Tab::make('Settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('settings')
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
@ -248,12 +273,12 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => $record->versions()->exists()),
|
||||
|
||||
TextEntry::make('no_settings_available')
|
||||
->label('Settings')
|
||||
->state('No policy snapshot available yet.')
|
||||
->helperText('This policy has been inventoried but no configuration snapshot has been captured yet.')
|
||||
->label(static::text('common.settings'))
|
||||
->state(static::text('resource.settings_empty_state'))
|
||||
->helperText(static::text('resource.settings_empty_state_helper'))
|
||||
->visible(fn (Policy $record) => ! $record->versions()->exists()),
|
||||
]),
|
||||
Tab::make('JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('json')
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
@ -261,7 +286,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->state(fn (Policy $record) => static::latestSnapshot($record))
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -269,7 +294,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -284,7 +309,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
|
||||
|
||||
// Legacy layout (kept for fallback if tabs are disabled)
|
||||
Section::make('Settings')
|
||||
Section::make(static::text('common.settings'))
|
||||
->schema([
|
||||
ViewEntry::make('settings')
|
||||
->label('')
|
||||
@ -298,7 +323,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return ! static::usesTabbedLayout($record);
|
||||
}),
|
||||
|
||||
Section::make('Policy Snapshot (JSON)')
|
||||
Section::make(static::text('resource.snapshot_json_section'))
|
||||
->schema([
|
||||
ViewEntry::make('snapshot_json')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
@ -306,7 +331,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->columnSpanFull(),
|
||||
|
||||
TextEntry::make('snapshot_size')
|
||||
->label('Payload Size')
|
||||
->label(static::text('resource.payload_size'))
|
||||
->state(fn (Policy $record) => strlen(json_encode(static::latestSnapshot($record) ?: [])))
|
||||
->formatStateUsing(function ($state) {
|
||||
if ($state > 512000) {
|
||||
@ -314,7 +339,7 @@ public static function infolist(Schema $schema): Schema
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Large payload ('.number_format($state / 1024, 0).' KB) - May impact performance
|
||||
'.static::text('resource.large_payload_warning', ['size' => number_format($state / 1024, 0)]).'
|
||||
</span>';
|
||||
}
|
||||
|
||||
@ -336,11 +361,6 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(function (Builder $query) {
|
||||
// Quick-Workaround: Hide policies not synced in last 7 days
|
||||
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
||||
$query->where('last_synced_at', '>', now()->subDays(7));
|
||||
})
|
||||
->defaultSort('display_name')
|
||||
->paginated(TablePaginationProfiles::resource())
|
||||
->persistFiltersInSession()
|
||||
@ -349,24 +369,36 @@ public static function table(Table $table): Table
|
||||
->recordUrl(fn (Policy $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('visibility_state')
|
||||
->label(static::text('common.visibility'))
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->description(fn (Policy $record): ?string => $record->isProviderMissing()
|
||||
? static::text('resource.visibility_source_unavailable_description')
|
||||
: null),
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->label(static::text('common.category'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory))
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('restore_mode')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->badge()
|
||||
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
|
||||
@ -374,19 +406,22 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('settings_status')
|
||||
->label('Settings')
|
||||
->label(static::text('common.settings'))
|
||||
->badge()
|
||||
->state(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
$snapshot = $latest?->snapshot ?? [];
|
||||
$hasSettings = is_array($snapshot) && ! empty($snapshot['settings']);
|
||||
|
||||
return $hasSettings ? 'Available' : 'Missing';
|
||||
return $hasSettings
|
||||
? static::text('resource.settings_available')
|
||||
: static::text('resource.settings_missing');
|
||||
})
|
||||
->color(function (Policy $record) {
|
||||
$latest = $record->versions->first();
|
||||
@ -396,12 +431,12 @@ public static function table(Table $table): Table
|
||||
return $hasSettings ? 'success' : 'gray';
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
->label(static::text('common.external_id'))
|
||||
->copyable()
|
||||
->limit(32)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('last_synced_at')
|
||||
->label('Last synced')
|
||||
->label(static::text('common.last_synced'))
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
@ -411,27 +446,35 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('visibility')
|
||||
->label('Visibility')
|
||||
->label(static::text('common.visibility'))
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'ignored' => 'Ignored',
|
||||
'active' => static::text('resource.filter_active'),
|
||||
'ignored' => static::text('resource.filter_ignored'),
|
||||
'provider_missing' => static::text('resource.filter_source_unavailable'),
|
||||
'all' => static::text('resource.filter_all'),
|
||||
])
|
||||
->default('active')
|
||||
->query(function (Builder $query, array $data) {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value)) {
|
||||
if (blank($value) || $value === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'active') {
|
||||
$query->whereNull('ignored_at');
|
||||
$query->active();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'ignored') {
|
||||
$query->whereNotNull('ignored_at');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'provider_missing') {
|
||||
$query->whereNotNull('missing_from_provider_at');
|
||||
}
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
@ -475,14 +518,16 @@ public static function table(Table $table): Table
|
||||
ActionGroup::make([
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->disabled(fn (Policy $record): bool => ! $record->isCurrentBackupEligible())
|
||||
->tooltip(fn (Policy $record): ?string => $record->currentBackupBlockedReasonLabel())
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -496,6 +541,16 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $record->isCurrentBackupEligible()) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($record->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = [(int) $record->getKey()];
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
@ -533,7 +588,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -541,11 +596,12 @@ public static function table(Table $table): Table
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveDisabled()
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('sync')
|
||||
->label('Sync')
|
||||
->label(static::text('resource.sync_action_secondary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -579,7 +635,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -592,7 +648,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -604,7 +660,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('resource.restore_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -613,19 +669,19 @@ public static function table(Table $table): Table
|
||||
$record->unignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy restored')
|
||||
->title(static::text('resource.policy_restored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
->tooltip(static::text('resource.restore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('ignore')
|
||||
->label('Ignore')
|
||||
->label(static::text('resource.ignore_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -634,31 +690,31 @@ public static function table(Table $table): Table
|
||||
$record->ignore();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy ignored')
|
||||
->title(static::text('resource.policy_ignored'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
->tooltip(static::text('resource.ignore_permission_tooltip'))
|
||||
->preserveVisibility()
|
||||
->apply(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_export')
|
||||
->label('Export to Backup')
|
||||
->label(static::text('resource.export_to_backup'))
|
||||
->icon('heroicon-o-archive-box-arrow-down')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('backup_name')
|
||||
->label('Backup Name')
|
||||
->label(static::text('common.backup_name'))
|
||||
->required()
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
->default(fn () => static::text('common.backup_name_default_prefix').' '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -674,6 +730,20 @@ public static function table(Table $table): Table
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$blocked = $records->first(
|
||||
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||
);
|
||||
|
||||
if ($blocked instanceof Policy) {
|
||||
Notification::make()
|
||||
->title(static::text('resource.current_backup_unavailable'))
|
||||
->body($blocked->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
@ -721,7 +791,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -732,7 +802,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_sync')
|
||||
->label('Sync Policies')
|
||||
->label(static::text('resource.sync_action_primary'))
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
@ -779,7 +849,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -792,7 +862,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -803,7 +873,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_restore')
|
||||
->label('Restore Policies')
|
||||
->label(static::text('resource.restore_bulk_action'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -873,7 +943,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -884,7 +954,7 @@ public static function table(Table $table): Table
|
||||
->apply(),
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('bulk_delete')
|
||||
->label('Ignore Policies')
|
||||
->label(static::text('resource.ignore_bulk_action'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -898,11 +968,11 @@ public static function table(Table $table): Table
|
||||
if ($records->count() >= 20) {
|
||||
return [
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@ -955,10 +1025,10 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -967,10 +1037,10 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->body(static::text('resource.delete_queued_body', ['count' => $count]))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
@ -979,10 +1049,10 @@ public static function table(Table $table): Table
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policies synced yet')
|
||||
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
|
||||
->emptyStateHeading(static::text('resource.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('resource.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-arrow-path')
|
||||
->emptyStateActions([
|
||||
static::makeSyncAction(),
|
||||
@ -1159,25 +1229,25 @@ private static function generalOverviewState(Policy $record): array
|
||||
|
||||
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
|
||||
if (is_string($name) && $name !== '') {
|
||||
$entries[] = ['key' => 'Name', 'value' => $name];
|
||||
$entries[] = ['key' => static::text('resource.general_field_name'), 'value' => $name];
|
||||
}
|
||||
|
||||
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
|
||||
if (is_string($platforms) && $platforms !== '') {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
} elseif (is_array($platforms) && $platforms !== []) {
|
||||
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
|
||||
$entries[] = ['key' => static::text('resource.general_field_platforms'), 'value' => $platforms];
|
||||
}
|
||||
|
||||
$technologies = $snapshot['technologies'] ?? null;
|
||||
if (is_string($technologies) && $technologies !== '') {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
} elseif (is_array($technologies) && $technologies !== []) {
|
||||
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
|
||||
$entries[] = ['key' => static::text('resource.general_field_technologies'), 'value' => $technologies];
|
||||
}
|
||||
|
||||
if (array_key_exists('templateReference', $snapshot)) {
|
||||
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
|
||||
$entries[] = ['key' => static::text('resource.general_field_template_reference'), 'value' => $snapshot['templateReference']];
|
||||
}
|
||||
|
||||
$settingCount = $snapshot['settingCount']
|
||||
@ -1185,29 +1255,29 @@ private static function generalOverviewState(Policy $record): array
|
||||
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
|
||||
|
||||
if (is_int($settingCount) || is_numeric($settingCount)) {
|
||||
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
|
||||
$entries[] = ['key' => static::text('resource.general_field_setting_count'), 'value' => $settingCount];
|
||||
}
|
||||
|
||||
$version = $snapshot['version'] ?? null;
|
||||
if (is_string($version) && $version !== '') {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
} elseif (is_numeric($version)) {
|
||||
$entries[] = ['key' => 'Version', 'value' => $version];
|
||||
$entries[] = ['key' => static::text('resource.general_field_version'), 'value' => $version];
|
||||
}
|
||||
|
||||
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
|
||||
if (is_string($lastModified) && $lastModified !== '') {
|
||||
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
|
||||
$entries[] = ['key' => static::text('resource.general_field_last_modified'), 'value' => $lastModified];
|
||||
}
|
||||
|
||||
$createdAt = $snapshot['createdDateTime'] ?? null;
|
||||
if (is_string($createdAt) && $createdAt !== '') {
|
||||
$entries[] = ['key' => 'Created', 'value' => $createdAt];
|
||||
$entries[] = ['key' => static::text('resource.general_field_created'), 'value' => $createdAt];
|
||||
}
|
||||
|
||||
$description = $snapshot['description'] ?? null;
|
||||
if (is_string($description) && $description !== '') {
|
||||
$entries[] = ['key' => 'Description', 'value' => $description];
|
||||
$entries[] = ['key' => static::text('resource.general_field_description'), 'value' => $description];
|
||||
}
|
||||
|
||||
return [
|
||||
@ -1232,4 +1302,9 @@ private static function settingsTabState(Policy $record): array
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Jobs\CapturePolicySnapshotJob;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
@ -39,23 +40,37 @@ private function makeCaptureSnapshotAction(): Action
|
||||
{
|
||||
$action = UiEnforcement::forAction(
|
||||
Action::make('capture_snapshot')
|
||||
->label('Capture snapshot')
|
||||
->label($this->text('resource.capture_snapshot_action'))
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Capture snapshot now')
|
||||
->modalSubheading('This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.')
|
||||
->modalHeading($this->text('resource.capture_snapshot_modal_heading'))
|
||||
->modalSubheading($this->text('resource.capture_snapshot_modal_subheading').' '.$this->text('common.source_microsoft_intune'))
|
||||
->disabled(fn (): bool => $this->record instanceof Policy && $this->record->isProviderMissing())
|
||||
->tooltip(fn (): ?string => $this->record instanceof Policy && $this->record->isProviderMissing()
|
||||
? $this->record->currentBackupBlockedReasonLabel()
|
||||
: null)
|
||||
->form([
|
||||
Forms\Components\Checkbox::make('include_assignments')
|
||||
->label('Include assignments')
|
||||
->label($this->text('resource.capture_snapshot_include_assignments'))
|
||||
->default(true)
|
||||
->helperText('Captures assignment include/exclude targeting and filters.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_assignments_helper')),
|
||||
Forms\Components\Checkbox::make('include_scope_tags')
|
||||
->label('Include scope tags')
|
||||
->label($this->text('resource.capture_snapshot_include_scope_tags'))
|
||||
->default(true)
|
||||
->helperText('Captures policy scope tag IDs.'),
|
||||
->helperText($this->text('resource.capture_snapshot_include_scope_tags_helper')),
|
||||
])
|
||||
->action(function (array $data, AuditLogger $auditLogger) {
|
||||
$policy = $this->record;
|
||||
|
||||
if ($policy instanceof Policy && $policy->isProviderMissing()) {
|
||||
Notification::make()
|
||||
->title($this->text('resource.capture_snapshot_unavailable_title'))
|
||||
->body($policy->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $policy->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
@ -108,11 +123,11 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Snapshot already in progress')
|
||||
->body('An active run already exists for this policy. Opening run details.')
|
||||
->title($this->text('resource.capture_snapshot_in_progress_title'))
|
||||
->body($this->text('resource.capture_snapshot_in_progress_body'))
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
@ -145,7 +160,7 @@ private function makeCaptureSnapshotAction(): Action
|
||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label($this->text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -155,7 +170,8 @@ private function makeCaptureSnapshotAction(): Action
|
||||
->color('primary')
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->tooltip('You do not have permission to capture policy snapshots.')
|
||||
->tooltip($this->text('resource.capture_snapshot_permission_tooltip'))
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
|
||||
if (! $action instanceof Action) {
|
||||
@ -164,4 +180,9 @@ private function makeCaptureSnapshotAction(): Action
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,15 +59,15 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||
->label('Restore to Intune')
|
||||
->label($this->text('relation.restore_to_microsoft_intune'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => $this->text('relation.restore_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading($this->text('relation.restore_subheading'))
|
||||
->form([
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->label($this->text('common.preview_only_dry_run'))
|
||||
->default(true),
|
||||
])
|
||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||
@ -77,7 +77,7 @@ public function table(Table $table): Table
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Missing tenant or user context.')
|
||||
->title($this->text('relation.missing_context_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -86,7 +86,7 @@ public function table(Table $table): Table
|
||||
|
||||
if ($record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title($this->text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -103,7 +103,7 @@ public function table(Table $table): Table
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title('Restore run failed to start')
|
||||
->title($this->text('relation.restore_run_failed_title'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
@ -112,7 +112,7 @@ public function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Restore run started')
|
||||
->title($this->text('relation.restore_run_started_title'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
@ -146,7 +146,7 @@ public function table(Table $table): Table
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return $this->text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -171,10 +171,11 @@ public function table(Table $table): Table
|
||||
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
|
||||
Tables\Columns\TextColumn::make('version_number')->label($this->text('common.version'))->sortable(),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label($this->text('common.captured'))->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label($this->text('common.actor')),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label($this->text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
||||
@ -189,8 +190,8 @@ public function table(Table $table): Table
|
||||
$restoreToIntune,
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No versions captured')
|
||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||
->emptyStateHeading($this->text('relation.no_versions_captured'))
|
||||
->emptyStateDescription($this->text('relation.no_versions_captured_description'));
|
||||
}
|
||||
|
||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
||||
@ -214,4 +215,9 @@ private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record):
|
||||
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,23 +121,25 @@ public static function infolist(Schema $schema): Schema
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->state(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Infolists\Components\TextEntry::make('version_number')->label('Version'),
|
||||
Infolists\Components\TextEntry::make('version_number')->label(static::text('common.version')),
|
||||
Infolists\Components\TextEntry::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Infolists\Components\TextEntry::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
|
||||
Section::make('Backup quality')
|
||||
Infolists\Components\TextEntry::make('created_by')->label(static::text('common.actor')),
|
||||
Infolists\Components\TextEntry::make('captured_at')->dateTime()->label(static::text('common.captured')),
|
||||
Section::make(static::text('versions.backup_quality_section'))
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('quality_snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -145,27 +147,27 @@ public static function infolist(Schema $schema): Schema
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Infolists\Components\TextEntry::make('quality_summary')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary),
|
||||
Infolists\Components\TextEntry::make('quality_assignment_signal')
|
||||
->label('Assignment quality')
|
||||
->label(static::text('versions.assignment_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionAssignmentQualityLabel($record)),
|
||||
Infolists\Components\TextEntry::make('quality_next_action')
|
||||
->label('Next action')
|
||||
->label(static::text('versions.next_action'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction),
|
||||
Infolists\Components\TextEntry::make('quality_integrity_warning')
|
||||
->label('Integrity note')
|
||||
->label(static::text('versions.integrity_note'))
|
||||
->state(fn (PolicyVersion $record): ?string => static::policyVersionQualitySummary($record)->integrityWarning)
|
||||
->visible(fn (PolicyVersion $record): bool => static::policyVersionQualitySummary($record)->hasIntegrityWarning())
|
||||
->columnSpanFull(),
|
||||
Infolists\Components\TextEntry::make('quality_boundary')
|
||||
->label('Boundary')
|
||||
->label(static::text('versions.boundary'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->positiveClaimBoundary)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
Section::make(static::text('versions.related_context_section'))
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
->label('')
|
||||
@ -179,7 +181,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->persistTabInQueryString('tab')
|
||||
->columnSpanFull()
|
||||
->tabs([
|
||||
Tab::make('Normalized settings')
|
||||
Tab::make(static::text('common.settings'))
|
||||
->id('normalized-settings')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_settings')
|
||||
@ -198,14 +200,14 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedSettingsSurface::build($normalized, 'policy_version');
|
||||
}),
|
||||
]),
|
||||
Tab::make('Raw JSON')
|
||||
Tab::make(static::text('resource.tab_json'))
|
||||
->id('raw-json')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('snapshot_pretty')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
|
||||
]),
|
||||
Tab::make('Diff')
|
||||
Tab::make(static::text('versions.diff_tab'))
|
||||
->id('diff')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('normalized_diff')
|
||||
@ -226,7 +228,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return NormalizedDiffSurface::build($result, 'policy_version');
|
||||
}),
|
||||
Infolists\Components\ViewEntry::make('diff_json')
|
||||
->label('Raw diff (advanced)')
|
||||
->label(static::text('versions.raw_diff_advanced'))
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (PolicyVersion $record) {
|
||||
$previous = $record->previous();
|
||||
@ -275,11 +277,11 @@ public static function infolist(Schema $schema): Schema
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$bulkPruneVersions = BulkAction::make('bulk_prune_versions')
|
||||
->label('Prune Versions')
|
||||
->label(static::text('versions.prune_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
|
||||
->modalDescription(static::text('versions.prune_modal_description'))
|
||||
->hidden(function (HasTable $livewire): bool {
|
||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||
$value = $trashedFilterState['value'] ?? null;
|
||||
@ -291,8 +293,8 @@ public static function table(Table $table): Table
|
||||
->form(function (Collection $records) {
|
||||
$fields = [
|
||||
Forms\Components\TextInput::make('retention_days')
|
||||
->label('Retention Days')
|
||||
->helperText('Versions captured within the last N days will be skipped.')
|
||||
->label(static::text('versions.retention_days'))
|
||||
->helperText(static::text('versions.retention_days_helper'))
|
||||
->numeric()
|
||||
->required()
|
||||
->default(90)
|
||||
@ -301,11 +303,11 @@ public static function table(Table $table): Table
|
||||
|
||||
if ($records->count() >= 20) {
|
||||
$fields[] = Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -363,7 +365,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -372,11 +374,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkPruneVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkRestoreVersions = BulkAction::make('bulk_restore_versions')
|
||||
->label('Restore Versions')
|
||||
->label(static::text('versions.restore_versions'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
@ -388,8 +390,8 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.restore_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.restore_versions_modal_description'))
|
||||
->action(function (Collection $records) {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -438,7 +440,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -447,11 +449,11 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkRestoreVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
$bulkForceDeleteVersions = BulkAction::make('bulk_force_delete_versions')
|
||||
->label('Force Delete Versions')
|
||||
->label(static::text('versions.force_delete_versions'))
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
@ -463,15 +465,15 @@ public static function table(Table $table): Table
|
||||
|
||||
return ! $isOnlyTrashed;
|
||||
})
|
||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||
->modalHeading(fn (Collection $records) => static::text('versions.force_delete_versions_modal_heading', ['count' => $records->count()]))
|
||||
->modalDescription(static::text('versions.force_delete_versions_modal_description'))
|
||||
->form([
|
||||
Forms\Components\TextInput::make('confirmation')
|
||||
->label('Type DELETE to confirm')
|
||||
->label(static::text('common.type_delete_to_confirm'))
|
||||
->required()
|
||||
->in(['DELETE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type DELETE to confirm.',
|
||||
'in' => static::text('common.type_delete_to_confirm_validation'),
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
@ -522,7 +524,7 @@ public static function table(Table $table): Table
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->label(static::text('common.open_operation'))
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
@ -531,7 +533,7 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction($bulkForceDeleteVersions)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $table
|
||||
@ -542,13 +544,15 @@ public static function table(Table $table): Table
|
||||
->persistSortInSession()
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('policy.display_name')
|
||||
->label('Policy')
|
||||
->label(static::text('common.policy'))
|
||||
->sortable()
|
||||
->searchable()
|
||||
->getStateUsing(fn (PolicyVersion $record): string => static::resolvedDisplayName($record)),
|
||||
Tables\Columns\TextColumn::make('version_number')->sortable(),
|
||||
Tables\Columns\TextColumn::make('version_number')
|
||||
->label(static::text('common.version'))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('snapshot_mode')
|
||||
->label('Snapshot')
|
||||
->label(static::text('common.snapshot'))
|
||||
->badge()
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->snapshotMode)
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicySnapshotMode))
|
||||
@ -556,30 +560,33 @@ public static function table(Table $table): Table
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicySnapshotMode))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicySnapshotMode)),
|
||||
Tables\Columns\TextColumn::make('backup_quality')
|
||||
->label('Backup quality')
|
||||
->label(static::text('versions.backup_quality'))
|
||||
->state(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->compactSummary)
|
||||
->description(fn (PolicyVersion $record): string => static::policyVersionQualitySummary($record)->nextAction)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('policy_type')
|
||||
->label(static::text('common.type'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
|
||||
Tables\Columns\TextColumn::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->badge()
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
|
||||
Tables\Columns\TextColumn::make('created_by')->label('Actor')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_by')->label(static::text('common.actor'))->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('captured_at')->label(static::text('common.captured'))->dateTime()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('policy_type')
|
||||
->label('Type')
|
||||
->label(static::text('common.type'))
|
||||
->options(FilterOptionCatalog::policyTypes())
|
||||
->searchable(),
|
||||
Tables\Filters\SelectFilter::make('platform')
|
||||
->label(static::text('common.platform'))
|
||||
->options(FilterOptionCatalog::platforms())
|
||||
->searchable(),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
FilterPresets::dateRange('captured_at', static::text('common.captured'), 'captured_at'),
|
||||
FilterPresets::archived(),
|
||||
])
|
||||
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||
@ -590,12 +597,12 @@ public static function table(Table $table): Table
|
||||
Actions\ActionGroup::make([
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore_via_wizard')
|
||||
->label('Restore via Wizard')
|
||||
->label(static::text('versions.restore_via_wizard'))
|
||||
->icon('heroicon-o-arrow-path-rounded-square')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
->modalHeading(fn (PolicyVersion $record): string => static::text('versions.restore_via_wizard_modal_heading', ['version' => $record->version_number]))
|
||||
->modalSubheading(static::text('versions.restore_via_wizard_modal_subheading'))
|
||||
->visible(function (): bool {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
@ -646,11 +653,11 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
|
||||
return 'You do not have permission to create restore runs.';
|
||||
return static::text('versions.restore_run_permission_tooltip');
|
||||
}
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
return static::text('versions.metadata_only_tooltip');
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -676,8 +683,8 @@ public static function table(Table $table): Table
|
||||
|
||||
if (static::policyVersionQualitySummary($record)->snapshotMode === 'metadata_only') {
|
||||
Notification::make()
|
||||
->title('Restore disabled for metadata-only snapshot')
|
||||
->body('This snapshot only contains metadata; Graph did not provide policy settings to restore.')
|
||||
->title(static::text('versions.restore_disabled_metadata_title'))
|
||||
->body(static::text('versions.restore_disabled_metadata_body'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
@ -686,7 +693,7 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Policy version belongs to a different tenant')
|
||||
->title(static::text('versions.different_tenant_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -697,7 +704,7 @@ public static function table(Table $table): Table
|
||||
|
||||
if (! $policy) {
|
||||
Notification::make()
|
||||
->title('Policy could not be found for this version')
|
||||
->title(static::text('versions.missing_policy_title'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
@ -706,11 +713,10 @@ public static function table(Table $table): Table
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => sprintf(
|
||||
'Policy Version Restore • %s • v%d',
|
||||
$policy->display_name,
|
||||
$record->version_number
|
||||
),
|
||||
'name' => static::text('versions.backup_set_name', [
|
||||
'policy' => $policy->display_name,
|
||||
'version' => $record->version_number,
|
||||
]),
|
||||
'created_by' => $user?->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
@ -788,7 +794,7 @@ public static function table(Table $table): Table
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('archive')
|
||||
->label('Archive')
|
||||
->label(static::text('versions.archive'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
@ -815,7 +821,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version archived')
|
||||
->title(static::text('versions.archived_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -823,14 +829,14 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->label(static::text('versions.force_delete'))
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
@ -857,7 +863,7 @@ public static function table(Table $table): Table
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version permanently deleted')
|
||||
->title(static::text('versions.force_deleted_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -865,7 +871,7 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
@ -873,7 +879,7 @@ public static function table(Table $table): Table
|
||||
|
||||
(function (): Actions\Action {
|
||||
$action = Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->label(static::text('common.restore'))
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
@ -900,7 +906,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Policy version restored')
|
||||
->title(static::text('versions.restored_title'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
@ -908,13 +914,13 @@ public static function table(Table $table): Table
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to manage policy versions.')
|
||||
->tooltip(static::text('versions.manage_permission_tooltip'))
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
})(),
|
||||
])
|
||||
->label('More')
|
||||
->label(static::text('common.more'))
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray'),
|
||||
])
|
||||
@ -923,14 +929,14 @@ public static function table(Table $table): Table
|
||||
$bulkPruneVersions,
|
||||
$bulkRestoreVersions,
|
||||
$bulkForceDeleteVersions,
|
||||
])->label('More'),
|
||||
])->label(static::text('common.more')),
|
||||
])
|
||||
->emptyStateHeading('No policy versions')
|
||||
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
|
||||
->emptyStateHeading(static::text('versions.empty_state_heading'))
|
||||
->emptyStateDescription(static::text('versions.empty_state_description'))
|
||||
->emptyStateIcon('heroicon-o-clock')
|
||||
->emptyStateActions([
|
||||
Actions\Action::make('open_backup_sets')
|
||||
->label('Open backup sets')
|
||||
->label(static::text('versions.open_backup_sets'))
|
||||
->url(fn (): string => BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->color('gray'),
|
||||
]);
|
||||
@ -1016,7 +1022,7 @@ public static function relatedContextEntries(PolicyVersion $record): array
|
||||
private static function primaryRelatedAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('primary_drill_down')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? 'Open related record')
|
||||
->label(fn (PolicyVersion $record): string => static::primaryRelatedEntry($record)?->actionLabel ?? static::text('versions.related_record_fallback'))
|
||||
->url(fn (PolicyVersion $record): ?string => static::primaryRelatedEntry($record)?->targetUrl)
|
||||
->hidden(fn (PolicyVersion $record): bool => ! (static::primaryRelatedEntry($record)?->isAvailable() ?? false))
|
||||
->color('gray');
|
||||
@ -1032,10 +1038,10 @@ private static function policyVersionAssignmentQualityLabel(PolicyVersion $recor
|
||||
$summary = static::policyVersionQualitySummary($record);
|
||||
|
||||
return match (true) {
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
$summary->hasAssignmentIssues => 'Assignment fetch failed during capture.',
|
||||
$summary->hasOrphanedAssignments => 'Orphaned assignment targets were detected.',
|
||||
default => 'No assignment issues were detected from captured metadata.',
|
||||
$summary->hasAssignmentIssues && $summary->hasOrphanedAssignments => static::text('versions.assignment_fetch_failed_orphaned'),
|
||||
$summary->hasAssignmentIssues => static::text('versions.assignment_fetch_failed'),
|
||||
$summary->hasOrphanedAssignments => static::text('versions.assignment_orphaned'),
|
||||
default => static::text('versions.assignment_no_issues'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1065,6 +1071,11 @@ private static function resolvedDisplayName(PolicyVersion $record): string
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return sprintf('Version %d', (int) $record->version_number);
|
||||
return static::text('versions.fallback_display_name', ['version' => (int) $record->version_number]);
|
||||
}
|
||||
|
||||
private static function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.'.$key, $replace);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1473,9 +1473,13 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1499,6 +1503,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$displayName = $item->resolvedDisplayName();
|
||||
$identifier = $item->policy_identifier ?? null;
|
||||
$versionNumber = $item->policyVersion?->version_number;
|
||||
$providerMissingNote = $item->policy?->missing_from_provider_at
|
||||
? 'current state: provider missing; historical restore available'
|
||||
: null;
|
||||
|
||||
$options[$item->id] = $displayName;
|
||||
|
||||
@ -1508,6 +1515,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
$platform,
|
||||
'quality: '.$qualitySummary->compactSummary,
|
||||
"restore: {$restore}",
|
||||
$providerMissingNote,
|
||||
$versionNumber ? "version: {$versionNumber}" : null,
|
||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||
@ -1540,9 +1548,13 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
->where(function ($query) {
|
||||
$query->whereNull('policy_id')
|
||||
->orWhereDoesntHave('policy')
|
||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
||||
->orWhereHas('policy', function ($policyQuery): void {
|
||||
$policyQuery
|
||||
->whereNull('ignored_at')
|
||||
->orWhereNotNull('missing_from_provider_at');
|
||||
});
|
||||
})
|
||||
->with(['policy:id,display_name'])
|
||||
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
|
||||
->get()
|
||||
->sortBy(function (BackupItem $item) {
|
||||
$meta = static::typeMeta($item->policy_type);
|
||||
@ -1659,6 +1671,7 @@ private static function restoreItemSelectionLabel(BackupItem $item): string
|
||||
return implode(' • ', array_filter([
|
||||
$item->resolvedDisplayName(),
|
||||
$summary->compactSummary,
|
||||
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@ -148,7 +148,8 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('file_size')
|
||||
->label('File size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—'),
|
||||
TextEntry::make('sha256')->label('SHA-256')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
@ -184,6 +185,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->formatStateUsing(fn ($state): string => $state ? 'Yes' : 'No'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata')
|
||||
@ -227,9 +229,12 @@ public static function infolist(Schema $schema): Schema
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->openUrlInNewTab()
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow())
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('previous_fingerprint')->label('Previous fingerprint')->copyable()->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceFlow()),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
@ -243,9 +248,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
->url(fn (ReviewPack $record): ?string => static::evidenceSnapshotUrl($record)),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
@ -429,6 +432,36 @@ public static function getPages(): array
|
||||
];
|
||||
}
|
||||
|
||||
public static function isCustomerWorkspaceFlow(): bool
|
||||
{
|
||||
return request()->query('source_surface') === CustomerReviewWorkspace::SOURCE_SURFACE;
|
||||
}
|
||||
|
||||
private static function evidenceSnapshotUrl(ReviewPack $record): ?string
|
||||
{
|
||||
if (! $record->evidenceSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant);
|
||||
|
||||
return static::isCustomerWorkspaceFlow()
|
||||
? static::appendQuery($url, ['source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE])
|
||||
: $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
|
||||
private static function truthEnvelope(ReviewPack $record, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
@ -19,6 +19,20 @@ class ViewReviewPack extends ViewRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if (ReviewPackResource::isCustomerWorkspaceFlow()) {
|
||||
return [
|
||||
Actions\Action::make('download')
|
||||
->label('Download')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->record->status === ReviewPackStatus::Ready->value)
|
||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record, [
|
||||
'source_surface' => \App\Filament\Pages\Reviews\CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
]))
|
||||
->openUrlInNewTab(),
|
||||
];
|
||||
}
|
||||
|
||||
$regenerateAction = UiEnforcement::forAction(
|
||||
Actions\Action::make('regenerate')
|
||||
->label('Regenerate')
|
||||
|
||||
@ -213,6 +213,20 @@ public static function makeOpenInEntraAction(): Actions\Action
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
public static function makeMembershipsAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('memberships')
|
||||
->label('Manage memberships')
|
||||
->icon('heroicon-o-users')
|
||||
->url(fn (Tenant $record): string => static::getUrl('memberships', ['record' => $record], panel: 'admin')),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MEMBERSHIP_VIEW)
|
||||
->tooltip('You do not have permission to view tenant memberships.')
|
||||
->preserveVisibility()
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function makeSyncTenantAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
|
||||
@ -2,7 +2,38 @@
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use Filament\Actions\Action;
|
||||
|
||||
class ManageTenantMemberships extends ViewTenant
|
||||
{
|
||||
protected static ?string $title = 'Tenant memberships';
|
||||
protected static ?string $title = 'Manage tenant memberships';
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
return 'Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.';
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = array_values(array_filter(
|
||||
parent::getHeaderActions(),
|
||||
static fn ($action): bool => ! ($action instanceof Action && $action->getName() === 'memberships'),
|
||||
));
|
||||
|
||||
array_unshift(
|
||||
$actions,
|
||||
Action::make('back_to_overview')
|
||||
->label('Back to tenant overview')
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $this->getRecord()->getRouteKey()], panel: 'admin')),
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,6 +54,7 @@ protected function getHeaderWidgets(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
TenantResource::makeMembershipsAction(),
|
||||
Actions\ActionGroup::make([
|
||||
TenantResource::makeAdminConsentAction(),
|
||||
TenantResource::makeOpenInEntraAction(),
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\TenantReviewSection;
|
||||
@ -26,6 +27,7 @@
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
@ -215,6 +217,7 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->hidden(fn (): bool => static::isCustomerWorkspaceMode())
|
||||
->columnSpanFull()
|
||||
->fontFamily('mono')
|
||||
->size(TextSize::ExtraSmall),
|
||||
@ -233,6 +236,7 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make(__('localization.review.sections'))
|
||||
->schema([
|
||||
RepeatableEntry::make('sections')
|
||||
->state(fn (TenantReview $record): array => static::visibleSections($record))
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('title'),
|
||||
@ -262,6 +266,17 @@ public static function infolist(Schema $schema): Schema
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, TenantReviewSection>
|
||||
*/
|
||||
private static function visibleSections(TenantReview $record): array
|
||||
{
|
||||
return $record->sections
|
||||
->reject(fn (TenantReviewSection $section): bool => static::isCustomerWorkspaceMode() && $section->isControlInterpretation())
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
||||
@ -639,6 +654,10 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
$controlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
||||
? $summary['control_interpretation']
|
||||
: [];
|
||||
$packagePresentation = static::governancePackagePresentation($record);
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
|
||||
@ -647,12 +666,17 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'customer_workspace_mode' => static::isCustomerWorkspaceMode(),
|
||||
'reason_semantics' => static::isCustomerWorkspaceMode()
|
||||
? []
|
||||
: $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
'metrics' => [
|
||||
'context_links' => static::summaryContextLinks($record, static::isCustomerWorkspaceMode()),
|
||||
'control_interpretation' => $controlInterpretation,
|
||||
'governance_package' => $packagePresentation,
|
||||
'metrics' => static::isCustomerWorkspaceMode() ? static::customerWorkspaceMetrics($record, $summary, $packagePresentation) : [
|
||||
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
@ -664,13 +688,164 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||
* @param array<string, mixed> $summary
|
||||
* @param array<string, mixed> $packagePresentation
|
||||
* @return array<int, array{label:string,value:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record): array
|
||||
private static function customerWorkspaceMetrics(TenantReview $record, array $summary, array $packagePresentation): array
|
||||
{
|
||||
$acceptedRisk = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
||||
|
||||
return [
|
||||
['label' => __('localization.review.governance_package'), 'value' => (string) ($packagePresentation['availability']['label'] ?? __('localization.review.governance_package_unavailable'))],
|
||||
['label' => __('localization.review.review_status'), 'value' => static::customerReviewStatusLabel($record)],
|
||||
['label' => __('localization.review.evidence_status'), 'value' => static::customerEvidenceStatusLabel($record)],
|
||||
['label' => __('localization.review.accepted_risk_status'), 'value' => static::customerAcceptedRiskStatusLabel($acceptedRisk)],
|
||||
['label' => __('localization.review.last_review'), 'value' => $record->published_at?->format('Y-m-d') ?? __('localization.review.pending')],
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerReviewStatusLabel(TenantReview $record): string
|
||||
{
|
||||
if ($record->isPublished() && (string) $record->completeness_state === TenantReviewCompletenessState::Complete->value) {
|
||||
return __('localization.review.review_completed');
|
||||
}
|
||||
|
||||
if ($record->isPublished()) {
|
||||
return __('localization.review.review_requires_attention');
|
||||
}
|
||||
|
||||
return Str::headline((string) $record->status);
|
||||
}
|
||||
|
||||
private static function customerEvidenceStatusLabel(TenantReview $record): string
|
||||
{
|
||||
$snapshot = $record->evidenceSnapshot;
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return __('localization.review.evidence_pending');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return __('localization.review.evidence_restricted');
|
||||
}
|
||||
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return __('localization.review.evidence_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.evidence_available');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $acceptedRisk
|
||||
*/
|
||||
private static function customerAcceptedRiskStatusLabel(array $acceptedRisk): string
|
||||
{
|
||||
$warningCount = (int) ($acceptedRisk['warning_count'] ?? 0);
|
||||
$statusMarkedCount = (int) ($acceptedRisk['status_marked_count'] ?? 0);
|
||||
|
||||
if ($warningCount > 0) {
|
||||
return __('localization.review.accepted_risk_follow_up');
|
||||
}
|
||||
|
||||
if ($statusMarkedCount > 0) {
|
||||
return __('localization.review.accepted_risk_on_record', ['count' => $statusMarkedCount]);
|
||||
}
|
||||
|
||||
return __('localization.review.accepted_risk_none');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function governancePackagePresentation(TenantReview $record): array
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$package = is_array($summary['governance_package'] ?? null) ? $summary['governance_package'] : [];
|
||||
|
||||
if ($package === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_merge($package, [
|
||||
'availability' => static::governancePackageAvailability($record),
|
||||
'delivery_note' => __('localization.review.governance_package_delivery_note'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,label:string,description:string}
|
||||
*/
|
||||
private static function governancePackageAvailability(TenantReview $record): array
|
||||
{
|
||||
$pack = $record->currentExportReviewPack;
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
$controlInterpretation = $record->controlInterpretation();
|
||||
$limitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
|
||||
$isPartialReview = in_array((string) $record->completeness_state, [
|
||||
TenantReviewCompletenessState::Partial->value,
|
||||
TenantReviewCompletenessState::Stale->value,
|
||||
], true) || $limitations !== [];
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_unavailable_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return [
|
||||
'state' => 'blocked',
|
||||
'label' => __('localization.review.governance_package_blocked'),
|
||||
'description' => __('localization.review.governance_package_blocked_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status === ReviewPackStatus::Expired->value || ($pack->expires_at !== null && $pack->expires_at->isPast())) {
|
||||
return [
|
||||
'state' => 'expired',
|
||||
'label' => __('localization.review.governance_package_expired'),
|
||||
'description' => __('localization.review.governance_package_expired_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return [
|
||||
'state' => 'unavailable',
|
||||
'label' => __('localization.review.governance_package_unavailable'),
|
||||
'description' => __('localization.review.governance_package_not_ready_description'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($isPartialReview) {
|
||||
return [
|
||||
'state' => 'partial',
|
||||
'label' => __('localization.review.governance_package_partial'),
|
||||
'description' => __('localization.review.governance_package_partial_description'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'state' => 'available',
|
||||
'label' => __('localization.review.governance_package_available'),
|
||||
'description' => __('localization.review.governance_package_available_description'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{title:string,label:string,url:?string,description:string}>
|
||||
*/
|
||||
private static function summaryContextLinks(TenantReview $record, bool $customerWorkspaceMode = false): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if (is_numeric($record->operation_run_id)) {
|
||||
if (! $customerWorkspaceMode && is_numeric($record->operation_run_id)) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.operation'),
|
||||
'label' => __('localization.review.open_operation'),
|
||||
@ -679,7 +854,7 @@ private static function summaryContextLinks(TenantReview $record): array
|
||||
];
|
||||
}
|
||||
|
||||
if ($record->currentExportReviewPack && $record->tenant) {
|
||||
if (! $customerWorkspaceMode && $record->currentExportReviewPack && $record->tenant) {
|
||||
$links[] = [
|
||||
'title' => __('localization.review.executive_pack'),
|
||||
'label' => __('localization.review.view_executive_pack'),
|
||||
@ -698,11 +873,23 @@ private static function summaryContextLinks(TenantReview $record): array
|
||||
}
|
||||
|
||||
if ($record->evidenceSnapshot && $record->tenant) {
|
||||
$user = auth()->user();
|
||||
$canViewEvidence = $user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $record->tenant);
|
||||
$evidenceUrl = $canViewEvidence
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null;
|
||||
|
||||
if ($customerWorkspaceMode && $evidenceUrl !== null) {
|
||||
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($record));
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'title' => __('localization.review.evidence_snapshot'),
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||
'description' => __('localization.review.evidence_snapshot_description'),
|
||||
'url' => $evidenceUrl,
|
||||
'description' => $canViewEvidence
|
||||
? __('localization.review.evidence_snapshot_description')
|
||||
: __('localization.review.evidence_proof_access_unavailable'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -718,6 +905,24 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
$review = $section->tenantReview;
|
||||
$tenant = $section->tenant;
|
||||
$links = [];
|
||||
|
||||
if ($section->isControlInterpretation() && $review instanceof TenantReview && $tenant instanceof Tenant && $review->evidenceSnapshot instanceof EvidenceSnapshot) {
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user instanceof User && $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
$evidenceUrl = EvidenceSnapshotResource::getUrl('view', ['record' => $review->evidenceSnapshot], tenant: $tenant);
|
||||
|
||||
if (static::isCustomerWorkspaceMode()) {
|
||||
$evidenceUrl = static::appendQuery($evidenceUrl, static::customerWorkspaceEvidenceQuery($review));
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'label' => __('localization.review.view_evidence_snapshot'),
|
||||
'url' => $evidenceUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
||||
@ -735,7 +940,8 @@ private static function sectionPresentation(TenantReviewSection $section): array
|
||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
||||
'links' => [],
|
||||
'is_control_interpretation' => $section->isControlInterpretation(),
|
||||
'links' => $links,
|
||||
];
|
||||
}
|
||||
|
||||
@ -783,4 +989,34 @@ private static function findingOutcomeSummary(array $summary): ?string
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
|
||||
private static function isCustomerWorkspaceMode(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function customerWorkspaceEvidenceQuery(TenantReview $record): array
|
||||
{
|
||||
return array_filter([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => (int) $record->getKey(),
|
||||
'interpretation_version' => $record->controlInterpretationVersion(),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,18 @@
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||
use App\Services\TenantReviews\TenantReviewService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Actions;
|
||||
@ -64,6 +67,12 @@ protected function authorizeAccess(): void
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
if ($this->isCustomerWorkspaceView()) {
|
||||
return [
|
||||
$this->downloadCurrentReviewPackAction(),
|
||||
];
|
||||
}
|
||||
|
||||
$secondaryActions = $this->secondaryLifecycleActions();
|
||||
|
||||
return array_values(array_filter([
|
||||
@ -343,6 +352,77 @@ private function archiveReviewAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function downloadCurrentReviewPackAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('download_current_review_pack')
|
||||
->label(__('localization.review.download_governance_package'))
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('primary')
|
||||
->disabled(fn (): bool => $this->currentReviewPackDownloadUrl() === null)
|
||||
->tooltip(fn (): ?string => $this->currentReviewPackUnavailableReason())
|
||||
->url(fn (): ?string => $this->currentReviewPackDownloadUrl())
|
||||
->openUrlInNewTab();
|
||||
}
|
||||
|
||||
private function currentReviewPackDownloadUrl(): ?string
|
||||
{
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => (int) $this->record->getKey(),
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function currentReviewPackUnavailableReason(): ?string
|
||||
{
|
||||
if ($this->currentReviewPackDownloadUrl() !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pack = $this->record->currentExportReviewPack;
|
||||
$tenant = $this->record->tenant;
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return __('localization.review.customer_review_pack_missing');
|
||||
}
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
||||
return __('localization.review.customer_review_pack_forbidden');
|
||||
}
|
||||
|
||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
||||
return __('localization.review.customer_review_pack_not_ready');
|
||||
}
|
||||
|
||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
||||
return __('localization.review.customer_review_pack_expired');
|
||||
}
|
||||
|
||||
return __('localization.review.customer_review_pack_unavailable');
|
||||
}
|
||||
|
||||
private function isCustomerWorkspaceView(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
@ -367,7 +447,9 @@ private function auditCustomerWorkspaceOpen(): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $this->record->getKey(),
|
||||
'source_surface' => 'customer_review_workspace',
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => request()->query('tenant_filter_id'),
|
||||
'interpretation_version' => $this->record->controlInterpretationVersion(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
|
||||
@ -59,6 +59,9 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
||||
? (int) $reviewPack->tenant_review_id
|
||||
: null,
|
||||
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
||||
'review_id' => $request->query('review_id'),
|
||||
'tenant_filter_id' => $request->query('tenant_filter_id'),
|
||||
'interpretation_version' => $request->query('interpretation_version'),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
|
||||
@ -242,12 +242,33 @@ public function handle(
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
if (! $policy->isCurrentBackupEligible()) {
|
||||
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => 'policy_provider_missing',
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy_ignored_locally',
|
||||
default => 'policy_not_current_backup_eligible',
|
||||
};
|
||||
$reason = $policy->currentBackupBlockedReasonLabel()
|
||||
?? 'Policy is not eligible for current backup capture.';
|
||||
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
'reason' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||
'status' => null,
|
||||
'reason_code' => $reasonCode,
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => str_replace('_', '.', $reasonCode),
|
||||
'message' => RunFailureSanitizer::sanitizeMessage($reason),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -120,6 +120,49 @@ public function handle(OperationRunService $operationRunService): void
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $policy->isCurrentBackupEligible()) {
|
||||
$failed++;
|
||||
$reasonCode = match ($policy->currentBackupBlockedReason()) {
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => 'policy.provider_missing',
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => 'policy.ignored_locally',
|
||||
default => 'policy.not_current_backup_eligible',
|
||||
};
|
||||
$failures[] = [
|
||||
'code' => $reasonCode,
|
||||
'message' => $policy->currentBackupBlockedReasonLabel()
|
||||
?? "Policy {$policyId} is not eligible for current backup capture.",
|
||||
];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$backupSet->update([
|
||||
'status' => 'failed',
|
||||
'item_count' => $succeeded,
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get latest version for snapshot
|
||||
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||
|
||||
@ -215,6 +258,15 @@ public function handle(OperationRunService $operationRunService): void
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
$backupSet->update([
|
||||
'status' => match ($outcome) {
|
||||
OperationRunOutcome::Failed->value => 'failed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => 'partial',
|
||||
default => 'completed',
|
||||
},
|
||||
'item_count' => $succeeded,
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
|
||||
@ -199,13 +199,16 @@ private function executeReviewDerivedGeneration(
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$generatedAt = now();
|
||||
|
||||
$fileMap = $this->buildReviewDerivedFileMap(
|
||||
reviewPack: $reviewPack,
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
||||
@ -219,7 +222,7 @@ private function executeReviewDerivedGeneration(
|
||||
'review-packs/%s/review-%d-%s.zip',
|
||||
$tenant->external_id,
|
||||
(int) $review->getKey(),
|
||||
now()->format('Y-m-d-His'),
|
||||
$generatedAt->format('Y-m-d-His'),
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
||||
@ -241,6 +244,7 @@ private function executeReviewDerivedGeneration(
|
||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
||||
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
@ -258,8 +262,8 @@ private function executeReviewDerivedGeneration(
|
||||
'file_size' => $fileSize,
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addDays($retentionDays),
|
||||
'generated_at' => $generatedAt,
|
||||
'expires_at' => $generatedAt->copy()->addDays($retentionDays),
|
||||
'summary' => $summary,
|
||||
]);
|
||||
|
||||
@ -582,13 +586,21 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildReviewDerivedFileMap(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): array {
|
||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
||||
$deliveryMetadata = $this->deliveryBundleMetadata(
|
||||
reviewPack: $reviewPack,
|
||||
review: $review,
|
||||
snapshot: $snapshot,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$sections = $review->sections
|
||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
||||
@ -599,7 +611,8 @@ private function buildReviewDerivedFileMap(
|
||||
'version' => '1.0',
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
'delivery_bundle' => $deliveryMetadata,
|
||||
'tenant_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
@ -622,11 +635,17 @@ private function buildReviewDerivedFileMap(
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
],
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'summary.json' => json_encode($this->redactReportPayload(array_merge(
|
||||
[
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
],
|
||||
$reviewSummary,
|
||||
[
|
||||
'delivery_bundle' => $this->deliveryBundleSummary($review),
|
||||
],
|
||||
), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
||||
@ -641,6 +660,14 @@ private function buildReviewDerivedFileMap(
|
||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
||||
];
|
||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
||||
ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME => $this->buildExecutiveEntrypoint(
|
||||
review: $review,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
reviewSummary: $reviewSummary,
|
||||
includePii: $includePii,
|
||||
generatedAt: $generatedAt,
|
||||
),
|
||||
];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
@ -659,6 +686,195 @@ private function buildReviewDerivedFileMap(
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function deliveryBundleSummary(TenantReview $review): array
|
||||
{
|
||||
return [
|
||||
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'executive_entrypoint_file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function deliveryBundleMetadata(
|
||||
ReviewPack $reviewPack,
|
||||
TenantReview $review,
|
||||
EvidenceSnapshot $snapshot,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): array {
|
||||
return [
|
||||
'contract' => ReviewPackService::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'artifact_family' => 'review_pack',
|
||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'generated_at' => $generatedAt->toIso8601String(),
|
||||
'released_review' => [
|
||||
'id' => (int) $review->getKey(),
|
||||
'status' => (string) $review->status,
|
||||
'completeness_state' => (string) $review->completeness_state,
|
||||
'published_at' => $review->published_at?->toIso8601String(),
|
||||
],
|
||||
'interpretation_version' => $review->controlInterpretationVersion(),
|
||||
'evidence_basis' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'entrypoint' => [
|
||||
'file' => ReviewPackService::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'role' => 'executive_entrypoint',
|
||||
'audience' => 'executive',
|
||||
'format' => 'text/markdown',
|
||||
],
|
||||
'appendix' => [
|
||||
[
|
||||
'file' => 'metadata.json',
|
||||
'role' => 'bundle_metadata',
|
||||
'description' => 'Structured delivery metadata and artifact role map.',
|
||||
],
|
||||
[
|
||||
'file' => 'summary.json',
|
||||
'role' => 'review_summary_appendix',
|
||||
'description' => 'Structured released-review summary truth.',
|
||||
],
|
||||
[
|
||||
'file' => 'sections.json',
|
||||
'role' => 'section_detail_appendix',
|
||||
'description' => 'Structured released-review section detail.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $reviewSummary
|
||||
*/
|
||||
private function buildExecutiveEntrypoint(
|
||||
TenantReview $review,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $reviewSummary,
|
||||
bool $includePii,
|
||||
\Carbon\CarbonInterface $generatedAt,
|
||||
): string {
|
||||
$package = is_array($reviewSummary['governance_package'] ?? null)
|
||||
? $this->redactReportPayload($reviewSummary['governance_package'], $includePii)
|
||||
: [];
|
||||
$controlInterpretation = is_array($reviewSummary['control_interpretation'] ?? null)
|
||||
? $reviewSummary['control_interpretation']
|
||||
: [];
|
||||
$nonCertificationDisclosure = $this->plainText(
|
||||
$controlInterpretation['non_certification_disclosure'] ?? null,
|
||||
'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
||||
);
|
||||
$tenantName = $includePii ? $tenant->name : '[REDACTED]';
|
||||
$topFindings = is_array($package['top_findings'] ?? null) ? $package['top_findings'] : [];
|
||||
$acceptedRisks = is_array($package['accepted_risks'] ?? null) ? $package['accepted_risks'] : [];
|
||||
$governanceDecisions = is_array($package['governance_decisions'] ?? null) ? $package['governance_decisions'] : [];
|
||||
$nextActions = is_array($reviewSummary['recommended_next_actions'] ?? null) ? $reviewSummary['recommended_next_actions'] : [];
|
||||
|
||||
$lines = [
|
||||
'# Executive summary',
|
||||
'',
|
||||
'Tenant: '.$this->plainText($tenantName, '[REDACTED]'),
|
||||
'Released review: #'.((int) $review->getKey()),
|
||||
'Review status: '.$this->plainText($review->status, 'unknown'),
|
||||
'Generated at: '.$generatedAt->toIso8601String(),
|
||||
'',
|
||||
'## Executive story',
|
||||
'',
|
||||
$this->plainText($package['executive_summary'] ?? null, 'No executive summary is available for this released review.'),
|
||||
'',
|
||||
'## Evidence basis',
|
||||
'',
|
||||
$this->plainText(
|
||||
$package['evidence_basis_summary'] ?? null,
|
||||
sprintf('Anchored to evidence snapshot #%d with %s completeness.', (int) $snapshot->getKey(), (string) $snapshot->completeness_state),
|
||||
),
|
||||
'',
|
||||
'## Key findings',
|
||||
'',
|
||||
...$this->entryBullets($topFindings, 'No key findings are listed for this released review.'),
|
||||
'',
|
||||
'## Accepted risks',
|
||||
'',
|
||||
...$this->entryBullets($acceptedRisks, 'No accepted risks are listed for this released review.'),
|
||||
'',
|
||||
'## Governance decisions requiring awareness',
|
||||
'',
|
||||
...$this->entryBullets($governanceDecisions, 'No governance decisions require awareness in this released review.'),
|
||||
'',
|
||||
'## Next actions',
|
||||
'',
|
||||
...$this->textBullets($nextActions, 'No next action is listed for this released review.'),
|
||||
'',
|
||||
'## Non-certification disclosure',
|
||||
'',
|
||||
$nonCertificationDisclosure,
|
||||
'',
|
||||
'## Structured auditor appendix',
|
||||
'',
|
||||
'This executive entrypoint is the first file to read. The structured auditor appendix remains available in metadata.json, summary.json, and sections.json.',
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $entries
|
||||
* @return list<string>
|
||||
*/
|
||||
private function entryBullets(array $entries, string $emptyText): array
|
||||
{
|
||||
if ($entries === []) {
|
||||
return ['- '.$emptyText];
|
||||
}
|
||||
|
||||
return collect($entries)
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||
->map(function (array $entry): string {
|
||||
$title = $this->plainText($entry['title'] ?? null, 'Entry');
|
||||
$summary = $this->plainText($entry['summary'] ?? null, '');
|
||||
|
||||
return $summary === '' ? '- '.$title : '- '.$title.' - '.$summary;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $entries
|
||||
* @return list<string>
|
||||
*/
|
||||
private function textBullets(array $entries, string $emptyText): array
|
||||
{
|
||||
$bullets = collect($entries)
|
||||
->filter(static fn (mixed $entry): bool => is_string($entry) && trim($entry) !== '')
|
||||
->map(fn (string $entry): string => '- '.$this->plainText($entry, ''))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $bullets === [] ? ['- '.$emptyText] : $bullets;
|
||||
}
|
||||
|
||||
private function plainText(mixed $value, string $fallback): string
|
||||
{
|
||||
if (! is_scalar($value) && $value !== null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$text = preg_replace('/\s+/', ' ', trim((string) $value));
|
||||
|
||||
return is_string($text) && $text !== '' ? $text : $fallback;
|
||||
}
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update([
|
||||
|
||||
@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class CrossTenantPromotionExecutionJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 420;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(OperationRun $operationRun)
|
||||
{
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [
|
||||
new EnsureQueuedExecutionLegitimate,
|
||||
new TrackOperationRun,
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRuns,
|
||||
RestoreService $restoreService,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
WorkspaceAuditLogger $auditLogger,
|
||||
): void {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for promotion execution.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === OperationRunStatus::Completed->value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $this->operationRun->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Promotion execution target tenant is missing.');
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot((int) $tenant->getKey(), $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$this->release(max(1, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$plan = is_array(data_get($context, 'promotion_execution.plan'))
|
||||
? data_get($context, 'promotion_execution.plan')
|
||||
: null;
|
||||
|
||||
if (! is_array($plan)) {
|
||||
throw new RuntimeException('Promotion execution plan is missing from operation context.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
$failures = [];
|
||||
|
||||
$items = is_array($plan['items'] ?? null) ? array_values(array_filter($plan['items'], 'is_array')) : [];
|
||||
$summary['total'] = count($items);
|
||||
|
||||
[$backupSet, $selectedItemIds, $preRestoreSummary, $preRestoreFailures] = $this->buildRestoreInputs(
|
||||
tenant: $tenant,
|
||||
operationRun: $this->operationRun,
|
||||
items: $items,
|
||||
);
|
||||
|
||||
$summary = array_replace($summary, $preRestoreSummary);
|
||||
$failures = array_merge($failures, $preRestoreFailures);
|
||||
|
||||
$restoreRun = null;
|
||||
|
||||
if ($selectedItemIds !== []) {
|
||||
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => $restoreService->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
dryRun: false,
|
||||
actorEmail: $this->operationRun->user?->email,
|
||||
actorName: $this->operationRun->initiator_name,
|
||||
providerConnectionId: is_numeric($context['provider_connection_id'] ?? null) ? (int) $context['provider_connection_id'] : null,
|
||||
));
|
||||
|
||||
RestoreRun::withoutEvents(function () use ($restoreRun): void {
|
||||
$restoreRun->forceFill(['operation_run_id' => (int) $this->operationRun?->getKey()])->save();
|
||||
});
|
||||
|
||||
[$restoreSummary, $restoreFailures] = $this->summaryFromRestoreRun($restoreRun, $items);
|
||||
$summary = $this->mergeSummary($summary, $restoreSummary);
|
||||
$failures = array_merge($failures, $restoreFailures);
|
||||
|
||||
$context['restore_run_id'] = (int) $restoreRun->getKey();
|
||||
$context['backup_set_id'] = (int) $backupSet->getKey();
|
||||
$this->operationRun->forceFill(['context' => $context])->save();
|
||||
} else {
|
||||
$backupSet?->delete();
|
||||
}
|
||||
|
||||
$outcome = $this->outcome($summary);
|
||||
|
||||
$updated = $operationRuns->updateRun(
|
||||
run: $this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summary,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$auditLogger->logCrossTenantPromotionExecutionCompleted(
|
||||
operationRun: $updated,
|
||||
sourceTenantId: is_numeric($context['source_tenant_id'] ?? null) ? (int) $context['source_tenant_id'] : null,
|
||||
targetTenant: $tenant,
|
||||
summaryCounts: $summary,
|
||||
restoreRun: $restoreRun,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
throw $exception;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function getOperationRun(): ?OperationRun
|
||||
{
|
||||
return $this->operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array{0: ?BackupSet, 1: list<int>, 2: array<string, int>, 3: list<array{code: string, message: string}>}
|
||||
*/
|
||||
private function buildRestoreInputs(Tenant $tenant, OperationRun $operationRun, array $items): array
|
||||
{
|
||||
$summary = [
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
$failures = [];
|
||||
$backupSet = BackupSet::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Cross-tenant promotion • Operation #'.$operationRun->getKey(),
|
||||
'created_by' => $operationRun->user?->email,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
'metadata' => [
|
||||
'source' => 'cross_tenant_promotion',
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
],
|
||||
]);
|
||||
$selectedItemIds = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$action = (string) ($item['execution_action'] ?? '');
|
||||
|
||||
if ($action === 'skip_aligned') {
|
||||
$summary['processed']++;
|
||||
$summary['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$versionId = data_get($item, 'source.policy_version_id');
|
||||
$sourceTenantId = data_get($item, 'source.tenant_id');
|
||||
|
||||
$version = is_numeric($versionId) && is_numeric($sourceTenantId)
|
||||
? PolicyVersion::query()
|
||||
->with('policy')
|
||||
->whereKey((int) $versionId)
|
||||
->where('tenant_id', (int) $sourceTenantId)
|
||||
->first()
|
||||
: null;
|
||||
|
||||
if (! $version instanceof PolicyVersion || ! $version->policy instanceof Policy) {
|
||||
$summary['processed']++;
|
||||
$summary['failed']++;
|
||||
$failures[] = [
|
||||
'code' => 'promotion.source_version_missing',
|
||||
'message' => 'Source policy version for '.$this->itemLabel($item).' was not found.',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourcePolicy = $version->policy;
|
||||
$targetExternalId = data_get($item, 'target.subject_external_id');
|
||||
$sourceExternalId = data_get($item, 'source.subject_external_id');
|
||||
$policyIdentifier = is_string($targetExternalId) && trim($targetExternalId) !== ''
|
||||
? trim($targetExternalId)
|
||||
: (is_string($sourceExternalId) && trim($sourceExternalId) !== '' ? trim($sourceExternalId) : (string) $sourcePolicy->external_id);
|
||||
|
||||
$targetPolicy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('policy_type', (string) $sourcePolicy->policy_type)
|
||||
->where('external_id', $policyIdentifier)
|
||||
->first();
|
||||
|
||||
$backupItem = BackupItem::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_id' => $targetPolicy?->getKey(),
|
||||
'policy_identifier' => $policyIdentifier,
|
||||
'policy_type' => (string) $sourcePolicy->policy_type,
|
||||
'platform' => (string) $sourcePolicy->platform,
|
||||
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
|
||||
'payload' => is_array($version->snapshot) ? $version->snapshot : [],
|
||||
'metadata' => [
|
||||
'source' => 'cross_tenant_promotion',
|
||||
'display_name' => (string) $sourcePolicy->display_name,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'source_tenant_id' => (int) $sourcePolicy->tenant_id,
|
||||
'source_policy_id' => (int) $sourcePolicy->getKey(),
|
||||
'source_policy_version_id' => (int) $version->getKey(),
|
||||
'source_subject_key' => (string) ($item['subject_key'] ?? ''),
|
||||
'execution_action' => $action,
|
||||
'target_subject_external_id' => is_string($targetExternalId) ? $targetExternalId : null,
|
||||
],
|
||||
'assignments' => is_array($version->assignments) ? $version->assignments : [],
|
||||
]);
|
||||
|
||||
$selectedItemIds[] = (int) $backupItem->getKey();
|
||||
}
|
||||
|
||||
$backupSet->forceFill(['item_count' => count($selectedItemIds)])->save();
|
||||
|
||||
return [$backupSet, $selectedItemIds, $summary, $failures];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array{0: array<string, int>, 1: list<array{code: string, message: string}>}
|
||||
*/
|
||||
private function summaryFromRestoreRun(RestoreRun $restoreRun, array $items): array
|
||||
{
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
$results = is_array($restoreRun->results) ? $restoreRun->results : [];
|
||||
$resultItems = is_array($results['items'] ?? null) ? $results['items'] : [];
|
||||
$succeeded = (int) ($metadata['succeeded'] ?? 0);
|
||||
$failed = (int) ($metadata['failed'] ?? 0) + (int) ($metadata['partial'] ?? 0);
|
||||
$skipped = (int) ($metadata['skipped'] ?? 0);
|
||||
$processed = $succeeded + $failed + $skipped;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$action = (string) ($item['execution_action'] ?? '');
|
||||
|
||||
if ($action === 'create_missing') {
|
||||
$created++;
|
||||
} elseif ($action === 'update_existing') {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($resultItems as $result) {
|
||||
if (! is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (string) ($result['status'] ?? '');
|
||||
|
||||
if (in_array($status, ['applied', 'dry_run'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => 'promotion.restore_item_not_applied',
|
||||
'message' => (string) ($result['reason'] ?? 'Promotion restore item did not apply.'),
|
||||
];
|
||||
}
|
||||
|
||||
return [[
|
||||
'processed' => $processed,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
'created' => min($created, $succeeded),
|
||||
'updated' => min($updated, max(0, $succeeded - min($created, $succeeded))),
|
||||
], $failures];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $left
|
||||
* @param array<string, int> $right
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function mergeSummary(array $left, array $right): array
|
||||
{
|
||||
foreach ($right as $key => $value) {
|
||||
$left[$key] = (int) ($left[$key] ?? 0) + (int) $value;
|
||||
}
|
||||
|
||||
return $left;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summary
|
||||
*/
|
||||
private function outcome(array $summary): string
|
||||
{
|
||||
$total = (int) ($summary['total'] ?? 0);
|
||||
$failed = (int) ($summary['failed'] ?? 0);
|
||||
$succeeded = (int) ($summary['succeeded'] ?? 0);
|
||||
$skipped = (int) ($summary['skipped'] ?? 0);
|
||||
|
||||
if ($total > 0 && $failed >= $total) {
|
||||
return OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
return OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($succeeded > 0 || $skipped > 0) {
|
||||
return OperationRunOutcome::Succeeded->value;
|
||||
}
|
||||
|
||||
return OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function itemLabel(array $item): string
|
||||
{
|
||||
$displayName = (string) ($item['display_name'] ?? '');
|
||||
|
||||
if ($displayName !== '') {
|
||||
return $displayName;
|
||||
}
|
||||
|
||||
return (string) ($item['subject_key'] ?? 'unknown subject');
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,6 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@ -98,6 +97,17 @@ public function table(Table $table): Table
|
||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
|
||||
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
|
||||
->sortable(),
|
||||
TextColumn::make('visibility_state')
|
||||
->label('Visibility')
|
||||
->badge()
|
||||
->state(fn (Policy $record): string => $record->visibilityState())
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyProviderPresence))
|
||||
->color(BadgeRenderer::color(BadgeDomain::PolicyProviderPresence))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::PolicyProviderPresence))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyProviderPresence))
|
||||
->description(fn (Policy $record): ?string => $record->isCurrentBackupEligible()
|
||||
? null
|
||||
: $record->currentBackupBlockedReasonLabel()),
|
||||
TextColumn::make('external_id')
|
||||
->label('External ID')
|
||||
->formatStateUsing(fn (?string $state): string => static::externalIdShort($state))
|
||||
@ -146,7 +156,7 @@ public function table(Table $table): Table
|
||||
'90' => 'Within 90 days',
|
||||
'any' => 'Any time',
|
||||
])
|
||||
->default('7')
|
||||
->default('any')
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = (string) ($data['value'] ?? '7');
|
||||
|
||||
@ -158,14 +168,28 @@ public function table(Table $table): Table
|
||||
|
||||
return $query->where('last_synced_at', '>', now()->subDays(max(1, $days)));
|
||||
}),
|
||||
TernaryFilter::make('ignored')
|
||||
->label('Ignored')
|
||||
->nullable()
|
||||
->queries(
|
||||
true: fn (Builder $query) => $query->whereNotNull('ignored_at'),
|
||||
false: fn (Builder $query) => $query->whereNull('ignored_at'),
|
||||
)
|
||||
->default(false),
|
||||
SelectFilter::make('visibility')
|
||||
->label('Visibility')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'ignored' => 'Ignored locally',
|
||||
'provider_missing' => 'Provider missing',
|
||||
'all' => 'All',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value) || $value === 'all') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
'active' => $query->active(),
|
||||
'ignored' => $query->whereNotNull('ignored_at'),
|
||||
'provider_missing' => $query->whereNotNull('missing_from_provider_at'),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
SelectFilter::make('has_versions')
|
||||
->label('Has versions')
|
||||
->options([
|
||||
@ -188,6 +212,7 @@ public function table(Table $table): Table
|
||||
])
|
||||
->emptyStateHeading('No matching policies available')
|
||||
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
|
||||
->checkIfRecordIsSelectableUsing(fn (Policy $record): bool => $record->isCurrentBackupEligible())
|
||||
->bulkActions([
|
||||
BulkAction::make('add_selected_to_backup_set')
|
||||
->label('Add selected')
|
||||
@ -285,6 +310,20 @@ public function table(Table $table): Table
|
||||
|
||||
sort($policyIds);
|
||||
|
||||
$blocked = $records->first(
|
||||
fn ($record): bool => $record instanceof Policy && ! $record->isCurrentBackupEligible()
|
||||
);
|
||||
|
||||
if ($blocked instanceof Policy) {
|
||||
Notification::make()
|
||||
->title('Current backup unavailable')
|
||||
->body($blocked->currentBackupBlockedReasonLabel())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($policyIds);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Concerns\InteractsWithODataTypes;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@ -15,12 +16,21 @@ class Policy extends Model
|
||||
use HasFactory;
|
||||
use InteractsWithODataTypes;
|
||||
|
||||
public const VISIBILITY_ACTIVE = 'active';
|
||||
|
||||
public const VISIBILITY_IGNORED_LOCALLY = 'ignored_locally';
|
||||
|
||||
public const VISIBILITY_PROVIDER_MISSING = 'provider_missing';
|
||||
|
||||
public const VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING = 'ignored_locally_provider_missing';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'last_synced_at' => 'datetime',
|
||||
'ignored_at' => 'datetime',
|
||||
'missing_from_provider_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
@ -38,16 +48,77 @@ public function backupItems(): HasMany
|
||||
return $this->hasMany(BackupItem::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('ignored_at');
|
||||
return $query
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function scopeIgnored($query)
|
||||
public function scopeIgnored(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('ignored_at');
|
||||
}
|
||||
|
||||
public function scopeProviderMissing(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function scopeCurrentBackupEligible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at');
|
||||
}
|
||||
|
||||
public function isIgnoredLocally(): bool
|
||||
{
|
||||
return $this->ignored_at !== null;
|
||||
}
|
||||
|
||||
public function isProviderMissing(): bool
|
||||
{
|
||||
return $this->missing_from_provider_at !== null;
|
||||
}
|
||||
|
||||
public function visibilityState(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->isIgnoredLocally() && $this->isProviderMissing() => self::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING,
|
||||
$this->isIgnoredLocally() => self::VISIBILITY_IGNORED_LOCALLY,
|
||||
$this->isProviderMissing() => self::VISIBILITY_PROVIDER_MISSING,
|
||||
default => self::VISIBILITY_ACTIVE,
|
||||
};
|
||||
}
|
||||
|
||||
public function isCurrentBackupEligible(): bool
|
||||
{
|
||||
return ! $this->isIgnoredLocally() && ! $this->isProviderMissing();
|
||||
}
|
||||
|
||||
public function currentBackupBlockedReason(): ?string
|
||||
{
|
||||
if ($this->isProviderMissing()) {
|
||||
return self::VISIBILITY_PROVIDER_MISSING;
|
||||
}
|
||||
|
||||
if ($this->isIgnoredLocally()) {
|
||||
return self::VISIBILITY_IGNORED_LOCALLY;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function currentBackupBlockedReasonLabel(): ?string
|
||||
{
|
||||
return match ($this->currentBackupBlockedReason()) {
|
||||
self::VISIBILITY_PROVIDER_MISSING => 'Provider missing - current provider-backed capture is unavailable.',
|
||||
self::VISIBILITY_IGNORED_LOCALLY => 'Ignored locally - restore local visibility before fresh capture.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function ignore(): void
|
||||
{
|
||||
$this->update(['ignored_at' => now()]);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -205,4 +206,63 @@ public function canonicalControlReferences(): array
|
||||
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function controlInterpretation(): array
|
||||
{
|
||||
$summary = is_array($this->summary) ? $this->summary : [];
|
||||
$interpretation = $summary['control_interpretation'] ?? [];
|
||||
|
||||
return is_array($interpretation) ? $interpretation : [];
|
||||
}
|
||||
|
||||
public function controlInterpretationVersion(): ?string
|
||||
{
|
||||
$version = $this->controlInterpretation()['version_key'] ?? null;
|
||||
|
||||
return is_string($version) && trim($version) !== '' ? $version : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function controlInterpretationControls(): array
|
||||
{
|
||||
$controls = $this->controlInterpretation()['controls'] ?? [];
|
||||
|
||||
return is_array($controls)
|
||||
? array_values(array_filter($controls, static fn (mixed $control): bool => is_array($control)))
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function controlInterpretationLimitationCounts(): array
|
||||
{
|
||||
$counts = $this->controlInterpretation()['limitation_counts'] ?? [];
|
||||
|
||||
if (! is_array($counts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($counts)
|
||||
->mapWithKeys(static fn (mixed $count, string|int $key): array => [(string) $key => (int) $count])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function controlInterpretationSection(): ?TenantReviewSection
|
||||
{
|
||||
if ($this->relationLoaded('sections')) {
|
||||
$section = $this->sections->firstWhere('section_key', ComplianceEvidenceMappingV1::SECTION_KEY);
|
||||
|
||||
return $section instanceof TenantReviewSection ? $section : null;
|
||||
}
|
||||
|
||||
return $this->sections()
|
||||
->where('section_key', ComplianceEvidenceMappingV1::SECTION_KEY)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -67,4 +68,26 @@ public function completenessEnum(): TenantReviewCompletenessState
|
||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
public function isControlInterpretation(): bool
|
||||
{
|
||||
return (string) $this->section_key === ComplianceEvidenceMappingV1::SECTION_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function controlInterpretationEntries(): array
|
||||
{
|
||||
if (! $this->isControlInterpretation()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$renderPayload = is_array($this->render_payload) ? $this->render_payload : [];
|
||||
$entries = $renderPayload['entries'] ?? [];
|
||||
|
||||
return is_array($entries)
|
||||
? array_values(array_filter($entries, static fn (mixed $entry): bool => is_array($entry)))
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
@ -184,6 +185,7 @@ public function panel(Panel $panel): Panel
|
||||
WorkspaceSettings::class,
|
||||
CrossTenantComparePage::class,
|
||||
GovernanceInbox::class,
|
||||
DecisionRegister::class,
|
||||
FindingsHygieneReport::class,
|
||||
FindingsIntakeQueue::class,
|
||||
MyFindingsInbox::class,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -176,6 +177,85 @@ public function logCrossTenantPromotionPreflightGenerated(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $plan
|
||||
*/
|
||||
public function logCrossTenantPromotionExecutionQueued(
|
||||
Workspace $workspace,
|
||||
Tenant $sourceTenant,
|
||||
Tenant $targetTenant,
|
||||
OperationRun $operationRun,
|
||||
array $plan,
|
||||
User|PlatformUser|null $actor = null,
|
||||
): \App\Models\AuditLog {
|
||||
$summary = is_array($plan['summary'] ?? null) ? $plan['summary'] : [];
|
||||
|
||||
return $this->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::CrossTenantPromotionExecutionQueued,
|
||||
context: [
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'source_tenant_name' => (string) $sourceTenant->name,
|
||||
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $targetTenant->name,
|
||||
'selection' => is_array($plan['selection'] ?? null) ? $plan['selection'] : [],
|
||||
'ready_count' => (int) ($summary['ready'] ?? 0),
|
||||
'excluded_count' => (int) ($summary['excluded'] ?? 0),
|
||||
'created_count' => (int) ($summary['created'] ?? 0),
|
||||
'updated_count' => (int) ($summary['updated'] ?? 0),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'queued',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
|
||||
summary: 'Cross-tenant promotion execution queued for '.$sourceTenant->name.' -> '.$targetTenant->name,
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $targetTenant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $summaryCounts
|
||||
*/
|
||||
public function logCrossTenantPromotionExecutionCompleted(
|
||||
OperationRun $operationRun,
|
||||
?int $sourceTenantId,
|
||||
Tenant $targetTenant,
|
||||
array $summaryCounts,
|
||||
?RestoreRun $restoreRun = null,
|
||||
): \App\Models\AuditLog {
|
||||
$context = is_array($operationRun->context) ? $operationRun->context : [];
|
||||
$sourceTenantName = is_string($context['source_tenant_name'] ?? null)
|
||||
? (string) $context['source_tenant_name']
|
||||
: null;
|
||||
|
||||
return $this->log(
|
||||
workspace: $targetTenant->workspace,
|
||||
action: AuditActionId::CrossTenantPromotionExecutionCompleted,
|
||||
context: [
|
||||
'source_tenant_id' => $sourceTenantId,
|
||||
'source_tenant_name' => $sourceTenantName,
|
||||
'target_tenant_id' => (int) $targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $targetTenant->name,
|
||||
'summary_counts' => $summaryCounts,
|
||||
'restore_run_id' => $restoreRun?->getKey(),
|
||||
'operation_outcome' => (string) $operationRun->outcome,
|
||||
],
|
||||
status: match ((string) $operationRun->outcome) {
|
||||
'failed' => 'failed',
|
||||
'partially_succeeded' => 'partial',
|
||||
default => 'success',
|
||||
},
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $operationRun->getKey(),
|
||||
targetLabel: ($sourceTenantName !== null ? $sourceTenantName.' -> ' : '').$targetTenant->name,
|
||||
summary: 'Cross-tenant promotion execution completed for '.(($sourceTenantName !== null ? $sourceTenantName.' -> ' : '')).$targetTenant->name,
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $targetTenant,
|
||||
);
|
||||
}
|
||||
|
||||
public function logSupportRequestCreated(
|
||||
SupportRequest $supportRequest,
|
||||
User|PlatformUser|null $actor = null,
|
||||
|
||||
@ -43,7 +43,7 @@ public function createBackupSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->currentBackupEligible()
|
||||
->get();
|
||||
|
||||
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
|
||||
@ -184,7 +184,7 @@ public function addPoliciesToSet(
|
||||
$policies = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('id', $policyIds)
|
||||
->whereNull('ignored_at')
|
||||
->currentBackupEligible()
|
||||
->get();
|
||||
|
||||
$metadata = $backupSet->metadata ?? [];
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Providers\ProviderConnectionResolver;
|
||||
use App\Services\Providers\ProviderGateway;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Providers\ProviderNextStepsRegistry;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Support\Arr;
|
||||
@ -25,6 +26,7 @@ public function __construct(
|
||||
private readonly ?ProviderConnectionResolver $providerConnections = null,
|
||||
private readonly ?ProviderGateway $providerGateway = null,
|
||||
private readonly ?ProviderNextStepsRegistry $nextStepsRegistry = null,
|
||||
private readonly ?AuditLogger $auditLogger = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -54,6 +56,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
$types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []);
|
||||
$synced = [];
|
||||
$failures = [];
|
||||
$successfulPolicyTypes = [];
|
||||
$observedExternalIdsByPolicyType = [];
|
||||
|
||||
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
||||
|
||||
@ -110,6 +114,9 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
continue;
|
||||
}
|
||||
|
||||
$successfulPolicyTypes[$policyType] = true;
|
||||
$observedExternalIdsByPolicyType[$policyType] ??= [];
|
||||
|
||||
foreach ($response->data as $policyData) {
|
||||
$externalId = $policyData['id'] ?? $policyData['external_id'] ?? null;
|
||||
|
||||
@ -117,6 +124,8 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalId = (string) $externalId;
|
||||
|
||||
$canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData);
|
||||
|
||||
if ($canonicalPolicyType !== $policyType) {
|
||||
@ -127,52 +136,60 @@ public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes =
|
||||
$odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null;
|
||||
|
||||
if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$observedExternalIdsByPolicyType[$policyType][] = $externalId;
|
||||
$displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
|
||||
$policyPlatform = $platform ?? ($policyData['platform'] ?? null);
|
||||
|
||||
$this->reclassifyEnrollmentConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$this->reclassifyConfigurationPoliciesIfNeeded(
|
||||
tenantId: $tenant->id,
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyType: $policyType,
|
||||
);
|
||||
|
||||
$policy = Policy::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
],
|
||||
[
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'display_name' => $displayName,
|
||||
'platform' => $policyPlatform,
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => null,
|
||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
]
|
||||
);
|
||||
$policy = Policy::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => $externalId,
|
||||
'policy_type' => $policyType,
|
||||
]);
|
||||
$wasProviderMissing = $policy->exists && $policy->missing_from_provider_at !== null;
|
||||
|
||||
$policy->forceFill([
|
||||
'workspace_id' => $tenant->workspace_id,
|
||||
'display_name' => $displayName,
|
||||
'platform' => $policyPlatform,
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => null,
|
||||
'metadata' => Arr::except($policyData, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
$synced[] = $policy->id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->markProviderMissingPolicies(
|
||||
tenant: $tenant,
|
||||
policyTypes: array_keys($successfulPolicyTypes),
|
||||
observedExternalIdsByPolicyType: $observedExternalIdsByPolicyType,
|
||||
);
|
||||
|
||||
return [
|
||||
'synced' => $synced,
|
||||
'failures' => $failures,
|
||||
@ -338,7 +355,7 @@ private function isEnrollmentNotificationItem(array $policyData): bool
|
||||
], true);
|
||||
}
|
||||
|
||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||
{
|
||||
$enrollmentTypes = [
|
||||
'enrollmentRestriction',
|
||||
@ -353,45 +370,54 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId
|
||||
}
|
||||
|
||||
$existingCorrect = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->first();
|
||||
|
||||
if ($existingCorrect) {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $enrollmentTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
$this->markSiblingPoliciesProviderMissing(
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyTypes: $enrollmentTypes,
|
||||
exceptPolicyType: $policyType,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $enrollmentTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->first();
|
||||
|
||||
if (! $existingWrong) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||
|
||||
$existingWrong->forceFill([
|
||||
'policy_type' => $policyType,
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $existingWrong,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
PolicyVersion::query()
|
||||
->where('policy_id', $existingWrong->id)
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void
|
||||
private function reclassifyConfigurationPoliciesIfNeeded(Tenant $tenant, string $externalId, string $policyType): void
|
||||
{
|
||||
$configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'];
|
||||
|
||||
@ -400,44 +426,154 @@ private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $
|
||||
}
|
||||
|
||||
$existingCorrect = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->where('policy_type', $policyType)
|
||||
->first();
|
||||
|
||||
if ($existingCorrect) {
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->update(['ignored_at' => now()]);
|
||||
$this->markSiblingPoliciesProviderMissing(
|
||||
tenant: $tenant,
|
||||
externalId: $externalId,
|
||||
policyTypes: $configurationTypes,
|
||||
exceptPolicyType: $policyType,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$existingWrong = Policy::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $configurationTypes)
|
||||
->where('policy_type', '!=', $policyType)
|
||||
->whereNull('ignored_at')
|
||||
->first();
|
||||
|
||||
if (! $existingWrong) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wasProviderMissing = $existingWrong->missing_from_provider_at !== null;
|
||||
|
||||
$existingWrong->forceFill([
|
||||
'policy_type' => $policyType,
|
||||
'missing_from_provider_at' => null,
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $existingWrong,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
|
||||
PolicyVersion::query()
|
||||
->where('policy_id', $existingWrong->id)
|
||||
->update(['policy_type' => $policyType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $policyTypes
|
||||
*/
|
||||
private function markSiblingPoliciesProviderMissing(Tenant $tenant, string $externalId, array $policyTypes, string $exceptPolicyType): void
|
||||
{
|
||||
$timestamp = now();
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', $externalId)
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->where('policy_type', '!=', $exceptPolicyType)
|
||||
->whereNull('missing_from_provider_at')
|
||||
->get()
|
||||
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingDetected,
|
||||
transitionAt: $timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $policyTypes
|
||||
* @param array<string, array<int, string>> $observedExternalIdsByPolicyType
|
||||
*/
|
||||
private function markProviderMissingPolicies(Tenant $tenant, array $policyTypes, array $observedExternalIdsByPolicyType): void
|
||||
{
|
||||
foreach ($policyTypes as $policyType) {
|
||||
if (! is_string($policyType) || $policyType === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$observedExternalIds = array_values(array_unique(array_filter(
|
||||
array_map('strval', $observedExternalIdsByPolicyType[$policyType] ?? []),
|
||||
static fn (string $externalId): bool => $externalId !== '',
|
||||
)));
|
||||
|
||||
$query = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('policy_type', $policyType)
|
||||
->whereNull('missing_from_provider_at');
|
||||
|
||||
if ($observedExternalIds !== []) {
|
||||
$query->whereNotIn('external_id', $observedExternalIds);
|
||||
}
|
||||
|
||||
$timestamp = now();
|
||||
|
||||
$query->get()
|
||||
->each(function (Policy $policy) use ($tenant, $timestamp): void {
|
||||
$policy->forceFill(['missing_from_provider_at' => $timestamp])->save();
|
||||
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingDetected,
|
||||
transitionAt: $timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function auditProviderPresenceTransition(
|
||||
Tenant $tenant,
|
||||
Policy $policy,
|
||||
AuditActionId $action,
|
||||
mixed $transitionAt = null,
|
||||
): void {
|
||||
$transitionAt ??= now();
|
||||
|
||||
$this->auditLogger()->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'external_id' => (string) $policy->external_id,
|
||||
'policy_type' => (string) $policy->policy_type,
|
||||
'transition_at' => method_exists($transitionAt, 'toIso8601String')
|
||||
? $transitionAt->toIso8601String()
|
||||
: (string) $transitionAt,
|
||||
'source' => 'policy_sync',
|
||||
],
|
||||
],
|
||||
resourceType: 'policy',
|
||||
resourceId: (string) $policy->getKey(),
|
||||
targetLabel: (string) $policy->display_name,
|
||||
status: 'success',
|
||||
);
|
||||
}
|
||||
|
||||
private function auditLogger(): AuditLogger
|
||||
{
|
||||
return $this->auditLogger ?? app(AuditLogger::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single policy from Graph and update local metadata.
|
||||
*/
|
||||
@ -506,13 +642,23 @@ public function syncPolicy(Tenant $tenant, Policy $policy): void
|
||||
|
||||
$displayName = $payload['displayName'] ?? $payload['name'] ?? $policy->display_name;
|
||||
$platform = $payload['platform'] ?? $policy->platform;
|
||||
$wasProviderMissing = $policy->missing_from_provider_at !== null;
|
||||
|
||||
$policy->forceFill([
|
||||
'display_name' => $displayName,
|
||||
'platform' => $platform,
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => null,
|
||||
'metadata' => Arr::except($payload, ['id', 'external_id', 'displayName', 'name', 'platform']),
|
||||
])->save();
|
||||
|
||||
if ($wasProviderMissing) {
|
||||
$this->auditProviderPresenceTransition(
|
||||
tenant: $tenant,
|
||||
policy: $policy,
|
||||
action: AuditActionId::PolicyProviderMissingCleared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -86,6 +86,19 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($context->workspaceRequiredCapability !== null) {
|
||||
$checks['capability'] = $this->initiatorHasRequiredWorkspaceCapability($context) ? 'passed' : 'failed';
|
||||
|
||||
if ($checks['capability'] === 'failed') {
|
||||
return QueuedExecutionLegitimacyDecision::deny(
|
||||
$context,
|
||||
$checks,
|
||||
ExecutionDenialReasonCode::MissingCapability,
|
||||
['required_capability' => $context->workspaceRequiredCapability],
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (! $this->isSystemAuthorityAllowed($context->operationType)) {
|
||||
$checks['execution_prerequisites'] = 'failed';
|
||||
@ -151,6 +164,9 @@ public function buildContext(OperationRun $run): QueuedExecutionContext
|
||||
requiredCapability: is_string($context['required_capability'] ?? null)
|
||||
? $context['required_capability']
|
||||
: $this->operationRunCapabilityResolver->requiredExecutionCapabilityForType($operationType),
|
||||
workspaceRequiredCapability: is_string($context['workspace_required_capability'] ?? null)
|
||||
? $context['workspace_required_capability']
|
||||
: null,
|
||||
providerConnectionId: $providerConnectionId,
|
||||
targetScope: [
|
||||
'workspace_id' => $workspaceId,
|
||||
@ -259,6 +275,29 @@ private function initiatorHasRequiredCapability(QueuedExecutionContext $context)
|
||||
);
|
||||
}
|
||||
|
||||
private function initiatorHasRequiredWorkspaceCapability(QueuedExecutionContext $context): bool
|
||||
{
|
||||
if (! $context->initiator instanceof User || ! is_string($context->workspaceRequiredCapability) || $context->workspaceRequiredCapability === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($context->workspaceId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$workspace = $context->run->tenant?->workspace ?? $context->run->workspace()->first();
|
||||
|
||||
if ($workspace === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->workspaceCapabilityResolver->can(
|
||||
$context->initiator,
|
||||
$workspace,
|
||||
$context->workspaceRequiredCapability,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@ -270,7 +309,7 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
$prerequisites[] = 'provider_connection';
|
||||
}
|
||||
|
||||
if (str_starts_with($operationType, 'restore.')) {
|
||||
if (str_starts_with($operationType, 'restore.') || $operationType === 'promotion.execute') {
|
||||
$prerequisites[] = 'write_gate';
|
||||
}
|
||||
|
||||
@ -279,6 +318,10 @@ private function prerequisiteClassesFor(string $operationType, ?int $providerCon
|
||||
|
||||
private function questionForContext(QueuedExecutionContext $context): TenantOperabilityQuestion
|
||||
{
|
||||
if ($context->operationType === 'promotion.execute') {
|
||||
return TenantOperabilityQuestion::RestoreEligibility;
|
||||
}
|
||||
|
||||
if ($context->providerConnectionId !== null || in_array($context->operationType, ['provider.connection.check', 'compliance.snapshot', 'provider.compliance.snapshot'], true)) {
|
||||
return TenantOperabilityQuestion::VerificationReadinessEligibility;
|
||||
}
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\PortfolioCompare;
|
||||
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
|
||||
use App\Support\PortfolioCompare\CrossTenantPromotionExecutionPlanner;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class CrossTenantPromotionExecutionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CrossTenantPromotionExecutionPlanner $planner,
|
||||
private readonly OperationRunService $operationRuns,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly OperationalControlEvaluator $operationalControls,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preview
|
||||
* @param array<string, mixed> $preflight
|
||||
*/
|
||||
public function start(
|
||||
CrossTenantCompareSelection $selection,
|
||||
array $preview,
|
||||
array $preflight,
|
||||
User $actor,
|
||||
): ProviderOperationStartResult {
|
||||
$workspace = $selection->targetTenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new \RuntimeException('Promotion execution requires a workspace context.');
|
||||
}
|
||||
|
||||
$decision = $this->operationalControls->evaluate('promotion.execute', $workspace);
|
||||
|
||||
if ($decision->isPaused()) {
|
||||
$this->auditLogger->log(
|
||||
workspace: $workspace,
|
||||
action: AuditActionId::OperationalControlExecutionBlocked,
|
||||
context: [
|
||||
'metadata' => array_filter([
|
||||
'control_key' => $decision->controlKey,
|
||||
'scope_type' => $decision->matchedScopeType,
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'reason_text' => $decision->reasonText,
|
||||
'expires_at' => $decision->expiresAt?->toIso8601String(),
|
||||
'actor_id' => (int) $actor->getKey(),
|
||||
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'requested_scope' => 'promotion.execute',
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
],
|
||||
actor: $actor,
|
||||
status: 'blocked',
|
||||
resourceType: 'operational_control',
|
||||
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
|
||||
targetLabel: 'Promotion execution',
|
||||
summary: 'Promotion execution blocked by operational control',
|
||||
tenant: $selection->targetTenant,
|
||||
);
|
||||
|
||||
throw OperationalControlBlockedException::forDecision($decision, 'Promotion execution');
|
||||
}
|
||||
|
||||
$plan = $this->planner->build($preview, $preflight);
|
||||
$providerConnection = $this->defaultProviderConnection((int) $selection->targetTenant->getKey());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$identity = array_replace($plan['identity'], [
|
||||
'provider_connection_id' => $providerConnection?->getKey(),
|
||||
]);
|
||||
|
||||
$context = [
|
||||
'operation_type' => 'promotion.execute',
|
||||
'source_tenant_id' => (int) $selection->sourceTenant->getKey(),
|
||||
'source_tenant_name' => (string) $selection->sourceTenant->name,
|
||||
'target_tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'target_tenant_name' => (string) $selection->targetTenant->name,
|
||||
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||
'workspace_required_capability' => Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
'target_scope' => [
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => (int) $selection->targetTenant->getKey(),
|
||||
'provider_connection_id' => $providerConnection instanceof ProviderConnection ? (int) $providerConnection->getKey() : null,
|
||||
'entra_tenant_id' => $providerConnection instanceof ProviderConnection
|
||||
? (string) $providerConnection->entra_tenant_id
|
||||
: (string) ($selection->targetTenant->tenant_id ?? $selection->targetTenant->external_id ?? $selection->targetTenant->getKey()),
|
||||
],
|
||||
'promotion_execution' => [
|
||||
'queued_at' => $now->toIso8601String(),
|
||||
'queued_by_user_id' => (int) $actor->getKey(),
|
||||
'plan' => $plan,
|
||||
],
|
||||
'selection' => $plan['selection'],
|
||||
];
|
||||
|
||||
$run = $this->operationRuns->ensureRunWithIdentity(
|
||||
tenant: $selection->targetTenant,
|
||||
type: 'promotion.execute',
|
||||
identityInputs: $identity,
|
||||
context: $context,
|
||||
initiator: $actor,
|
||||
);
|
||||
|
||||
if (! $run->wasRecentlyCreated) {
|
||||
return ProviderOperationStartResult::deduped($run);
|
||||
}
|
||||
|
||||
$this->operationRuns->updateRun($run, OperationRunStatus::Queued->value, summaryCounts: [
|
||||
'total' => (int) $plan['summary']['ready'],
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
]);
|
||||
|
||||
$this->operationRuns->dispatchOrFail(
|
||||
$run,
|
||||
fn (OperationRun $operationRun): mixed => CrossTenantPromotionExecutionJob::dispatch($operationRun),
|
||||
);
|
||||
|
||||
$this->auditLogger->logCrossTenantPromotionExecutionQueued(
|
||||
workspace: $workspace,
|
||||
sourceTenant: $selection->sourceTenant,
|
||||
targetTenant: $selection->targetTenant,
|
||||
operationRun: $run->fresh() ?? $run,
|
||||
plan: $plan,
|
||||
actor: $actor,
|
||||
);
|
||||
|
||||
return ProviderOperationStartResult::started($run->fresh() ?? $run, true);
|
||||
}
|
||||
|
||||
private function defaultProviderConnection(int $tenantId): ?ProviderConnection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('provider', 'microsoft')
|
||||
->where('is_default', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,10 @@
|
||||
|
||||
class ReviewPackService
|
||||
{
|
||||
public const string REVIEW_DERIVED_DELIVERY_CONTRACT = 'auditor_ready_executive_export.v1';
|
||||
|
||||
public const string EXECUTIVE_ENTRYPOINT_FILENAME = 'executive-summary.md';
|
||||
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
private EvidenceSnapshotResolver $snapshotResolver,
|
||||
@ -193,6 +197,11 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'delivery_bundle' => [
|
||||
'contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'executive_entrypoint_file' => self::EXECUTIVE_ENTRYPOINT_FILENAME,
|
||||
'appendix_files' => ['metadata.json', 'summary.json', 'sections.json'],
|
||||
],
|
||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
||||
? $review->summary['finding_outcomes']
|
||||
: [],
|
||||
@ -376,6 +385,7 @@ public function computeFingerprintForReview(TenantReview $review, array $options
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_fingerprint' => (string) $review->fingerprint,
|
||||
'review_status' => (string) $review->status,
|
||||
'delivery_contract' => self::REVIEW_DERIVED_DELIVERY_CONTRACT,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
@ -38,6 +38,16 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
||||
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
||||
$completeness = $this->readinessGate->completenessForSections($sections);
|
||||
$status = $this->readinessGate->statusForSections($sections);
|
||||
$executiveSummarySection = collect($sections)
|
||||
->firstWhere('section_key', 'executive_summary');
|
||||
$controlInterpretationSection = collect($sections)
|
||||
->firstWhere('section_key', 'control_interpretation');
|
||||
$openRisksSection = collect($sections)
|
||||
->firstWhere('section_key', 'open_risks');
|
||||
$acceptedRisksSection = collect($sections)
|
||||
->firstWhere('section_key', 'accepted_risks');
|
||||
$operationsSection = collect($sections)
|
||||
->firstWhere('section_key', 'operations_health');
|
||||
|
||||
if ($review instanceof TenantReview && $review->isPublished()) {
|
||||
$status = TenantReviewStatus::Published;
|
||||
@ -68,13 +78,260 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
||||
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
|
||||
? data_get($sections, '0.summary_payload.canonical_controls')
|
||||
: [],
|
||||
'control_interpretation' => is_array(data_get($controlInterpretationSection, 'summary_payload'))
|
||||
? array_merge(
|
||||
data_get($controlInterpretationSection, 'summary_payload'),
|
||||
[
|
||||
'controls' => is_array(data_get($controlInterpretationSection, 'render_payload.entries'))
|
||||
? data_get($controlInterpretationSection, 'render_payload.entries')
|
||||
: [],
|
||||
],
|
||||
)
|
||||
: [],
|
||||
'report_count' => 2,
|
||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||
'operation_count' => (int) data_get($operationsSection, 'summary_payload.operation_count', 0),
|
||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
||||
'governance_package' => $this->governancePackageSummary(
|
||||
snapshot: $snapshot,
|
||||
executiveSummarySection: is_array($executiveSummarySection) ? $executiveSummarySection : [],
|
||||
controlInterpretationSection: is_array($controlInterpretationSection) ? $controlInterpretationSection : [],
|
||||
openRisksSection: is_array($openRisksSection) ? $openRisksSection : [],
|
||||
acceptedRisksSection: is_array($acceptedRisksSection) ? $acceptedRisksSection : [],
|
||||
),
|
||||
'last_composed_at' => now()->toIso8601String(),
|
||||
],
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $executiveSummarySection
|
||||
* @param array<string, mixed> $controlInterpretationSection
|
||||
* @param array<string, mixed> $openRisksSection
|
||||
* @param array<string, mixed> $acceptedRisksSection
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function governancePackageSummary(
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $executiveSummarySection,
|
||||
array $controlInterpretationSection,
|
||||
array $openRisksSection,
|
||||
array $acceptedRisksSection,
|
||||
): array {
|
||||
$executiveSummaryPayload = is_array($executiveSummarySection['summary_payload'] ?? null)
|
||||
? $executiveSummarySection['summary_payload']
|
||||
: [];
|
||||
$executiveRenderPayload = is_array($executiveSummarySection['render_payload'] ?? null)
|
||||
? $executiveSummarySection['render_payload']
|
||||
: [];
|
||||
$controlInterpretationSummary = is_array($controlInterpretationSection['summary_payload'] ?? null)
|
||||
? $controlInterpretationSection['summary_payload']
|
||||
: [];
|
||||
$openRiskEntries = collect(data_get($openRisksSection, 'render_payload.entries', []))
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||
->take(3)
|
||||
->map(fn (array $entry): array => $this->packageFindingEntry($entry))
|
||||
->values()
|
||||
->all();
|
||||
$acceptedRiskEntries = collect(data_get($acceptedRisksSection, 'render_payload.entries', []))
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||
->map(fn (array $entry): array => $this->packageAcceptedRiskEntry($entry))
|
||||
->values();
|
||||
$governanceDecisionEntries = $acceptedRiskEntries
|
||||
->filter(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
||||
->values();
|
||||
$stableAcceptedRiskEntries = $acceptedRiskEntries
|
||||
->reject(fn (array $entry): bool => $this->requiresGovernanceDecisionFollowUp($entry))
|
||||
->values();
|
||||
$governanceDecisions = $governanceDecisionEntries
|
||||
->map(fn (array $entry): array => $this->packageGovernanceDecisionEntry($entry))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'delivery_artifact_family' => 'review_pack',
|
||||
'interpretation_version' => is_string($controlInterpretationSummary['version_key'] ?? null)
|
||||
? $controlInterpretationSummary['version_key']
|
||||
: null,
|
||||
'executive_summary' => $this->governancePackageExecutiveSummary(
|
||||
executiveSummaryPayload: $executiveSummaryPayload,
|
||||
executiveRenderPayload: $executiveRenderPayload,
|
||||
controlInterpretationSummary: $controlInterpretationSummary,
|
||||
acceptedRiskCount: $acceptedRiskEntries->count(),
|
||||
),
|
||||
'top_findings' => $openRiskEntries,
|
||||
'accepted_risks' => $stableAcceptedRiskEntries->all(),
|
||||
'governance_decisions' => $governanceDecisions,
|
||||
'evidence_basis_summary' => $this->governancePackageEvidenceBasisSummary(
|
||||
snapshot: $snapshot,
|
||||
controlInterpretationSummary: $controlInterpretationSummary,
|
||||
),
|
||||
'supporting_artifact_links' => [
|
||||
[
|
||||
'artifact_family' => 'evidence_snapshot',
|
||||
'artifact_key' => 'evidence_snapshot:'.$snapshot->getKey(),
|
||||
'purpose' => 'evidence_basis',
|
||||
],
|
||||
[
|
||||
'artifact_family' => 'review_pack',
|
||||
'artifact_key' => 'review_pack:current_export',
|
||||
'purpose' => 'stakeholder_delivery',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function packageFindingEntry(array $entry): array
|
||||
{
|
||||
return [
|
||||
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
||||
'title' => $this->entryTitle($entry, 'Open finding'),
|
||||
'severity' => is_string($entry['severity'] ?? null) ? $entry['severity'] : 'unknown',
|
||||
'status' => is_string($entry['status'] ?? null) ? $entry['status'] : 'unknown',
|
||||
'summary' => $this->entrySummary($entry, 'This finding remains open in the released review and should be discussed in stakeholder delivery.'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function packageAcceptedRiskEntry(array $entry): array
|
||||
{
|
||||
return [
|
||||
'finding_id' => is_numeric($entry['id'] ?? null) ? (int) $entry['id'] : null,
|
||||
'title' => $this->entryTitle($entry, 'Accepted risk'),
|
||||
'governance_state' => is_string($entry['governance_state'] ?? null) ? $entry['governance_state'] : 'unknown',
|
||||
'summary' => $this->entrySummary($entry, 'This accepted-risk entry qualifies the current governance position for stakeholder delivery.'),
|
||||
'owner_label' => $this->ownerLabel($entry),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function requiresGovernanceDecisionFollowUp(array $entry): bool
|
||||
{
|
||||
return in_array((string) ($entry['governance_state'] ?? ''), [
|
||||
'expired_exception',
|
||||
'revoked_exception',
|
||||
'risk_accepted_without_valid_exception',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function packageGovernanceDecisionEntry(array $entry): array
|
||||
{
|
||||
$governanceState = (string) ($entry['governance_state'] ?? 'unknown');
|
||||
|
||||
return [
|
||||
'finding_id' => $entry['finding_id'] ?? null,
|
||||
'title' => $entry['title'] ?? 'Governance decision',
|
||||
'governance_state' => $governanceState,
|
||||
'summary' => match ($governanceState) {
|
||||
'expired_exception' => 'The accepted-risk exception has expired and needs follow-up before stakeholder delivery.',
|
||||
'revoked_exception' => 'The accepted-risk exception was revoked and needs follow-up before stakeholder delivery.',
|
||||
'risk_accepted_without_valid_exception' => 'The accepted-risk entry has no currently valid exception basis and needs follow-up before stakeholder delivery.',
|
||||
default => 'This governance decision needs follow-up before stakeholder delivery.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $executiveSummaryPayload
|
||||
* @param array<string, mixed> $executiveRenderPayload
|
||||
* @param array<string, mixed> $controlInterpretationSummary
|
||||
*/
|
||||
private function governancePackageExecutiveSummary(
|
||||
array $executiveSummaryPayload,
|
||||
array $executiveRenderPayload,
|
||||
array $controlInterpretationSummary,
|
||||
int $acceptedRiskCount,
|
||||
): string {
|
||||
$highlights = collect($executiveRenderPayload['highlights'] ?? [])
|
||||
->filter(static fn (mixed $highlight): bool => is_string($highlight) && trim($highlight) !== '')
|
||||
->values();
|
||||
|
||||
if ($highlights->isNotEmpty()) {
|
||||
return (string) $highlights->first();
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'This released review summarizes %d mapped control(s), %d open risk(s), and %d accepted-risk item(s) from the anchored evidence basis.',
|
||||
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
||||
(int) ($executiveSummaryPayload['open_risk_count'] ?? 0),
|
||||
$acceptedRiskCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $controlInterpretationSummary
|
||||
*/
|
||||
private function governancePackageEvidenceBasisSummary(EvidenceSnapshot $snapshot, array $controlInterpretationSummary): string
|
||||
{
|
||||
return sprintf(
|
||||
'Anchored to evidence snapshot #%d with %s completeness and %d mapped control(s).',
|
||||
(int) $snapshot->getKey(),
|
||||
(string) $snapshot->completeness_state,
|
||||
(int) ($controlInterpretationSummary['mapped_control_count'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function entryTitle(array $entry, string $fallback): string
|
||||
{
|
||||
foreach (['title', 'name', 'finding_title'] as $key) {
|
||||
$value = $entry[$key] ?? null;
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function entrySummary(array $entry, string $fallback): string
|
||||
{
|
||||
foreach (['customer_summary', 'summary', 'request_reason'] as $key) {
|
||||
$value = $entry[$key] ?? null;
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function ownerLabel(array $entry): ?string
|
||||
{
|
||||
$owner = $entry['owner'] ?? null;
|
||||
|
||||
if (is_array($owner)) {
|
||||
$name = $owner['name'] ?? null;
|
||||
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ public function customerWorkspaceTenantQuery(User $user, Workspace $workspace):
|
||||
return Tenant::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereHas('tenantReviews', fn ($query) => $query->published())
|
||||
->with([
|
||||
'tenantReviews' => fn ($query) => $query
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -15,6 +16,7 @@ final class TenantReviewSectionFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
private readonly ComplianceEvidenceMappingV1 $complianceEvidenceMapping,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -29,8 +31,11 @@ public function make(EvidenceSnapshot $snapshot): array
|
||||
$baselineItem = $this->item($items, 'baseline_drift_posture');
|
||||
$operationsItem = $this->item($items, 'operations_summary');
|
||||
|
||||
$controlInterpretation = $this->complianceEvidenceMapping->interpret($snapshot, $findingsItem);
|
||||
|
||||
return [
|
||||
$this->executiveSummarySection($snapshot, $findingsItem, $permissionItem, $rolesItem, $baselineItem, $operationsItem),
|
||||
$controlInterpretation['section'],
|
||||
$this->openRisksSection($findingsItem),
|
||||
$this->acceptedRisksSection($findingsItem),
|
||||
$this->permissionPostureSection($permissionItem, $rolesItem),
|
||||
|
||||
@ -27,6 +27,9 @@ enum AuditActionId: string
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
|
||||
case PolicyProviderMissingDetected = 'policy.provider_missing_detected';
|
||||
case PolicyProviderMissingCleared = 'policy.provider_missing_cleared';
|
||||
|
||||
// Managed tenant onboarding wizard.
|
||||
case ManagedTenantOnboardingStart = 'managed_tenant_onboarding.start';
|
||||
case ManagedTenantOnboardingResume = 'managed_tenant_onboarding.resume';
|
||||
@ -70,6 +73,8 @@ enum AuditActionId: string
|
||||
case BaselineCompareCompleted = 'baseline_compare.completed';
|
||||
case BaselineCompareFailed = 'baseline_compare.failed';
|
||||
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
|
||||
case CrossTenantPromotionExecutionQueued = 'cross_tenant_promotion_execution.queued';
|
||||
case CrossTenantPromotionExecutionCompleted = 'cross_tenant_promotion_execution.completed';
|
||||
case BaselineAssignmentCreated = 'baseline_assignment.created';
|
||||
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
|
||||
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
|
||||
@ -91,6 +96,7 @@ enum AuditActionId: string
|
||||
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
||||
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
||||
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
||||
case EvidenceSnapshotOpened = 'evidence_snapshot.opened';
|
||||
case TenantReviewCreated = 'tenant_review.created';
|
||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||
case TenantReviewPublished = 'tenant_review.published';
|
||||
@ -98,6 +104,7 @@ enum AuditActionId: string
|
||||
case TenantReviewOpened = 'tenant_review.opened';
|
||||
case TenantReviewExported = 'tenant_review.exported';
|
||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||
case CustomerReviewWorkspaceOpened = 'customer_review_workspace.opened';
|
||||
case ReviewPackDownloaded = 'review_pack.downloaded';
|
||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||
@ -182,6 +189,8 @@ private static function labels(): array
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role change',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removal',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection',
|
||||
self::PolicyProviderMissingDetected->value => 'Policy provider missing detected',
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider missing cleared',
|
||||
self::ManagedTenantOnboardingStart->value => 'Managed tenant onboarding start',
|
||||
self::ManagedTenantOnboardingResume->value => 'Managed tenant onboarding resume',
|
||||
self::ManagedTenantOnboardingDraftSelected->value => 'Managed tenant onboarding draft selected',
|
||||
@ -220,6 +229,8 @@ private static function labels(): array
|
||||
self::BaselineCompareCompleted->value => 'Baseline compare completed',
|
||||
self::BaselineCompareFailed->value => 'Baseline compare failed',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
|
||||
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
|
||||
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
|
||||
@ -241,6 +252,7 @@ private static function labels(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
@ -248,6 +260,7 @@ private static function labels(): array
|
||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||
@ -308,6 +321,8 @@ private static function summaries(): array
|
||||
self::TenantMembershipRoleChange->value => 'Tenant member role changed',
|
||||
self::TenantMembershipRemove->value => 'Tenant member removed',
|
||||
self::TenantMembershipLastOwnerBlocked->value => 'Tenant last-owner protection triggered',
|
||||
self::PolicyProviderMissingDetected->value => 'Policy marked provider missing',
|
||||
self::PolicyProviderMissingCleared->value => 'Policy provider presence restored',
|
||||
self::WorkspaceSettingUpdated->value => 'Workspace setting updated',
|
||||
self::WorkspaceSettingReset->value => 'Workspace setting reset',
|
||||
self::BaselineProfileCreated->value => 'Baseline profile created',
|
||||
@ -315,6 +330,8 @@ private static function summaries(): array
|
||||
self::BaselineProfileArchived->value => 'Baseline profile archived',
|
||||
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
|
||||
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
|
||||
self::CrossTenantPromotionExecutionQueued->value => 'Cross-tenant promotion execution queued',
|
||||
self::CrossTenantPromotionExecutionCompleted->value => 'Cross-tenant promotion execution completed',
|
||||
self::AlertDestinationCreated->value => 'Alert destination created',
|
||||
self::AlertDestinationUpdated->value => 'Alert destination updated',
|
||||
self::AlertDestinationDeleted->value => 'Alert destination deleted',
|
||||
@ -337,6 +354,7 @@ private static function summaries(): array
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
self::EvidenceSnapshotOpened->value => 'Evidence snapshot opened',
|
||||
self::TenantReviewCreated->value => 'Tenant review created',
|
||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||
self::TenantReviewPublished->value => 'Tenant review published',
|
||||
@ -344,6 +362,7 @@ private static function summaries(): array
|
||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::CustomerReviewWorkspaceOpened->value => 'Customer review workspace opened',
|
||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||
self::SupportRequestCreated->value => 'Support request created',
|
||||
|
||||
@ -205,8 +205,8 @@ public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySum
|
||||
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
||||
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
||||
nextAction: $degradationFamilies === []
|
||||
? 'Open the version detail if you need raw settings or diff context.'
|
||||
: 'Prefer a stronger version or inspect the version detail before restore.',
|
||||
? $this->text('next_action_open_version_detail')
|
||||
: $this->text('next_action_prefer_stronger_version'),
|
||||
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
||||
);
|
||||
}
|
||||
@ -295,25 +295,25 @@ private function singleRecordHighlights(
|
||||
$highlights = [];
|
||||
|
||||
if ($snapshotMode === 'metadata_only') {
|
||||
$highlights[] = 'Metadata only';
|
||||
$highlights[] = $this->text('quality_highlight_metadata_only');
|
||||
}
|
||||
|
||||
if ($hasAssignmentIssues) {
|
||||
$highlights[] = 'Assignment fetch failed';
|
||||
$highlights[] = $this->text('quality_highlight_assignment_fetch_failed');
|
||||
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
||||
$highlights[] = 'Assignments captured separately';
|
||||
$highlights[] = $this->text('quality_highlight_assignments_captured_separately');
|
||||
}
|
||||
|
||||
if ($hasOrphanedAssignments) {
|
||||
$highlights[] = 'Orphaned assignments';
|
||||
$highlights[] = $this->text('quality_highlight_orphaned_assignments');
|
||||
}
|
||||
|
||||
if ($integrityWarning !== null) {
|
||||
$highlights[] = 'Integrity warning';
|
||||
$highlights[] = $this->text('quality_highlight_integrity_warning');
|
||||
}
|
||||
|
||||
if ($snapshotMode === 'unknown' && $highlights === []) {
|
||||
$highlights[] = 'Unknown quality';
|
||||
$highlights[] = $this->text('quality_highlight_unknown_quality');
|
||||
}
|
||||
|
||||
return array_values(array_unique($highlights));
|
||||
@ -326,9 +326,9 @@ private function compactSummaryFromHighlights(array $qualityHighlights, string $
|
||||
}
|
||||
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'Full payload',
|
||||
'unknown' => 'Unknown quality',
|
||||
default => 'No degradations detected',
|
||||
'full' => $this->text('compact_summary_full_payload'),
|
||||
'unknown' => $this->text('compact_summary_unknown_quality'),
|
||||
default => $this->text('compact_summary_no_degradations_detected'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -336,15 +336,20 @@ private function singleRecordSummaryMessage(array $qualityHighlights, string $sn
|
||||
{
|
||||
if ($qualityHighlights === []) {
|
||||
return match ($snapshotMode) {
|
||||
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
||||
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
||||
default => 'No degradations were detected.',
|
||||
'full' => $this->text('summary_full_no_degradations'),
|
||||
'unknown' => $this->text('summary_unknown_quality'),
|
||||
default => $this->text('summary_no_degradations'),
|
||||
};
|
||||
}
|
||||
|
||||
return implode(' • ', $qualityHighlights).'.';
|
||||
}
|
||||
|
||||
private function text(string $key, array $replace = []): string
|
||||
{
|
||||
return __('localization.policy.versions.'.$key, $replace);
|
||||
}
|
||||
|
||||
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
||||
{
|
||||
if ($totalItems === 0) {
|
||||
|
||||
@ -43,6 +43,7 @@ final class BadgeCatalog
|
||||
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,
|
||||
BadgeDomain::PolicyRestoreMode->value => Domains\PolicyRestoreModeBadge::class,
|
||||
BadgeDomain::PolicyRisk->value => Domains\PolicyRiskBadge::class,
|
||||
BadgeDomain::PolicyProviderPresence->value => Domains\PolicyProviderPresenceBadge::class,
|
||||
BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class,
|
||||
BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class,
|
||||
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
|
||||
|
||||
@ -34,6 +34,7 @@ enum BadgeDomain: string
|
||||
case PolicySnapshotMode = 'policy_snapshot_mode';
|
||||
case PolicyRestoreMode = 'policy_restore_mode';
|
||||
case PolicyRisk = 'policy_risk';
|
||||
case PolicyProviderPresence = 'policy_provider_presence';
|
||||
case IgnoredAt = 'ignored_at';
|
||||
case RestorePreviewDecision = 'restore_preview_decision';
|
||||
case RestoreResultStatus = 'restore_result_status';
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class PolicyProviderPresenceBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
return match (BadgeCatalog::normalizeState($value)) {
|
||||
Policy::VISIBILITY_ACTIVE => new BadgeSpec(__('localization.policy.badges.active'), 'success', 'heroicon-m-check-circle'),
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY => new BadgeSpec(__('localization.policy.badges.ignored_locally'), 'warning', 'heroicon-m-eye-slash'),
|
||||
Policy::VISIBILITY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.source_unavailable'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
Policy::VISIBILITY_IGNORED_LOCALLY_PROVIDER_MISSING => new BadgeSpec(__('localization.policy.badges.ignored_source_unavailable'), 'danger', 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -13,8 +13,8 @@ public function spec(mixed $value): BadgeSpec
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
'full' => new BadgeSpec('Full', 'success', 'heroicon-m-check-circle'),
|
||||
'metadata_only' => new BadgeSpec('Metadata only', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
'full' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_full'), 'success', 'heroicon-m-check-circle'),
|
||||
'metadata_only' => new BadgeSpec(__('localization.policy.versions.snapshot_mode_metadata_only'), 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -120,12 +120,12 @@ private static function platform(mixed $value): TagBadgeSpec
|
||||
->toString();
|
||||
|
||||
$label = match ($normalized) {
|
||||
'windows' => 'Windows',
|
||||
'android' => 'Android',
|
||||
'ios' => 'iOS',
|
||||
'macos' => 'macOS',
|
||||
'all' => 'All',
|
||||
'mobile' => 'Mobile',
|
||||
'windows' => __('localization.policy.common.platform_label_windows'),
|
||||
'android' => __('localization.policy.common.platform_label_android'),
|
||||
'ios' => __('localization.policy.common.platform_label_ios'),
|
||||
'macos' => __('localization.policy.common.platform_label_macos'),
|
||||
'all' => __('localization.policy.common.platform_label_all'),
|
||||
'mobile' => __('localization.policy.common.platform_label_mobile'),
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Governance\Controls;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final readonly class ComplianceEvidenceMappingV1
|
||||
{
|
||||
public const string VERSION_KEY = 'compliance_evidence_mapping.v1';
|
||||
|
||||
public const string SECTION_KEY = 'control_interpretation';
|
||||
|
||||
public function __construct(
|
||||
private CanonicalControlCatalog $catalog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* summary: array<string, mixed>,
|
||||
* section: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function interpret(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem): array
|
||||
{
|
||||
$findingsSummary = $this->findingsSummary($findingsItem);
|
||||
$entries = $this->findingEntries($findingsSummary);
|
||||
$unresolvedEntryCount = $entries
|
||||
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.status') !== 'resolved')
|
||||
->count();
|
||||
$controls = $this->controlDefinitions($findingsSummary, $entries);
|
||||
$snapshotLimitations = $this->snapshotLimitations($snapshot, $findingsItem, $unresolvedEntryCount);
|
||||
|
||||
$controlSummaries = $controls
|
||||
->map(fn (CanonicalControlDefinition $definition): array => $this->controlSummary(
|
||||
definition: $definition,
|
||||
entries: $this->entriesForControl($entries, $definition->controlKey),
|
||||
snapshotLimitations: $snapshotLimitations,
|
||||
))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$globalLimitations = $this->globalLimitations($controlSummaries, $snapshotLimitations, $controls->isEmpty(), $unresolvedEntryCount);
|
||||
$limitationCounts = $this->limitationCounts($controlSummaries, $globalLimitations);
|
||||
|
||||
$summary = [
|
||||
'version_key' => self::VERSION_KEY,
|
||||
'display_label' => 'Compliance evidence mapping v1',
|
||||
'non_certification_disclosure' => 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.',
|
||||
'mapped_control_count' => count($controlSummaries),
|
||||
'follow_up_required_count' => collect($controlSummaries)
|
||||
->where('readiness_bucket', 'follow_up_required')
|
||||
->count(),
|
||||
'limitation_counts' => $limitationCounts,
|
||||
'limitations' => $globalLimitations,
|
||||
'controls' => $controlSummaries,
|
||||
];
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'section' => [
|
||||
'section_key' => self::SECTION_KEY,
|
||||
'title' => 'Control readiness interpretation',
|
||||
'sort_order' => 15,
|
||||
'required' => true,
|
||||
'completeness_state' => $this->sectionCompleteness($findingsItem, $controls->isEmpty(), $snapshotLimitations),
|
||||
'source_snapshot_fingerprint' => $this->sourceFingerprint($findingsItem) ?? (string) $snapshot->fingerprint,
|
||||
'summary_payload' => Arr::except($summary, ['controls']),
|
||||
'render_payload' => [
|
||||
'entries' => array_map(
|
||||
fn (array $control): array => $this->controlExplanation($control, $snapshot),
|
||||
$controlSummaries,
|
||||
),
|
||||
'disclosure' => $summary['non_certification_disclosure'],
|
||||
'next_actions' => $this->sectionNextActions($controlSummaries, $globalLimitations),
|
||||
'empty_state' => $controlSummaries === []
|
||||
? 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.'
|
||||
: null,
|
||||
],
|
||||
'measured_at' => $findingsItem?->measured_at ?? $snapshot->generated_at,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findingsSummary(?EvidenceSnapshotItem $findingsItem): array
|
||||
{
|
||||
return is_array($findingsItem?->summary_payload) ? $findingsItem->summary_payload : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $findingsSummary
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function findingEntries(array $findingsSummary): Collection
|
||||
{
|
||||
return collect(Arr::wrap($findingsSummary['entries'] ?? []))
|
||||
->filter(static fn (mixed $entry): bool => is_array($entry))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $findingsSummary
|
||||
* @param Collection<int, array<string, mixed>> $entries
|
||||
* @return Collection<int, CanonicalControlDefinition>
|
||||
*/
|
||||
private function controlDefinitions(array $findingsSummary, Collection $entries): Collection
|
||||
{
|
||||
$summaryControls = collect(Arr::wrap($findingsSummary['canonical_controls'] ?? []))
|
||||
->filter(static fn (mixed $control): bool => is_array($control));
|
||||
|
||||
$entryControls = $entries
|
||||
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
||||
->filter(static fn (mixed $control): bool => is_array($control));
|
||||
|
||||
return $summaryControls
|
||||
->merge($entryControls)
|
||||
->map(fn (array $control): ?CanonicalControlDefinition => $this->definitionFor($control))
|
||||
->filter()
|
||||
->unique(static fn (CanonicalControlDefinition $definition): string => $definition->controlKey)
|
||||
->sortBy(static fn (CanonicalControlDefinition $definition): string => $definition->name)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $control
|
||||
*/
|
||||
private function definitionFor(array $control): ?CanonicalControlDefinition
|
||||
{
|
||||
$controlKey = $control['control_key'] ?? null;
|
||||
|
||||
if (! is_string($controlKey) || trim($controlKey) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->catalog->find($controlKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $entries
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function entriesForControl(Collection $entries, string $controlKey): Collection
|
||||
{
|
||||
return $entries
|
||||
->filter(static fn (array $entry): bool => (string) data_get($entry, 'canonical_control_resolution.control.control_key') === $controlKey)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $entries
|
||||
* @param list<string> $snapshotLimitations
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function controlSummary(CanonicalControlDefinition $definition, Collection $entries, array $snapshotLimitations): array
|
||||
{
|
||||
$openEntries = $entries->filter(static fn (array $entry): bool => in_array((string) ($entry['status'] ?? ''), Finding::openStatuses(), true));
|
||||
$acceptedRiskEntries = $entries->filter(static fn (array $entry): bool => (string) ($entry['status'] ?? '') === Finding::STATUS_RISK_ACCEPTED);
|
||||
$governanceWarnings = $entries->filter(static fn (array $entry): bool => self::hasGovernanceWarning($entry));
|
||||
$limitationFlags = $this->controlLimitations($acceptedRiskEntries->count(), $snapshotLimitations);
|
||||
$readinessBucket = $this->readinessBucket(
|
||||
openCount: $openEntries->count(),
|
||||
acceptedRiskCount: $acceptedRiskEntries->count(),
|
||||
governanceWarningCount: $governanceWarnings->count(),
|
||||
limitationFlags: $limitationFlags,
|
||||
);
|
||||
|
||||
return [
|
||||
'control_key' => $definition->controlKey,
|
||||
'control_name' => $definition->name,
|
||||
'domain_key' => $definition->domainKey,
|
||||
'readiness_bucket' => $readinessBucket,
|
||||
'readiness_label' => self::readinessLabel($readinessBucket),
|
||||
'limitation_flags' => $limitationFlags,
|
||||
'limitation_labels' => array_map(self::limitationLabel(...), $limitationFlags),
|
||||
'customer_summary' => $this->customerSummary($definition, $readinessBucket, $openEntries->count(), $acceptedRiskEntries->count()),
|
||||
'evidence_basis_summary' => $this->evidenceBasisSummary($entries->count(), $openEntries->count(), $acceptedRiskEntries->count()),
|
||||
'accepted_risk_summary' => $acceptedRiskEntries->isEmpty()
|
||||
? null
|
||||
: $this->acceptedRiskSummary($acceptedRiskEntries, $governanceWarnings->count()),
|
||||
'recommended_next_action' => $this->recommendedNextAction($readinessBucket, $acceptedRiskEntries->count(), $limitationFlags),
|
||||
'detail_anchor' => 'control-'.$definition->controlKey,
|
||||
'supporting_finding_ids' => $entries
|
||||
->pluck('id')
|
||||
->filter(static fn (mixed $id): bool => is_numeric($id))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'finding_count' => $entries->count(),
|
||||
'open_finding_count' => $openEntries->count(),
|
||||
'accepted_risk_count' => $acceptedRiskEntries->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array<string, mixed>> $acceptedRiskEntries
|
||||
*/
|
||||
private function acceptedRiskSummary(Collection $acceptedRiskEntries, int $governanceWarningCount): string
|
||||
{
|
||||
if ($governanceWarningCount > 0) {
|
||||
return sprintf(
|
||||
'%d accepted-risk finding(s) need governance follow-up before relying on this interpretation.',
|
||||
$acceptedRiskEntries->count(),
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%d accepted-risk finding(s) are part of the evidence basis and qualify the readiness view.',
|
||||
$acceptedRiskEntries->count(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $snapshotLimitations
|
||||
* @return list<string>
|
||||
*/
|
||||
private function controlLimitations(int $acceptedRiskCount, array $snapshotLimitations): array
|
||||
{
|
||||
$limitations = $snapshotLimitations;
|
||||
|
||||
if ($acceptedRiskCount > 0) {
|
||||
$limitations[] = 'accepted_risk_influenced';
|
||||
}
|
||||
|
||||
return array_values(array_unique($limitations));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $limitationFlags
|
||||
*/
|
||||
private function readinessBucket(int $openCount, int $acceptedRiskCount, int $governanceWarningCount, array $limitationFlags): string
|
||||
{
|
||||
if ($openCount > 0 || $governanceWarningCount > 0) {
|
||||
return 'follow_up_required';
|
||||
}
|
||||
|
||||
if ($acceptedRiskCount > 0 || $limitationFlags !== []) {
|
||||
return 'review_recommended';
|
||||
}
|
||||
|
||||
return 'evidence_on_record';
|
||||
}
|
||||
|
||||
private function customerSummary(CanonicalControlDefinition $definition, string $readinessBucket, int $openCount, int $acceptedRiskCount): string
|
||||
{
|
||||
return match ($readinessBucket) {
|
||||
'follow_up_required' => sprintf(
|
||||
'%s needs follow-up because %d open finding(s) remain in the released evidence basis.',
|
||||
$definition->name,
|
||||
$openCount,
|
||||
),
|
||||
'review_recommended' => $acceptedRiskCount > 0
|
||||
? sprintf('%s has evidence on record with accepted-risk context that should be reviewed before relying on the interpretation.', $definition->name)
|
||||
: sprintf('%s has evidence on record, with limitations that should be reviewed before relying on the interpretation.', $definition->name),
|
||||
default => sprintf('%s has evidence on record in this released review.', $definition->name),
|
||||
};
|
||||
}
|
||||
|
||||
private function evidenceBasisSummary(int $signalCount, int $openCount, int $acceptedRiskCount): string
|
||||
{
|
||||
$parts = [
|
||||
sprintf('%d evidence signal(s) reference this control.', $signalCount),
|
||||
];
|
||||
|
||||
if ($openCount > 0) {
|
||||
$parts[] = sprintf('%d open finding(s) still need follow-up.', $openCount);
|
||||
}
|
||||
|
||||
if ($acceptedRiskCount > 0) {
|
||||
$parts[] = sprintf('%d accepted-risk finding(s) qualify this view.', $acceptedRiskCount);
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $limitationFlags
|
||||
*/
|
||||
private function recommendedNextAction(string $readinessBucket, int $acceptedRiskCount, array $limitationFlags): string
|
||||
{
|
||||
if ($readinessBucket === 'follow_up_required') {
|
||||
return 'Review the surfaced findings with the tenant and agree ownership plus follow-up timing.';
|
||||
}
|
||||
|
||||
if ($acceptedRiskCount > 0) {
|
||||
return 'Review the accepted-risk owner and next review date before customer delivery.';
|
||||
}
|
||||
|
||||
if ($limitationFlags !== []) {
|
||||
return 'Confirm the evidence basis and limitations before using this control as customer-facing readiness support.';
|
||||
}
|
||||
|
||||
return 'Keep this evidence on record and revisit it during the normal review cadence.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $control
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function controlExplanation(array $control, EvidenceSnapshot $snapshot): array
|
||||
{
|
||||
return [
|
||||
'title' => $control['control_name'],
|
||||
'control_key' => $control['control_key'],
|
||||
'control_name' => $control['control_name'],
|
||||
'readiness_bucket' => $control['readiness_bucket'],
|
||||
'readiness_label' => $control['readiness_label'],
|
||||
'limitation_flags' => $control['limitation_flags'],
|
||||
'limitation_labels' => $control['limitation_labels'],
|
||||
'customer_summary' => $control['customer_summary'],
|
||||
'evidence_basis_summary' => $control['evidence_basis_summary'],
|
||||
'accepted_risk_summary' => $control['accepted_risk_summary'],
|
||||
'explanation_text' => $control['customer_summary'],
|
||||
'evidence_basis_items' => array_values(array_filter([
|
||||
$control['evidence_basis_summary'],
|
||||
$control['accepted_risk_summary'],
|
||||
])),
|
||||
'accepted_risk_context' => $control['accepted_risk_summary'],
|
||||
'recommended_next_action' => $control['recommended_next_action'],
|
||||
'proof_access_state' => $this->proofAccessState($snapshot),
|
||||
'supporting_finding_ids' => $control['supporting_finding_ids'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $controlSummaries
|
||||
* @param list<string> $globalLimitations
|
||||
* @return list<string>
|
||||
*/
|
||||
private function sectionNextActions(array $controlSummaries, array $globalLimitations): array
|
||||
{
|
||||
if ($controlSummaries === []) {
|
||||
return ['Review unmapped evidence before using this review for customer-facing readiness discussions.'];
|
||||
}
|
||||
|
||||
$actions = collect($controlSummaries)
|
||||
->pluck('recommended_next_action')
|
||||
->filter(static fn (mixed $action): bool => is_string($action) && trim($action) !== '')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (in_array('unmapped', $globalLimitations, true)) {
|
||||
$actions[] = 'Treat this review as partial until unmapped evidence can be interpreted.';
|
||||
}
|
||||
|
||||
return array_values(array_unique($actions));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $controlSummaries
|
||||
* @param list<string> $snapshotLimitations
|
||||
* @return list<string>
|
||||
*/
|
||||
private function globalLimitations(array $controlSummaries, array $snapshotLimitations, bool $noMappedControls, int $unresolvedEntryCount): array
|
||||
{
|
||||
$limitations = $snapshotLimitations;
|
||||
|
||||
if ($noMappedControls) {
|
||||
$limitations[] = 'unmapped';
|
||||
}
|
||||
|
||||
if ($unresolvedEntryCount > 0) {
|
||||
$limitations[] = 'partial_mapping';
|
||||
}
|
||||
|
||||
foreach ($controlSummaries as $control) {
|
||||
foreach (Arr::wrap($control['limitation_flags'] ?? []) as $limitation) {
|
||||
if (is_string($limitation) && trim($limitation) !== '') {
|
||||
$limitations[] = $limitation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($limitations));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $controlSummaries
|
||||
* @param list<string> $globalLimitations
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function limitationCounts(array $controlSummaries, array $globalLimitations): array
|
||||
{
|
||||
$counts = collect($controlSummaries)
|
||||
->flatMap(static fn (array $control): array => Arr::wrap($control['limitation_flags'] ?? []))
|
||||
->filter(static fn (mixed $limitation): bool => is_string($limitation) && trim($limitation) !== '')
|
||||
->countBy()
|
||||
->all();
|
||||
|
||||
foreach ($globalLimitations as $limitation) {
|
||||
$counts[$limitation] = max((int) ($counts[$limitation] ?? 0), 1);
|
||||
}
|
||||
|
||||
ksort($counts);
|
||||
|
||||
return array_map('intval', $counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function snapshotLimitations(EvidenceSnapshot $snapshot, ?EvidenceSnapshotItem $findingsItem, int $unresolvedEntryCount): array
|
||||
{
|
||||
$limitations = [];
|
||||
$state = (string) ($findingsItem?->state ?? $snapshot->completeness_state);
|
||||
|
||||
if ($state === TenantReviewCompletenessState::Stale->value || (string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
$limitations[] = 'stale_evidence';
|
||||
}
|
||||
|
||||
if (in_array($state, [TenantReviewCompletenessState::Partial->value, TenantReviewCompletenessState::Missing->value], true)) {
|
||||
$limitations[] = 'partial_mapping';
|
||||
}
|
||||
|
||||
if ($unresolvedEntryCount > 0) {
|
||||
$limitations[] = 'partial_mapping';
|
||||
}
|
||||
|
||||
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
||||
$limitations[] = 'supporting_evidence_unavailable';
|
||||
}
|
||||
|
||||
return array_values(array_unique($limitations));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $snapshotLimitations
|
||||
*/
|
||||
private function sectionCompleteness(?EvidenceSnapshotItem $findingsItem, bool $noMappedControls, array $snapshotLimitations): string
|
||||
{
|
||||
if (! $findingsItem instanceof EvidenceSnapshotItem) {
|
||||
return TenantReviewCompletenessState::Missing->value;
|
||||
}
|
||||
|
||||
if (in_array('stale_evidence', $snapshotLimitations, true)) {
|
||||
return TenantReviewCompletenessState::Stale->value;
|
||||
}
|
||||
|
||||
if ($noMappedControls || in_array('partial_mapping', $snapshotLimitations, true)) {
|
||||
return TenantReviewCompletenessState::Partial->value;
|
||||
}
|
||||
|
||||
return TenantReviewCompletenessState::tryFrom((string) $findingsItem->state)?->value
|
||||
?? TenantReviewCompletenessState::Missing->value;
|
||||
}
|
||||
|
||||
private function proofAccessState(EvidenceSnapshot $snapshot): string
|
||||
{
|
||||
if ((string) $snapshot->status === 'expired' || ($snapshot->expires_at !== null && $snapshot->expires_at->isPast())) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
if (! $snapshot->exists || $snapshot->generated_at === null) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
return 'available';
|
||||
}
|
||||
|
||||
private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
||||
{
|
||||
$fingerprint = $item?->source_fingerprint;
|
||||
|
||||
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function hasGovernanceWarning(array $entry): bool
|
||||
{
|
||||
if (is_string($entry['governance_warning'] ?? null) && trim((string) $entry['governance_warning']) !== '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((string) ($entry['governance_state'] ?? ''), [
|
||||
'expired_exception',
|
||||
'revoked_exception',
|
||||
'rejected_exception',
|
||||
'risk_accepted_without_valid_exception',
|
||||
], true);
|
||||
}
|
||||
|
||||
public static function readinessLabel(string $bucket): string
|
||||
{
|
||||
return match ($bucket) {
|
||||
'follow_up_required' => 'Follow-up required',
|
||||
'review_recommended' => 'Review recommended',
|
||||
'evidence_on_record' => 'Evidence on record',
|
||||
default => Str::headline($bucket),
|
||||
};
|
||||
}
|
||||
|
||||
public static function limitationLabel(string $flag): string
|
||||
{
|
||||
return match ($flag) {
|
||||
'accepted_risk_influenced' => 'Accepted risk influences this view',
|
||||
'partial_mapping' => 'Partial evidence mapping',
|
||||
'stale_evidence' => 'Evidence freshness needs review',
|
||||
'supporting_evidence_unavailable' => 'Supporting evidence unavailable',
|
||||
'unmapped' => 'No mapped control coverage',
|
||||
default => Str::headline($flag),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -141,7 +141,7 @@ public function supportsFilters(string $domainKey, string $subjectClass): bool
|
||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||
{
|
||||
return match ([trim($domainKey), trim($subjectClass)]) {
|
||||
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => 'Intune policies',
|
||||
[GovernanceDomainKey::Intune->value, GovernanceSubjectClass::Policy->value] => __('localization.policy.taxonomy.policies'),
|
||||
[GovernanceDomainKey::PlatformFoundation->value, GovernanceSubjectClass::ConfigurationResource->value] => 'Platform foundation configuration resources',
|
||||
[GovernanceDomainKey::Entra->value, GovernanceSubjectClass::Control->value] => 'Entra controls',
|
||||
default => trim($domainKey).' / '.trim($subjectClass),
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\GovernanceDecisions;
|
||||
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final readonly class GovernanceDecisionRegisterBuilder
|
||||
{
|
||||
private const int RECENTLY_CLOSED_DAYS = 30;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array TERMINAL_STATUSES = [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int, Tenant> $visibleTenants
|
||||
* @return array{
|
||||
* rows: list<array<string, mixed>>,
|
||||
* counts: array{open: int, recently_closed: int},
|
||||
* }
|
||||
*/
|
||||
public function build(Workspace $workspace, array $visibleTenants, string $registerState = 'open'): array
|
||||
{
|
||||
$visibleTenantIds = array_values(array_map(
|
||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
||||
$visibleTenants,
|
||||
));
|
||||
|
||||
if ($visibleTenantIds === []) {
|
||||
return [
|
||||
'rows' => [],
|
||||
'counts' => [
|
||||
'open' => 0,
|
||||
'recently_closed' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$rows = FindingException::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('tenant_id', $visibleTenantIds)
|
||||
->with(['tenant:id,name', 'owner:id,name', 'currentDecision'])
|
||||
->get()
|
||||
->map(fn (FindingException $exception): ?array => $this->buildRow($exception))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
/** @var Collection<int, array<string, mixed>> $openRows */
|
||||
$openRows = $rows
|
||||
->where('register_state', 'open')
|
||||
->sortBy([
|
||||
['due_at', 'asc'],
|
||||
['exception_id', 'asc'],
|
||||
])
|
||||
->values();
|
||||
|
||||
/** @var Collection<int, array<string, mixed>> $recentlyClosedRows */
|
||||
$recentlyClosedRows = $rows
|
||||
->where('register_state', 'recently_closed')
|
||||
->sortByDesc('decision_at')
|
||||
->values();
|
||||
|
||||
return [
|
||||
'rows' => match ($registerState) {
|
||||
'recently_closed' => $recentlyClosedRows->all(),
|
||||
default => $openRows->all(),
|
||||
},
|
||||
'counts' => [
|
||||
'open' => $openRows->count(),
|
||||
'recently_closed' => $recentlyClosedRows->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function buildRow(FindingException $exception): ?array
|
||||
{
|
||||
$currentDecision = $exception->currentDecision;
|
||||
|
||||
if (! $currentDecision instanceof FindingExceptionDecision) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$registerState = $this->resolveRegisterState($exception, $currentDecision);
|
||||
|
||||
if ($registerState === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'exception_id' => (int) $exception->getKey(),
|
||||
'register_state' => $registerState,
|
||||
'tenant_name' => $exception->tenant?->name,
|
||||
'owner_name' => $exception->owner?->name,
|
||||
'status' => (string) $exception->status,
|
||||
'current_validity_state' => (string) $exception->current_validity_state,
|
||||
'next_action_label' => $registerState === 'open'
|
||||
? $this->resolveNextActionLabel($exception, $currentDecision)
|
||||
: 'Decision closed',
|
||||
'closure_reason' => $registerState === 'recently_closed'
|
||||
? (string) $currentDecision->reason
|
||||
: null,
|
||||
'due_at' => $exception->review_due_at ?? $exception->expires_at,
|
||||
'decision_at' => $currentDecision->decided_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRegisterState(FindingException $exception, FindingExceptionDecision $currentDecision): ?string
|
||||
{
|
||||
$status = (string) $exception->status;
|
||||
|
||||
if (in_array($status, self::TERMINAL_STATUSES, true)) {
|
||||
return $this->isRecentlyClosed($currentDecision->decided_at)
|
||||
? 'recently_closed'
|
||||
: null;
|
||||
}
|
||||
|
||||
return 'open';
|
||||
}
|
||||
|
||||
private function resolveNextActionLabel(FindingException $exception, FindingExceptionDecision $currentDecision): string
|
||||
{
|
||||
if ($exception->isPendingRenewal() || $currentDecision->decision_type === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED) {
|
||||
return 'Review renewal';
|
||||
}
|
||||
|
||||
if ($exception->isPending()) {
|
||||
return 'Review approval';
|
||||
}
|
||||
|
||||
return 'Review follow-up';
|
||||
}
|
||||
|
||||
private function isRecentlyClosed(?CarbonInterface $decidedAt): bool
|
||||
{
|
||||
if (! $decidedAt instanceof CarbonInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $decidedAt->greaterThanOrEqualTo(now()->startOfDay()->subDays(self::RECENTLY_CLOSED_DAYS));
|
||||
}
|
||||
}
|
||||
@ -84,6 +84,20 @@ public static function forGovernanceInbox(
|
||||
);
|
||||
}
|
||||
|
||||
public static function forDecisionRegister(
|
||||
string $canonicalRouteName,
|
||||
?int $tenantId,
|
||||
string $backLinkUrl,
|
||||
): self {
|
||||
return new self(
|
||||
sourceSurface: 'governance.decision_register',
|
||||
canonicalRouteName: $canonicalRouteName,
|
||||
tenantId: $tenantId,
|
||||
backLinkLabel: 'Back to decision register',
|
||||
backLinkUrl: $backLinkUrl,
|
||||
);
|
||||
}
|
||||
|
||||
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
|
||||
{
|
||||
return new self(
|
||||
|
||||
@ -49,6 +49,18 @@ public function entryLabel(string $relationKey): string
|
||||
return OperationRunLinks::singularLabel();
|
||||
}
|
||||
|
||||
if ($relationKey === 'current_policy_version') {
|
||||
return __('localization.policy.versions.related_entry_current_policy_version');
|
||||
}
|
||||
|
||||
if ($relationKey === 'parent_policy') {
|
||||
return __('localization.policy.versions.related_entry_policy');
|
||||
}
|
||||
|
||||
if ($relationKey === 'policy_version') {
|
||||
return __('localization.policy.versions.related_entry_policy_version');
|
||||
}
|
||||
|
||||
return self::ENTRY_LABELS[$relationKey] ?? Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
@ -62,6 +74,14 @@ public function actionLabel(string $relationKey): string
|
||||
return OperationRunLinks::openLabel();
|
||||
}
|
||||
|
||||
if ($relationKey === 'parent_policy') {
|
||||
return __('localization.policy.versions.related_action_view_policy');
|
||||
}
|
||||
|
||||
if (in_array($relationKey, ['current_policy_version', 'policy_version'], true)) {
|
||||
return __('localization.policy.versions.related_action_view_policy_version');
|
||||
}
|
||||
|
||||
return self::ACTION_LABELS[$relationKey] ?? 'Open '.Str::headline(str_replace('_', ' ', $relationKey));
|
||||
}
|
||||
|
||||
|
||||
@ -257,6 +257,7 @@ private static function canonicalDefinitions(): array
|
||||
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
|
||||
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
|
||||
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
|
||||
'promotion.execute' => new CanonicalOperationType('promotion.execute', 'platform_foundation', null, 'Promotion execution', true, 120),
|
||||
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
|
||||
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
|
||||
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
|
||||
@ -315,6 +316,7 @@ private static function operationAliases(): array
|
||||
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
|
||||
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
|
||||
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
|
||||
new OperationTypeAlias('promotion.execute', 'promotion.execute', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
|
||||
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
|
||||
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
|
||||
|
||||
@ -15,6 +15,7 @@ enum OperationRunType: string
|
||||
case BackupSchedulePurge = 'backup.schedule.purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory.role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case PromotionExecute = 'promotion.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case TenantReviewCompose = 'tenant.review.compose';
|
||||
|
||||
@ -17,6 +17,13 @@ final class OperationalControlCatalog
|
||||
'operation_types' => ['restore.execute'],
|
||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'key' => 'promotion.execute',
|
||||
'label' => 'Promotion execution',
|
||||
'supported_scopes' => ['global', 'workspace'],
|
||||
'operation_types' => ['promotion.execute'],
|
||||
'affected_surfaces' => ['admin.cross_tenant_compare.execute'],
|
||||
],
|
||||
'ai.execution' => [
|
||||
'key' => 'ai.execution',
|
||||
'label' => 'AI execution',
|
||||
|
||||
@ -25,7 +25,7 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory.groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup.schedule.execute', 'backup.schedule.retention', 'backup.schedule.purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory.role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
'alerts.evaluate', 'alerts.deliver' => Capabilities::ALERTS_VIEW,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_VIEW,
|
||||
@ -51,7 +51,7 @@ public function requiredExecutionCapabilityForType(string $operationType): ?stri
|
||||
'provider.connection.check', 'inventory.sync', 'compliance.snapshot' => Capabilities::PROVIDER_RUN,
|
||||
'policy.sync', 'tenant.sync' => Capabilities::TENANT_SYNC,
|
||||
'policy.delete' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'assignments.restore', 'restore.execute', 'promotion.execute' => Capabilities::TENANT_MANAGE,
|
||||
'tenant.review.compose' => Capabilities::TENANT_REVIEW_MANAGE,
|
||||
default => $this->requiredCapabilityForType($operationType),
|
||||
};
|
||||
|
||||
@ -23,6 +23,7 @@ public function __construct(
|
||||
public ?User $initiator,
|
||||
public ExecutionAuthorityMode $authorityMode,
|
||||
public ?string $requiredCapability,
|
||||
public ?string $workspaceRequiredCapability,
|
||||
public ?int $providerConnectionId,
|
||||
public array $targetScope,
|
||||
public array $prerequisiteClasses = [],
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\PortfolioCompare;
|
||||
|
||||
use DomainException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class CrossTenantPromotionExecutionPlanner
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $preview
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return array{
|
||||
* selection: array<string, mixed>,
|
||||
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
|
||||
* items: list<array<string, mixed>>,
|
||||
* excluded: list<array<string, mixed>>,
|
||||
* identity: array<string, mixed>
|
||||
* }
|
||||
*/
|
||||
public function build(array $preview, array $preflight): array
|
||||
{
|
||||
$previewSelection = $this->selection($preview);
|
||||
$preflightSelection = $this->selection($preflight);
|
||||
|
||||
if ($previewSelection !== $preflightSelection) {
|
||||
throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.');
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$excluded = $this->excludedSubjects($preflight);
|
||||
|
||||
foreach ($this->readySubjects($preflight) as $subject) {
|
||||
$item = $this->executionItem($subject);
|
||||
|
||||
if ($item === null) {
|
||||
$excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
$items = $this->sortItems($items);
|
||||
$excluded = $this->sortItems($excluded);
|
||||
|
||||
if ($items === []) {
|
||||
throw new DomainException('Promotion preflight has no executable ready subjects.');
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total' => count($items) + count($excluded),
|
||||
'ready' => count($items),
|
||||
'excluded' => count($excluded),
|
||||
'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')),
|
||||
'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')),
|
||||
'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')),
|
||||
];
|
||||
|
||||
return [
|
||||
'selection' => $previewSelection,
|
||||
'summary' => $summary,
|
||||
'items' => $items,
|
||||
'excluded' => $excluded,
|
||||
'identity' => $this->identity($previewSelection, $items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
|
||||
*/
|
||||
private function selection(array $payload): array
|
||||
{
|
||||
$selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : [];
|
||||
$policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : [];
|
||||
|
||||
$policyTypes = array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||
$policyTypes,
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
|
||||
sort($policyTypes);
|
||||
|
||||
return [
|
||||
'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null,
|
||||
'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null,
|
||||
'policyTypes' => $policyTypes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function readySubjects(array $preflight): array
|
||||
{
|
||||
$subjects = data_get($preflight, 'buckets.ready', []);
|
||||
|
||||
if (! is_array($subjects)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($subjects, 'is_array'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $preflight
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function excludedSubjects(array $preflight): array
|
||||
{
|
||||
$excluded = [];
|
||||
|
||||
foreach (['blocked', 'manual_mapping_required'] as $bucket) {
|
||||
$subjects = data_get($preflight, 'buckets.'.$bucket, []);
|
||||
|
||||
if (! is_array($subjects)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
if (! is_array($subject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$excluded[] = $this->excludedSubject($subject, $bucket);
|
||||
}
|
||||
}
|
||||
|
||||
return $excluded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function executionItem(array $subject): ?array
|
||||
{
|
||||
$policyVersionId = data_get($subject, 'source.evidence.policyVersionId');
|
||||
|
||||
if (! is_numeric($policyVersionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
|
||||
$action = match ($state) {
|
||||
'match' => 'skip_aligned',
|
||||
'missing' => 'create_missing',
|
||||
default => 'update_existing',
|
||||
};
|
||||
|
||||
return [
|
||||
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||
'compare_state' => $state,
|
||||
'execution_action' => $action,
|
||||
'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||
'source' => [
|
||||
'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')),
|
||||
'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')),
|
||||
'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')),
|
||||
'policy_version_id' => (int) $policyVersionId,
|
||||
'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')),
|
||||
],
|
||||
'target' => [
|
||||
'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')),
|
||||
'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')),
|
||||
'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function excludedSubject(array $subject, string $reason): array
|
||||
{
|
||||
return [
|
||||
'policy_type' => $this->stringValue($subject, 'policyType'),
|
||||
'display_name' => $this->stringValue($subject, 'displayName'),
|
||||
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
||||
'compare_state' => $this->stringValue($subject, 'state'),
|
||||
'excluded_reason' => $reason,
|
||||
'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortItems(array $items): array
|
||||
{
|
||||
usort($items, static function (array $left, array $right): int {
|
||||
return [
|
||||
(string) ($left['policy_type'] ?? ''),
|
||||
(string) ($left['subject_key'] ?? ''),
|
||||
(string) ($left['display_name'] ?? ''),
|
||||
] <=> [
|
||||
(string) ($right['policy_type'] ?? ''),
|
||||
(string) ($right['subject_key'] ?? ''),
|
||||
(string) ($right['display_name'] ?? ''),
|
||||
];
|
||||
});
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $selection
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function identity(array $selection, array $items): array
|
||||
{
|
||||
return [
|
||||
'source_tenant_id' => $selection['sourceTenantId'] ?? null,
|
||||
'target_tenant_id' => $selection['targetTenantId'] ?? null,
|
||||
'policy_types' => $selection['policyTypes'] ?? [],
|
||||
'subjects' => array_map(static fn (array $item): array => [
|
||||
'policy_type' => $item['policy_type'] ?? '',
|
||||
'subject_key' => $item['subject_key'] ?? '',
|
||||
'source_policy_version_id' => data_get($item, 'source.policy_version_id'),
|
||||
'source_evidence_hash' => data_get($item, 'source.evidence_hash'),
|
||||
'target_subject_external_id' => data_get($item, 'target.subject_external_id'),
|
||||
'execution_action' => $item['execution_action'] ?? '',
|
||||
], $items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $subject
|
||||
*/
|
||||
private function stringValue(array $subject, string $key): string
|
||||
{
|
||||
$value = $subject[$key] ?? null;
|
||||
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
|
||||
private function intValue(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function stringList(mixed $values): array
|
||||
{
|
||||
if (! is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
||||
$values,
|
||||
), static fn (string $value): bool => $value !== ''));
|
||||
}
|
||||
}
|
||||
@ -54,12 +54,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
|
||||
return $this->resolved(
|
||||
descriptor: $descriptor,
|
||||
primaryLabel: (string) ($policy->display_name ?: 'Policy'),
|
||||
secondaryLabel: 'Policy #'.$policy->getKey(),
|
||||
primaryLabel: (string) ($policy->display_name ?: __('localization.policy.versions.related_entry_policy')),
|
||||
secondaryLabel: __('localization.policy.versions.reference_policy_number', ['id' => $policy->getKey()]),
|
||||
linkTarget: new ReferenceLinkTarget(
|
||||
targetKind: ReferenceClass::Policy->value,
|
||||
url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant),
|
||||
actionLabel: 'View policy',
|
||||
actionLabel: __('localization.policy.versions.related_action_view_policy'),
|
||||
contextBadge: 'Tenant',
|
||||
),
|
||||
);
|
||||
|
||||
@ -53,7 +53,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
}
|
||||
|
||||
$policyName = $version->policy?->display_name;
|
||||
$secondary = 'Version '.(string) $version->version_number;
|
||||
$secondary = __('localization.policy.versions.reference_version_number', ['version' => (string) $version->version_number]);
|
||||
|
||||
if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') {
|
||||
$secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value);
|
||||
@ -61,12 +61,12 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
||||
|
||||
return $this->resolved(
|
||||
descriptor: $descriptor,
|
||||
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : 'Policy version',
|
||||
primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : __('localization.policy.versions.related_entry_policy_version'),
|
||||
secondaryLabel: $secondary,
|
||||
linkTarget: new ReferenceLinkTarget(
|
||||
targetKind: ReferenceClass::PolicyVersion->value,
|
||||
url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant),
|
||||
actionLabel: 'View policy version',
|
||||
actionLabel: __('localization.policy.versions.related_action_view_policy_version'),
|
||||
contextBadge: 'Tenant',
|
||||
),
|
||||
);
|
||||
|
||||
@ -92,6 +92,14 @@
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'promotion.execute' => [
|
||||
'job_class' => \App\Jobs\Operations\CrossTenantPromotionExecutionJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
'running_stale_after_seconds' => 1500,
|
||||
'expected_max_runtime_seconds' => 420,
|
||||
'direct_failed_bridge' => false,
|
||||
'scheduled_reconciliation' => true,
|
||||
],
|
||||
'tenant.review_pack.generate' => [
|
||||
'job_class' => \App\Jobs\GenerateReviewPackJob::class,
|
||||
'queued_stale_after_seconds' => 300,
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('policies') || Schema::hasColumn('policies', 'missing_from_provider_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policies', function (Blueprint $table): void {
|
||||
$table->timestamp('missing_from_provider_at')->nullable()->after('ignored_at');
|
||||
$table->index('missing_from_provider_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('policies') || ! Schema::hasColumn('policies', 'missing_from_provider_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('policies', function (Blueprint $table): void {
|
||||
$table->dropIndex(['missing_from_provider_at']);
|
||||
$table->dropColumn('missing_from_provider_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -129,20 +129,68 @@
|
||||
'reporting' => 'Berichte',
|
||||
'customer_reviews' => 'Kundenreviews',
|
||||
'customer_review_workspace' => 'Kundenreview-Workspace',
|
||||
'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace',
|
||||
'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
|
||||
'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.',
|
||||
'customer_safe_review_workspace' => 'Kundensicherer Governance-Paket-Index',
|
||||
'customer_workspace_intro' => 'Prüfen Sie für jeden berechtigten Tenant den executive-fähigen Status des Governance-Pakets und öffnen Sie bei Bedarf die kundensichere Detailansicht.',
|
||||
'customer_workspace_canonical_note' => 'Jede Zeile ist ein Einstieg in die Detailansicht: Dort sehen Sie Paketstatus, Executive-Einstieg, Nachweise, aktuelle Risiken und den nächsten kundensicheren Schritt.',
|
||||
'customer_workspace_mapping_version' => 'Die Control-Readiness-Interpretation verwendet :version für diesen Workspace.',
|
||||
'customer_workspace_non_certification_disclosure' => 'Dieser Workspace fasst die aktuelle Review- und Nachweislage für die Service-Auslieferung zusammen. Er ersetzt weder ein formales Auditurteil noch eine Zertifizierung oder rechtliche Attestierung.',
|
||||
'reviews' => 'Reviews',
|
||||
'clear_filters' => 'Filter löschen',
|
||||
'tenant' => 'Tenant',
|
||||
'latest_review' => 'Letztes Review',
|
||||
'review_status' => 'Review-Status',
|
||||
'status' => 'Status',
|
||||
'control' => 'Control',
|
||||
'control_interpretation' => 'Control-Readiness-Interpretation',
|
||||
'control_readiness' => 'Control-Readiness',
|
||||
'assessment_status' => 'Prüfstatus',
|
||||
'review_recommended' => 'Review empfohlen',
|
||||
'recommended_next_action' => 'Empfohlene nächste Aktion',
|
||||
'customer_safe' => 'Kundensicher',
|
||||
'interpretation_version_short' => 'Interpretationsversion: :version',
|
||||
'additional_controls' => '+:count weitere Control(s)',
|
||||
'control_limitations_summary' => 'Limitierungen: :limitations.',
|
||||
'control_readiness_unmapped' => 'Keine gemappten Controls',
|
||||
'control_readiness_unmapped_description' => 'In diesem veröffentlichten Review sind keine kanonischen Controls gemappt. Behandeln Sie die Control-Sicht als partiell, bis Evidence-Referenzen gemappt werden können.',
|
||||
'control_evidence_unmapped' => 'Keine gemappte Evidence-Basis verfügbar.',
|
||||
'control_evidence_unavailable' => 'Evidence-Basis nicht verfügbar.',
|
||||
'control_recommendation_unmapped' => 'Prüfen Sie unmapped Evidence vor der Kundenauslieferung.',
|
||||
'proof_access_state' => 'Proof-Zugriff',
|
||||
'key_findings' => 'Wichtige Findings',
|
||||
'accepted_risks' => 'Akzeptierte Risiken',
|
||||
'evidence_proof' => 'Evidence-Nachweis',
|
||||
'evidence_status' => 'Nachweise',
|
||||
'published' => 'Veröffentlicht',
|
||||
'review_pack' => 'Review-Pack',
|
||||
'open_latest_review' => 'Letztes Review öffnen',
|
||||
'open' => 'Öffnen',
|
||||
'open_review' => 'Review öffnen',
|
||||
'last_review' => 'Letztes Review',
|
||||
'primary_action' => 'Primäre Aktion',
|
||||
'download_review_pack' => 'Review-Pack herunterladen',
|
||||
'download_current_review_pack' => 'Aktuelles Review-Pack herunterladen',
|
||||
'download_governance_package' => 'Governance-Paket herunterladen',
|
||||
'governance_package' => 'Governance-Paket',
|
||||
'governance_decisions' => 'Governance-Entscheidungen',
|
||||
'governance_package_delivery_note' => 'Dieses Governance-Paket wird über das aktuelle Export-Review-Pack des veröffentlichten Reviews ausgeliefert.',
|
||||
'executive_entrypoint' => 'Executive-Einstieg',
|
||||
'executive_entrypoint_description' => 'Beginnen Sie im heruntergeladenen Paket mit executive-summary.md.',
|
||||
'auditor_appendix' => 'Strukturierter Auditor-Anhang',
|
||||
'auditor_appendix_description' => 'metadata.json, summary.json und sections.json bleiben als sekundärer strukturierter Anhang enthalten.',
|
||||
'governance_package_available' => 'Governance-Paket verfügbar',
|
||||
'governance_package_available_description' => 'Das aktuelle Export-Review-Pack ist aus diesem veröffentlichten Review für die Stakeholder-Auslieferung bereit.',
|
||||
'governance_package_partial' => 'Governance-Paket partiell',
|
||||
'governance_package_partial_description' => 'Das aktuelle Export-Review-Pack ist bereit, aber die zugrunde liegende Review-Basis bleibt partiell oder limitierungsbehaftet.',
|
||||
'governance_package_unavailable' => 'Governance-Paket nicht verfügbar',
|
||||
'governance_package_unavailable_description' => 'Diesem veröffentlichten Review ist noch kein aktuelles Export-Review-Pack zugeordnet.',
|
||||
'governance_package_not_ready_description' => 'Das aktuelle Export-Review-Pack ist für die Stakeholder-Auslieferung noch nicht bereit.',
|
||||
'governance_package_expired' => 'Governance-Paket abgelaufen',
|
||||
'governance_package_expired_description' => 'Das aktuelle Export-Review-Pack ist abgelaufen und kann aus diesem veröffentlichten Review nicht heruntergeladen werden.',
|
||||
'governance_package_blocked' => 'Governance-Paket blockiert',
|
||||
'governance_package_blocked_description' => 'Dieses Konto kann das veröffentlichte Review lesen, aber das aktuelle Export-Review-Pack nicht herunterladen.',
|
||||
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
|
||||
'no_released_customer_reviews' => 'Keine veröffentlichten Kundenreviews passen zu dieser Ansicht',
|
||||
'no_released_customer_reviews_description' => 'Veröffentlichen Sie ein Tenant-Review, bevor es im kundensicheren Workspace erscheint.',
|
||||
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
|
||||
'no_published_review' => 'Kein veröffentlichtes Review',
|
||||
@ -154,8 +202,45 @@
|
||||
'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).',
|
||||
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
|
||||
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
|
||||
'accepted_risk_accountable' => 'Verantwortlich: :name.',
|
||||
'accepted_risk_accountable_until' => 'Verantwortlich: :name. Erneute Prüfung bis :date.',
|
||||
'accepted_risk_reason' => 'Begründung: :reason.',
|
||||
'accepted_risk_partial_accountability' => 'Die Verantwortlichkeit ist teilweise erfasst; Review-Owner-Details sind nicht vollständig verfügbar.',
|
||||
'unavailable' => 'Nicht verfügbar',
|
||||
'available' => 'Verfügbar',
|
||||
'partial' => 'Teilweise',
|
||||
'blocked' => 'Blockiert',
|
||||
'expired' => 'Abgelaufen',
|
||||
'restricted' => 'Eingeschränkt',
|
||||
'review_pack_available' => 'Aktuelles Review-Pack verfügbar',
|
||||
'no_current_review_pack' => 'Noch kein aktuelles Review-Pack verfügbar',
|
||||
'review_pack_access_unavailable' => 'Review-Pack-Zugriff ist für dieses Konto nicht verfügbar',
|
||||
'review_pack_unavailable' => 'Review-Pack ist noch nicht bereit',
|
||||
'review_pack_expired' => 'Review-Pack abgelaufen',
|
||||
'evidence_proof_available' => 'Nachweiszusammenfassung verfügbar',
|
||||
'evidence_proof_absent' => 'Noch keine Nachweiszusammenfassung verknüpft',
|
||||
'evidence_proof_access_unavailable' => 'Nachweiszugriff ist für dieses Konto nicht verfügbar',
|
||||
'evidence_proof_expired' => 'Nachweiszusammenfassung abgelaufen',
|
||||
'evidence_available' => 'Nachweise verfügbar',
|
||||
'evidence_pending' => 'Nachweise ausstehend',
|
||||
'evidence_restricted' => 'Nachweise eingeschränkt',
|
||||
'evidence_expired' => 'Nachweise abgelaufen',
|
||||
'assessment_basis' => 'Prüfgrundlage',
|
||||
'assessment_basis_description' => 'Diese Prüfbereiche zeigen, wie die Aussagen des Pakets durch die aktuelle Review-Evidenz gestützt werden.',
|
||||
'review_completed' => 'Review abgeschlossen',
|
||||
'review_requires_attention' => 'Prüfung erforderlich',
|
||||
'ready_for_release' => 'Zur Veröffentlichung bereit',
|
||||
'accepted_risk_status' => 'Status akzeptierter Risiken',
|
||||
'accepted_risk_none' => 'Keine erfasst',
|
||||
'accepted_risk_on_record' => ':count erfasst',
|
||||
'accepted_risk_follow_up' => 'Nacharbeit erforderlich',
|
||||
'customer_review_pack_unavailable' => 'Das aktuelle Review-Pack kann aus diesem kundensicheren Flow nicht heruntergeladen werden.',
|
||||
'customer_review_pack_missing' => 'Diesem veröffentlichten Review ist noch kein aktuelles Review-Pack zugeordnet.',
|
||||
'customer_review_pack_not_ready' => 'Das zugeordnete Review-Pack ist noch nicht für den Download bereit.',
|
||||
'customer_review_pack_expired' => 'Das zugeordnete Review-Pack ist abgelaufen.',
|
||||
'customer_review_pack_forbidden' => 'Dieses Konto kann das Review lesen, aber das aktuelle Review-Pack nicht herunterladen.',
|
||||
'released_governance_record' => 'Veröffentlichter Governance-Nachweis',
|
||||
'released_governance_record_available' => 'Dieses veröffentlichte Review ist für kundensichere Governance-Nutzung verfügbar.',
|
||||
'outcome_summary' => 'Ergebniszusammenfassung',
|
||||
'review' => 'Review',
|
||||
'review_date' => 'Review-Datum',
|
||||
@ -169,6 +254,10 @@
|
||||
'outcome' => 'Ergebnis',
|
||||
'export' => 'Export',
|
||||
'next_step' => 'Nächster Schritt',
|
||||
'workspace_next_step_evidence_review' => 'Nachweise prüfen',
|
||||
'workspace_next_step_review_open' => 'Review öffnen',
|
||||
'workspace_next_step_package_review' => 'Paket prüfen',
|
||||
'workspace_next_step_control_mapping' => 'Kontrollzuordnung prüfen',
|
||||
'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews',
|
||||
'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.',
|
||||
'create_first_review' => 'Erstes Review erstellen',
|
||||
@ -240,6 +329,188 @@
|
||||
'actions' => 'Aktionen',
|
||||
'open_approval_queue' => 'Freigabewarteschlange öffnen',
|
||||
],
|
||||
'policy' => [
|
||||
'common' => [
|
||||
'policy' => 'Richtlinie',
|
||||
'policies' => 'Richtlinien',
|
||||
'type' => 'Typ',
|
||||
'visibility' => 'Sichtbarkeit',
|
||||
'category' => 'Kategorie',
|
||||
'restore' => 'Wiederherstellen',
|
||||
'platform' => 'Plattform',
|
||||
'settings' => 'Einstellungen',
|
||||
'external_id' => 'Externe ID',
|
||||
'last_synced' => 'Zuletzt synchronisiert',
|
||||
'snapshot' => 'Snapshot',
|
||||
'version' => 'Version',
|
||||
'actor' => 'Akteur',
|
||||
'created' => 'Erstellt',
|
||||
'captured' => 'Erfasst',
|
||||
'platform_label_windows' => 'Windows',
|
||||
'platform_label_android' => 'Android',
|
||||
'platform_label_ios' => 'iOS',
|
||||
'platform_label_macos' => 'macOS',
|
||||
'platform_label_all' => 'Alle',
|
||||
'platform_label_mobile' => 'Mobil',
|
||||
'open_operation' => 'Operation öffnen',
|
||||
'more' => 'Mehr',
|
||||
'backup_name' => 'Backup-Name',
|
||||
'backup_name_default_prefix' => 'Backup',
|
||||
'source_microsoft_intune' => 'Quelle: Microsoft Intune',
|
||||
'type_delete_to_confirm' => 'Zur Bestätigung DELETE eingeben',
|
||||
'type_delete_to_confirm_validation' => 'Bitte DELETE zur Bestätigung eingeben.',
|
||||
'preview_only_dry_run' => 'Nur Vorschau (Dry-Run)',
|
||||
],
|
||||
'resource' => [
|
||||
'sync_action_primary' => 'Richtlinien synchronisieren',
|
||||
'sync_action_secondary' => 'Synchronisieren',
|
||||
'sync_modal_heading' => 'Richtlinien-Inventar synchronisieren',
|
||||
'sync_modal_description' => 'Diese Aktion reiht eine Hintergrundssynchronisierung für unterstützte Richtlinientypen im aktuellen Tenant ein.',
|
||||
'sync_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu synchronisieren.',
|
||||
'capture_snapshot_action' => 'Snapshot erfassen',
|
||||
'capture_snapshot_modal_heading' => 'Snapshot jetzt erfassen',
|
||||
'capture_snapshot_modal_subheading' => 'Diese Aktion reiht einen Hintergrundjob ein, der die aktuelle Konfiguration aus Microsoft Graph abruft und eine neue Richtlinienversion speichert.',
|
||||
'capture_snapshot_include_assignments' => 'Zuweisungen einschließen',
|
||||
'capture_snapshot_include_assignments_helper' => 'Erfasst Include-/Exclude-Ziele und Filter für Zuweisungen.',
|
||||
'capture_snapshot_include_scope_tags' => 'Scope-Tags einschließen',
|
||||
'capture_snapshot_include_scope_tags_helper' => 'Erfasst die Scope-Tag-IDs der Richtlinie.',
|
||||
'capture_snapshot_unavailable_title' => 'Snapshot-Erfassung nicht verfügbar',
|
||||
'capture_snapshot_in_progress_title' => 'Snapshot bereits in Arbeit',
|
||||
'capture_snapshot_in_progress_body' => 'Für diese Richtlinie existiert bereits ein aktiver Lauf. Laufdetails werden geöffnet.',
|
||||
'capture_snapshot_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien-Snapshots zu erfassen.',
|
||||
'visibility_source_unavailable_description' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Die historische Wiederherstellung bleibt verfügbar.',
|
||||
'visibility_source_unavailable_backup_items' => 'Die verbundene Quelle hat diese Richtlinie nicht geliefert oder ist aktuell nicht verfügbar. Historische Backup-Items bleiben für die Wiederherstellungsauswahl verfügbar.',
|
||||
'details_section' => 'Richtliniendetails',
|
||||
'tab_general' => 'Allgemein',
|
||||
'tab_json' => 'JSON',
|
||||
'general_field_name' => 'Name',
|
||||
'general_field_platforms' => 'Plattformen',
|
||||
'general_field_technologies' => 'Technologien',
|
||||
'general_field_template_reference' => 'Vorlagenreferenz',
|
||||
'general_field_setting_count' => 'Anzahl Einstellungen',
|
||||
'general_field_version' => 'Version',
|
||||
'general_field_last_modified' => 'Zuletzt geändert',
|
||||
'general_field_created' => 'Erstellt',
|
||||
'general_field_description' => 'Beschreibung',
|
||||
'general_empty_state' => 'Keine allgemeinen Metadaten verfügbar.',
|
||||
'general_fallback_field' => 'Feld',
|
||||
'template_fallback' => 'Vorlage',
|
||||
'settings_empty_state' => 'Noch kein Richtlinien-Snapshot verfügbar.',
|
||||
'settings_empty_state_helper' => 'Diese Richtlinie wurde inventarisiert, aber es wurde noch kein Konfigurations-Snapshot erfasst.',
|
||||
'snapshot_metadata_only_helper' => 'Graph lieferte für diesen Richtlinientyp :status zurück. Es wurden nur lokale Metadaten gespeichert; Einstellungen und Wiederherstellung sind erst verfügbar, wenn Graph wieder erfolgreich antwortet.',
|
||||
'graph_error_fallback' => 'einen Fehler',
|
||||
'snapshot_json_section' => 'Richtlinien-Snapshot (JSON)',
|
||||
'payload_size' => 'Payload-Größe',
|
||||
'large_payload_warning' => 'Großer Payload (:size KB) - kann die Performance beeinträchtigen',
|
||||
'settings_available' => 'Verfügbar',
|
||||
'settings_missing' => 'Fehlt',
|
||||
'filter_active' => 'Aktiv',
|
||||
'filter_ignored' => 'Lokal ignoriert',
|
||||
'filter_source_unavailable' => 'Quelle nicht verfügbar',
|
||||
'filter_all' => 'Alle',
|
||||
'export_to_backup' => 'Ins Backup exportieren',
|
||||
'current_backup_unavailable' => 'Aktuelles Backup nicht verfügbar',
|
||||
'restore_action' => 'Wiederherstellen',
|
||||
'restore_bulk_action' => 'Richtlinien wiederherstellen',
|
||||
'restore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien wiederherzustellen.',
|
||||
'policy_restored' => 'Richtlinie wiederhergestellt',
|
||||
'ignore_action' => 'Ignorieren',
|
||||
'ignore_bulk_action' => 'Richtlinien ignorieren',
|
||||
'ignore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu ignorieren.',
|
||||
'policy_ignored' => 'Richtlinie ignoriert',
|
||||
'empty_state_heading' => 'Noch keine Richtlinien im Inventory',
|
||||
'empty_state_description' => 'Starte eine Synchronisierung, um das Richtlinien-Inventar dieses Tenants mit Versionen, Wiederherstellbarkeit und Governance-Evidence aufzubauen.',
|
||||
'delete_queued_body' => 'Löschung für :count Richtlinien eingeplant.',
|
||||
],
|
||||
'versions' => [
|
||||
'backup_quality_section' => 'Backup-Qualität',
|
||||
'related_context_section' => 'Zugehöriger Kontext',
|
||||
'diff_tab' => 'Diff',
|
||||
'backup_quality' => 'Backup-Qualität',
|
||||
'snapshot_mode_full' => 'Vollständig',
|
||||
'snapshot_mode_metadata_only' => 'Nur Metadaten',
|
||||
'assignment_quality' => 'Zuweisungsqualität',
|
||||
'next_action' => 'Nächste Aktion',
|
||||
'integrity_note' => 'Integritätshinweis',
|
||||
'boundary' => 'Abgrenzung',
|
||||
'quality_highlight_metadata_only' => 'Nur Metadaten',
|
||||
'quality_highlight_assignment_fetch_failed' => 'Abruf der Zuweisungen fehlgeschlagen',
|
||||
'quality_highlight_assignments_captured_separately' => 'Zuweisungen separat erfasst',
|
||||
'quality_highlight_orphaned_assignments' => 'Verwaiste Zuweisungen erkannt',
|
||||
'quality_highlight_integrity_warning' => 'Integritätswarnung',
|
||||
'quality_highlight_unknown_quality' => 'Unbekannte Qualität',
|
||||
'compact_summary_full_payload' => 'Vollständige Nutzlast',
|
||||
'compact_summary_unknown_quality' => 'Unbekannte Qualität',
|
||||
'compact_summary_no_degradations_detected' => 'Keine Degradationen erkannt',
|
||||
'summary_full_no_degradations' => 'In Snapshot und Zuweisungsmetadaten wurden keine Degradationen erkannt.',
|
||||
'summary_unknown_quality' => 'Die Qualität ist unbekannt, weil diesem Datensatz ausreichende Vollständigkeitsmetadaten für eine stärkere Aussage fehlen.',
|
||||
'summary_no_degradations' => 'Es wurden keine Degradationen erkannt.',
|
||||
'next_action_open_version_detail' => 'Öffne die Versionsdetails, wenn du Roh-Einstellungen oder Diff-Kontext brauchst.',
|
||||
'next_action_prefer_stronger_version' => 'Bevorzuge eine stärkere Version oder prüfe die Versionsdetails vor der Wiederherstellung.',
|
||||
'raw_diff_advanced' => 'Rohdiff (erweitert)',
|
||||
'prune_versions' => 'Versionen bereinigen',
|
||||
'prune_modal_description' => 'Nur Versionen, die älter als das angegebene Aufbewahrungsfenster in Tagen sind, kommen infrage. Neuere Versionen werden übersprungen.',
|
||||
'retention_days' => 'Aufbewahrungstage',
|
||||
'retention_days_helper' => 'Versionen aus den letzten N Tagen werden übersprungen.',
|
||||
'manage_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinienversionen zu verwalten.',
|
||||
'restore_versions' => 'Versionen wiederherstellen',
|
||||
'restore_versions_modal_heading' => ':count Richtlinienversionen wiederherstellen?',
|
||||
'restore_versions_modal_description' => 'Archivierte Versionen werden in die aktive Liste zurückgeführt. Aktive Versionen werden übersprungen.',
|
||||
'force_delete_versions' => 'Versionen endgültig löschen',
|
||||
'force_delete_versions_modal_heading' => ':count Richtlinienversionen endgültig löschen?',
|
||||
'force_delete_versions_modal_description' => 'Dies ist endgültig. Nur archivierte Versionen werden dauerhaft gelöscht; aktive Versionen werden übersprungen.',
|
||||
'restore_via_wizard' => 'Über Assistent wiederherstellen',
|
||||
'restore_via_wizard_modal_heading' => 'Version :version über Assistent wiederherstellen?',
|
||||
'restore_via_wizard_modal_subheading' => 'Erstellt aus diesem Snapshot ein Backup-Set mit einem Element und öffnet den Wiederherstellungsassistenten vorausgefüllt.',
|
||||
'restore_run_permission_tooltip' => 'Sie haben keine Berechtigung, Wiederherstellungsläufe zu erstellen.',
|
||||
'metadata_only_tooltip' => 'Für reine Metadaten-Snapshots deaktiviert (Graph hat keine Richtlinieneinstellungen geliefert).',
|
||||
'restore_disabled_metadata_title' => 'Wiederherstellung für reinen Metadaten-Snapshot deaktiviert',
|
||||
'restore_disabled_metadata_body' => 'Dieser Snapshot enthält nur Metadaten; Graph hat keine Richtlinieneinstellungen für eine Wiederherstellung geliefert.',
|
||||
'different_tenant_title' => 'Richtlinienversion gehört zu einem anderen Tenant',
|
||||
'missing_policy_title' => 'Richtlinie für diese Version konnte nicht gefunden werden',
|
||||
'backup_set_name' => 'Richtlinienversions-Wiederherstellung - :policy - v:version',
|
||||
'archive' => 'Archivieren',
|
||||
'archived_title' => 'Richtlinienversion archiviert',
|
||||
'force_delete' => 'Endgültig löschen',
|
||||
'force_deleted_title' => 'Richtlinienversion dauerhaft gelöscht',
|
||||
'restored_title' => 'Richtlinienversion wiederhergestellt',
|
||||
'empty_state_heading' => 'Noch keine Richtlinienversionen',
|
||||
'empty_state_description' => 'Erfasse oder synchronisiere Richtlinien-Snapshots, um eine Versionshistorie aufzubauen.',
|
||||
'open_backup_sets' => 'Backup-Sets öffnen',
|
||||
'related_entry_current_policy_version' => 'Aktuelle Richtlinienversion',
|
||||
'related_entry_policy' => 'Richtlinie',
|
||||
'related_entry_policy_version' => 'Richtlinienversion',
|
||||
'related_action_view_policy' => 'Richtlinie anzeigen',
|
||||
'related_action_view_policy_version' => 'Richtlinienversion anzeigen',
|
||||
'reference_policy_number' => 'Richtlinie #:id',
|
||||
'reference_version_number' => 'Version :version',
|
||||
'related_record_fallback' => 'Zugehörigen Datensatz öffnen',
|
||||
'assignment_fetch_failed_orphaned' => 'Das Abrufen der Zuweisungen ist fehlgeschlagen und verwaiste Ziele wurden erkannt.',
|
||||
'assignment_fetch_failed' => 'Das Abrufen der Zuweisungen ist während der Erfassung fehlgeschlagen.',
|
||||
'assignment_orphaned' => 'Verwaiste Zuweisungsziele wurden erkannt.',
|
||||
'assignment_no_issues' => 'Aus den erfassten Metadaten wurden keine Zuweisungsprobleme erkannt.',
|
||||
'fallback_display_name' => 'Version :version',
|
||||
],
|
||||
'relation' => [
|
||||
'restore_to_microsoft_intune' => 'In Microsoft Intune wiederherstellen',
|
||||
'restore_heading' => 'Version :version in Microsoft Intune wiederherstellen?',
|
||||
'restore_subheading' => 'Erstellt einen Wiederherstellungslauf mit diesem Richtlinienversions-Snapshot.',
|
||||
'missing_context_title' => 'Tenant- oder Benutzerkontext fehlt.',
|
||||
'restore_run_failed_title' => 'Wiederherstellungslauf konnte nicht gestartet werden',
|
||||
'restore_run_started_title' => 'Wiederherstellungslauf gestartet',
|
||||
'no_versions_captured' => 'Noch keine Versionen erfasst',
|
||||
'no_versions_captured_description' => 'Erfasse oder synchronisiere diese Richtlinie erneut, um Versionshistorieneinträge zu erzeugen.',
|
||||
],
|
||||
'badges' => [
|
||||
'active' => 'Aktiv',
|
||||
'ignored_locally' => 'Lokal ignoriert',
|
||||
'source_unavailable' => 'Quelle nicht verfügbar',
|
||||
'ignored_source_unavailable' => 'Ignoriert + Quelle nicht verfügbar',
|
||||
],
|
||||
'taxonomy' => [
|
||||
'policies' => 'Richtlinien',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
|
||||
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
|
||||
|
||||
@ -129,20 +129,68 @@
|
||||
'reporting' => 'Reporting',
|
||||
'customer_reviews' => 'Customer reviews',
|
||||
'customer_review_workspace' => 'Customer Review Workspace',
|
||||
'customer_safe_review_workspace' => 'Customer-safe review workspace',
|
||||
'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.',
|
||||
'customer_workspace_canonical_note' => 'Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.',
|
||||
'customer_safe_review_workspace' => 'Customer-safe governance package index',
|
||||
'customer_workspace_intro' => 'Review the executive-ready governance package status for each entitled tenant and open the customer-safe detail when follow-up is needed.',
|
||||
'customer_workspace_canonical_note' => 'Each row is an index entry: open the review detail to inspect package status, the executive entrypoint, supporting evidence, current risks, and the next customer-safe action.',
|
||||
'customer_workspace_mapping_version' => 'Control readiness interpretation uses :version for this workspace.',
|
||||
'customer_workspace_non_certification_disclosure' => 'This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.',
|
||||
'reviews' => 'Reviews',
|
||||
'clear_filters' => 'Clear filters',
|
||||
'tenant' => 'Tenant',
|
||||
'latest_review' => 'Latest review',
|
||||
'review_status' => 'Review status',
|
||||
'status' => 'Status',
|
||||
'control' => 'Control',
|
||||
'control_interpretation' => 'Control readiness interpretation',
|
||||
'control_readiness' => 'Control readiness',
|
||||
'assessment_status' => 'Assessment status',
|
||||
'review_recommended' => 'Review recommended',
|
||||
'recommended_next_action' => 'Recommended next action',
|
||||
'customer_safe' => 'Customer-safe',
|
||||
'interpretation_version_short' => 'Interpretation version: :version',
|
||||
'additional_controls' => '+:count more control(s)',
|
||||
'control_limitations_summary' => 'Limitations: :limitations.',
|
||||
'control_readiness_unmapped' => 'No mapped controls',
|
||||
'control_readiness_unmapped_description' => 'No canonical controls are mapped in this released review. Treat the control view as partial until evidence references can be mapped.',
|
||||
'control_evidence_unmapped' => 'No mapped evidence basis is available.',
|
||||
'control_evidence_unavailable' => 'Evidence basis unavailable.',
|
||||
'control_recommendation_unmapped' => 'Review unmapped evidence before customer delivery.',
|
||||
'proof_access_state' => 'Proof access',
|
||||
'key_findings' => 'Key findings',
|
||||
'accepted_risks' => 'Accepted risks',
|
||||
'evidence_proof' => 'Evidence proof',
|
||||
'evidence_status' => 'Evidence',
|
||||
'published' => 'Published',
|
||||
'review_pack' => 'Review pack',
|
||||
'open_latest_review' => 'Open latest review',
|
||||
'open' => 'Open',
|
||||
'open_review' => 'Open review',
|
||||
'last_review' => 'Last review',
|
||||
'primary_action' => 'Primary action',
|
||||
'download_review_pack' => 'Download review pack',
|
||||
'download_current_review_pack' => 'Download current review pack',
|
||||
'download_governance_package' => 'Download governance package',
|
||||
'governance_package' => 'Governance package',
|
||||
'governance_decisions' => 'Governance decisions',
|
||||
'governance_package_delivery_note' => 'This governance package is delivered through the current export review pack for the released review.',
|
||||
'executive_entrypoint' => 'Executive entrypoint',
|
||||
'executive_entrypoint_description' => 'Start with executive-summary.md in the downloaded package.',
|
||||
'auditor_appendix' => 'Structured auditor appendix',
|
||||
'auditor_appendix_description' => 'metadata.json, summary.json, and sections.json remain included as the secondary structured appendix.',
|
||||
'governance_package_available' => 'Governance package available',
|
||||
'governance_package_available_description' => 'The current export review pack is ready for stakeholder delivery from this released review.',
|
||||
'governance_package_partial' => 'Governance package partial',
|
||||
'governance_package_partial_description' => 'The current export review pack is ready, but the supporting review basis remains partial or limitation-aware.',
|
||||
'governance_package_unavailable' => 'Governance package unavailable',
|
||||
'governance_package_unavailable_description' => 'No current export review pack is attached to this released review yet.',
|
||||
'governance_package_not_ready_description' => 'The current export review pack is not ready for stakeholder delivery yet.',
|
||||
'governance_package_expired' => 'Governance package expired',
|
||||
'governance_package_expired_description' => 'The current export review pack has expired and cannot be downloaded from this released review.',
|
||||
'governance_package_blocked' => 'Governance package blocked',
|
||||
'governance_package_blocked_description' => 'This account can read the released review but cannot download the current export review pack.',
|
||||
'no_entitled_tenants' => 'No entitled tenants match this view',
|
||||
'no_released_customer_reviews' => 'No released customer reviews match this view',
|
||||
'no_released_customer_reviews_description' => 'Publish a tenant review before it appears in the customer-safe workspace.',
|
||||
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
|
||||
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
|
||||
'no_published_review' => 'No published review',
|
||||
@ -154,8 +202,45 @@
|
||||
'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).',
|
||||
'accepted_risks_governed' => ':count accepted risks are governed.',
|
||||
'accepted_risks_on_record' => ':count accepted risks are on record.',
|
||||
'accepted_risk_accountable' => 'Accountable: :name.',
|
||||
'accepted_risk_accountable_until' => 'Accountable: :name. Re-review by :date.',
|
||||
'accepted_risk_reason' => 'Reason: :reason.',
|
||||
'accepted_risk_partial_accountability' => 'Accountability is partially recorded; review owner details are not fully available.',
|
||||
'unavailable' => 'Unavailable',
|
||||
'available' => 'Available',
|
||||
'partial' => 'Partial',
|
||||
'blocked' => 'Blocked',
|
||||
'expired' => 'Expired',
|
||||
'restricted' => 'Restricted',
|
||||
'review_pack_available' => 'Current review pack available',
|
||||
'no_current_review_pack' => 'No current review pack available yet',
|
||||
'review_pack_access_unavailable' => 'Review pack access is unavailable for this actor',
|
||||
'review_pack_unavailable' => 'Review pack is not ready yet',
|
||||
'review_pack_expired' => 'Review pack expired',
|
||||
'evidence_proof_available' => 'Proof summary available',
|
||||
'evidence_proof_absent' => 'No proof summary linked yet',
|
||||
'evidence_proof_access_unavailable' => 'Proof access is unavailable for this actor',
|
||||
'evidence_proof_expired' => 'Proof summary expired',
|
||||
'evidence_available' => 'Evidence available',
|
||||
'evidence_pending' => 'Evidence pending',
|
||||
'evidence_restricted' => 'Evidence restricted',
|
||||
'evidence_expired' => 'Evidence expired',
|
||||
'assessment_basis' => 'Assessment basis',
|
||||
'assessment_basis_description' => 'These assessment areas explain how the package statements are supported by the current review evidence.',
|
||||
'review_completed' => 'Review completed',
|
||||
'review_requires_attention' => 'Review required',
|
||||
'ready_for_release' => 'Ready for release',
|
||||
'accepted_risk_status' => 'Accepted risk status',
|
||||
'accepted_risk_none' => 'None on record',
|
||||
'accepted_risk_on_record' => ':count on record',
|
||||
'accepted_risk_follow_up' => 'Follow-up required',
|
||||
'customer_review_pack_unavailable' => 'The current review pack cannot be downloaded from this customer-safe flow.',
|
||||
'customer_review_pack_missing' => 'No current review pack is attached to this released review yet.',
|
||||
'customer_review_pack_not_ready' => 'The attached review pack is not ready for download yet.',
|
||||
'customer_review_pack_expired' => 'The attached review pack has expired.',
|
||||
'customer_review_pack_forbidden' => 'This account can read the review but cannot download the current review pack.',
|
||||
'released_governance_record' => 'Released governance record',
|
||||
'released_governance_record_available' => 'This released review is available for customer-safe governance consumption.',
|
||||
'outcome_summary' => 'Outcome summary',
|
||||
'review' => 'Review',
|
||||
'review_date' => 'Review date',
|
||||
@ -169,6 +254,10 @@
|
||||
'outcome' => 'Outcome',
|
||||
'export' => 'Export',
|
||||
'next_step' => 'Next step',
|
||||
'workspace_next_step_evidence_review' => 'Review evidence',
|
||||
'workspace_next_step_review_open' => 'Open review',
|
||||
'workspace_next_step_package_review' => 'Review package',
|
||||
'workspace_next_step_control_mapping' => 'Review control mapping',
|
||||
'no_tenant_reviews_yet' => 'No tenant reviews yet',
|
||||
'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.',
|
||||
'create_first_review' => 'Create first review',
|
||||
@ -240,6 +329,188 @@
|
||||
'actions' => 'Actions',
|
||||
'open_approval_queue' => 'Open approval queue',
|
||||
],
|
||||
'policy' => [
|
||||
'common' => [
|
||||
'policy' => 'Policy',
|
||||
'policies' => 'Policies',
|
||||
'type' => 'Type',
|
||||
'visibility' => 'Visibility',
|
||||
'category' => 'Category',
|
||||
'restore' => 'Restore',
|
||||
'platform' => 'Platform',
|
||||
'settings' => 'Settings',
|
||||
'external_id' => 'External ID',
|
||||
'last_synced' => 'Last synced',
|
||||
'snapshot' => 'Snapshot',
|
||||
'version' => 'Version',
|
||||
'actor' => 'Actor',
|
||||
'created' => 'Created',
|
||||
'captured' => 'Captured',
|
||||
'platform_label_windows' => 'Windows',
|
||||
'platform_label_android' => 'Android',
|
||||
'platform_label_ios' => 'iOS',
|
||||
'platform_label_macos' => 'macOS',
|
||||
'platform_label_all' => 'All',
|
||||
'platform_label_mobile' => 'Mobile',
|
||||
'open_operation' => 'Open operation',
|
||||
'more' => 'More',
|
||||
'backup_name' => 'Backup name',
|
||||
'backup_name_default_prefix' => 'Backup',
|
||||
'source_microsoft_intune' => 'Source: Microsoft Intune',
|
||||
'type_delete_to_confirm' => 'Type DELETE to confirm',
|
||||
'type_delete_to_confirm_validation' => 'Please type DELETE to confirm.',
|
||||
'preview_only_dry_run' => 'Preview only (dry-run)',
|
||||
],
|
||||
'resource' => [
|
||||
'sync_action_primary' => 'Sync policies',
|
||||
'sync_action_secondary' => 'Sync',
|
||||
'sync_modal_heading' => 'Sync policy inventory',
|
||||
'sync_modal_description' => 'This queues a background sync operation for supported policy types in the current tenant.',
|
||||
'sync_permission_tooltip' => 'You do not have permission to sync policies.',
|
||||
'capture_snapshot_action' => 'Capture snapshot',
|
||||
'capture_snapshot_modal_heading' => 'Capture snapshot now',
|
||||
'capture_snapshot_modal_subheading' => 'This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.',
|
||||
'capture_snapshot_include_assignments' => 'Include assignments',
|
||||
'capture_snapshot_include_assignments_helper' => 'Captures assignment include/exclude targeting and filters.',
|
||||
'capture_snapshot_include_scope_tags' => 'Include scope tags',
|
||||
'capture_snapshot_include_scope_tags_helper' => 'Captures policy scope tag IDs.',
|
||||
'capture_snapshot_unavailable_title' => 'Snapshot capture unavailable',
|
||||
'capture_snapshot_in_progress_title' => 'Snapshot already in progress',
|
||||
'capture_snapshot_in_progress_body' => 'An active run already exists for this policy. Opening run details.',
|
||||
'capture_snapshot_permission_tooltip' => 'You do not have permission to capture policy snapshots.',
|
||||
'visibility_source_unavailable_description' => 'The connected source did not return this policy or is currently unavailable. Historical restore remains available.',
|
||||
'visibility_source_unavailable_backup_items' => 'The connected source did not return this policy or is currently unavailable. Historical backup items remain available for restore selection.',
|
||||
'details_section' => 'Policy details',
|
||||
'tab_general' => 'General',
|
||||
'tab_json' => 'JSON',
|
||||
'general_field_name' => 'Name',
|
||||
'general_field_platforms' => 'Platforms',
|
||||
'general_field_technologies' => 'Technologies',
|
||||
'general_field_template_reference' => 'Template reference',
|
||||
'general_field_setting_count' => 'Setting count',
|
||||
'general_field_version' => 'Version',
|
||||
'general_field_last_modified' => 'Last modified',
|
||||
'general_field_created' => 'Created',
|
||||
'general_field_description' => 'Description',
|
||||
'general_empty_state' => 'No general metadata available.',
|
||||
'general_fallback_field' => 'Field',
|
||||
'template_fallback' => 'Template',
|
||||
'settings_empty_state' => 'No policy snapshot available yet.',
|
||||
'settings_empty_state_helper' => 'This policy has been inventoried but no configuration snapshot has been captured yet.',
|
||||
'snapshot_metadata_only_helper' => 'Graph returned :status for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.',
|
||||
'graph_error_fallback' => 'an error',
|
||||
'snapshot_json_section' => 'Policy snapshot (JSON)',
|
||||
'payload_size' => 'Payload size',
|
||||
'large_payload_warning' => 'Large payload (:size KB) - may impact performance',
|
||||
'settings_available' => 'Available',
|
||||
'settings_missing' => 'Missing',
|
||||
'filter_active' => 'Active',
|
||||
'filter_ignored' => 'Ignored locally',
|
||||
'filter_source_unavailable' => 'Source unavailable',
|
||||
'filter_all' => 'All',
|
||||
'export_to_backup' => 'Export to backup',
|
||||
'current_backup_unavailable' => 'Current backup unavailable',
|
||||
'restore_action' => 'Restore',
|
||||
'restore_bulk_action' => 'Restore policies',
|
||||
'restore_permission_tooltip' => 'You do not have permission to restore policies.',
|
||||
'policy_restored' => 'Policy restored',
|
||||
'ignore_action' => 'Ignore',
|
||||
'ignore_bulk_action' => 'Ignore policies',
|
||||
'ignore_permission_tooltip' => 'You do not have permission to ignore policies.',
|
||||
'policy_ignored' => 'Policy ignored',
|
||||
'empty_state_heading' => 'No policies in inventory yet',
|
||||
'empty_state_description' => 'Run a sync to build this tenant\'s policy inventory, including versions, restore readiness, and governance evidence.',
|
||||
'delete_queued_body' => 'Queued deletion for :count policies.',
|
||||
],
|
||||
'versions' => [
|
||||
'backup_quality_section' => 'Backup quality',
|
||||
'related_context_section' => 'Related context',
|
||||
'diff_tab' => 'Diff',
|
||||
'backup_quality' => 'Backup quality',
|
||||
'snapshot_mode_full' => 'Full',
|
||||
'snapshot_mode_metadata_only' => 'Metadata only',
|
||||
'assignment_quality' => 'Assignment quality',
|
||||
'next_action' => 'Next action',
|
||||
'integrity_note' => 'Integrity note',
|
||||
'boundary' => 'Boundary',
|
||||
'quality_highlight_metadata_only' => 'Metadata only',
|
||||
'quality_highlight_assignment_fetch_failed' => 'Assignment fetch failed',
|
||||
'quality_highlight_assignments_captured_separately' => 'Assignments captured separately',
|
||||
'quality_highlight_orphaned_assignments' => 'Orphaned assignments',
|
||||
'quality_highlight_integrity_warning' => 'Integrity warning',
|
||||
'quality_highlight_unknown_quality' => 'Unknown quality',
|
||||
'compact_summary_full_payload' => 'Full payload',
|
||||
'compact_summary_unknown_quality' => 'Unknown quality',
|
||||
'compact_summary_no_degradations_detected' => 'No degradations detected',
|
||||
'summary_full_no_degradations' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
||||
'summary_unknown_quality' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
||||
'summary_no_degradations' => 'No degradations were detected.',
|
||||
'next_action_open_version_detail' => 'Open the version detail if you need raw settings or diff context.',
|
||||
'next_action_prefer_stronger_version' => 'Prefer a stronger version or inspect the version detail before restore.',
|
||||
'raw_diff_advanced' => 'Raw diff (advanced)',
|
||||
'prune_versions' => 'Prune versions',
|
||||
'prune_modal_description' => 'Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.',
|
||||
'retention_days' => 'Retention days',
|
||||
'retention_days_helper' => 'Versions captured within the last N days will be skipped.',
|
||||
'manage_permission_tooltip' => 'You do not have permission to manage policy versions.',
|
||||
'restore_versions' => 'Restore versions',
|
||||
'restore_versions_modal_heading' => 'Restore :count policy versions?',
|
||||
'restore_versions_modal_description' => 'Archived versions will be restored back to the active list. Active versions will be skipped.',
|
||||
'force_delete_versions' => 'Force delete versions',
|
||||
'force_delete_versions_modal_heading' => 'Force delete :count policy versions?',
|
||||
'force_delete_versions_modal_description' => 'This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.',
|
||||
'restore_via_wizard' => 'Restore via wizard',
|
||||
'restore_via_wizard_modal_heading' => 'Restore version :version via wizard?',
|
||||
'restore_via_wizard_modal_subheading' => 'Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.',
|
||||
'restore_run_permission_tooltip' => 'You do not have permission to create restore runs.',
|
||||
'metadata_only_tooltip' => 'Disabled for metadata-only snapshots (Graph did not provide policy settings).',
|
||||
'restore_disabled_metadata_title' => 'Restore disabled for metadata-only snapshot',
|
||||
'restore_disabled_metadata_body' => 'This snapshot only contains metadata; Graph did not provide policy settings to restore.',
|
||||
'different_tenant_title' => 'Policy version belongs to a different tenant',
|
||||
'missing_policy_title' => 'Policy could not be found for this version',
|
||||
'backup_set_name' => 'Policy version restore - :policy - v:version',
|
||||
'archive' => 'Archive',
|
||||
'archived_title' => 'Policy version archived',
|
||||
'force_delete' => 'Force delete',
|
||||
'force_deleted_title' => 'Policy version permanently deleted',
|
||||
'restored_title' => 'Policy version restored',
|
||||
'empty_state_heading' => 'No policy versions',
|
||||
'empty_state_description' => 'Capture or sync policy snapshots to build a version history.',
|
||||
'open_backup_sets' => 'Open backup sets',
|
||||
'related_entry_current_policy_version' => 'Current policy version',
|
||||
'related_entry_policy' => 'Policy',
|
||||
'related_entry_policy_version' => 'Policy version',
|
||||
'related_action_view_policy' => 'View policy',
|
||||
'related_action_view_policy_version' => 'View policy version',
|
||||
'reference_policy_number' => 'Policy #:id',
|
||||
'reference_version_number' => 'Version :version',
|
||||
'related_record_fallback' => 'Open related record',
|
||||
'assignment_fetch_failed_orphaned' => 'Assignment fetch failed and orphaned targets were detected.',
|
||||
'assignment_fetch_failed' => 'Assignment fetch failed during capture.',
|
||||
'assignment_orphaned' => 'Orphaned assignment targets were detected.',
|
||||
'assignment_no_issues' => 'No assignment issues were detected from captured metadata.',
|
||||
'fallback_display_name' => 'Version :version',
|
||||
],
|
||||
'relation' => [
|
||||
'restore_to_microsoft_intune' => 'Restore to Microsoft Intune',
|
||||
'restore_heading' => 'Restore version :version to Microsoft Intune?',
|
||||
'restore_subheading' => 'Creates a restore run using this policy version snapshot.',
|
||||
'missing_context_title' => 'Missing tenant or user context.',
|
||||
'restore_run_failed_title' => 'Restore run failed to start',
|
||||
'restore_run_started_title' => 'Restore run started',
|
||||
'no_versions_captured' => 'No versions captured',
|
||||
'no_versions_captured_description' => 'Capture or sync this policy again to create version history entries.',
|
||||
],
|
||||
'badges' => [
|
||||
'active' => 'Active',
|
||||
'ignored_locally' => 'Ignored locally',
|
||||
'source_unavailable' => 'Source unavailable',
|
||||
'ignored_source_unavailable' => 'Ignored + source unavailable',
|
||||
],
|
||||
'taxonomy' => [
|
||||
'policies' => 'Policies',
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Language override applied.',
|
||||
'locale_override_cleared' => 'Language override cleared.',
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($key) && $key !== '' ? $key : 'Field';
|
||||
$label = is_string($key) && $key !== '' ? $key : __('localization.policy.resource.general_fallback_field');
|
||||
|
||||
$cards[] = [
|
||||
'key' => $label,
|
||||
@ -92,23 +92,23 @@
|
||||
@endphp
|
||||
|
||||
@if (empty($cards))
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">No general metadata available.</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ __('localization.policy.resource.general_empty_state') }}</p>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($cards as $entry)
|
||||
@php
|
||||
$keyLower = $entry['key_lower'] ?? '';
|
||||
$value = $entry['value'] ?? null;
|
||||
$isPlatform = str_contains($keyLower, 'platform');
|
||||
$isPlatform = str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform');
|
||||
$isTechnologies = str_contains($keyLower, 'technolog');
|
||||
$isTemplateReference = str_contains($keyLower, 'template');
|
||||
$isTemplateReference = str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage');
|
||||
$isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null;
|
||||
$toneKey = match (true) {
|
||||
str_contains($keyLower, 'name') => 'name',
|
||||
str_contains($keyLower, 'platform') => 'platform',
|
||||
str_contains($keyLower, 'setting') => 'settings',
|
||||
str_contains($keyLower, 'template') => 'template',
|
||||
str_contains($keyLower, 'technology') => 'technology',
|
||||
str_contains($keyLower, 'platform') || str_contains($keyLower, 'plattform') => 'platform',
|
||||
str_contains($keyLower, 'setting') || str_contains($keyLower, 'einstellung') => 'settings',
|
||||
str_contains($keyLower, 'template') || str_contains($keyLower, 'vorlage') => 'template',
|
||||
str_contains($keyLower, 'technology') || str_contains($keyLower, 'technolog') => 'technology',
|
||||
default => 'default',
|
||||
};
|
||||
$tone = $toneMap[$toneKey] ?? $toneMap['default'];
|
||||
@ -152,7 +152,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }}
|
||||
{{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : __('localization.policy.resource.template_fallback') }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
$links = is_array($state['links'] ?? null) ? $state['links'] : [];
|
||||
$disclosure = is_string($state['disclosure'] ?? null) ? $state['disclosure'] : null;
|
||||
$emptyState = is_string($state['empty_state'] ?? null) ? $state['empty_state'] : null;
|
||||
$isControlInterpretation = (bool) ($state['is_control_interpretation'] ?? false);
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@ -48,21 +49,88 @@
|
||||
@continue(! is_array($entry))
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
|
||||
</div>
|
||||
@if ($isControlInterpretation)
|
||||
@php
|
||||
$readinessBucket = is_string($entry['readiness_bucket'] ?? null) ? $entry['readiness_bucket'] : 'review_recommended';
|
||||
$readinessColor = match ($readinessBucket) {
|
||||
'follow_up_required' => 'warning',
|
||||
'review_recommended' => 'info',
|
||||
'evidence_on_record' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
$limitationLabels = is_array($entry['limitation_labels'] ?? null) ? $entry['limitation_labels'] : [];
|
||||
$basisItems = is_array($entry['evidence_basis_items'] ?? null) ? $entry['evidence_basis_items'] : [];
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$detailParts = collect([
|
||||
$entry['severity'] ?? null,
|
||||
$entry['status'] ?? null,
|
||||
$entry['governance_state'] ?? null,
|
||||
$entry['outcome'] ?? null,
|
||||
])->filter(fn ($value) => is_string($value) && trim($value) !== '')->map(fn (string $value) => \Illuminate\Support\Str::headline($value))->all();
|
||||
@endphp
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $entry['control_name'] ?? $entry['title'] ?? __('localization.review.control') }}
|
||||
</div>
|
||||
|
||||
@if ($detailParts !== [])
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ implode(' · ', $detailParts) }}</div>
|
||||
<x-filament::badge :color="$readinessColor" size="sm">
|
||||
{{ $entry['readiness_label'] ?? __('localization.review.review_recommended') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($entry['explanation_text'] ?? null))
|
||||
<div class="mt-2 text-gray-700 dark:text-gray-300">
|
||||
{{ $entry['explanation_text'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($basisItems !== [])
|
||||
<div class="mt-3 space-y-1">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.evidence_basis') }}</div>
|
||||
<ul class="space-y-1 text-gray-700 dark:text-gray-300">
|
||||
@foreach ($basisItems as $basisItem)
|
||||
@continue(! is_string($basisItem) || trim($basisItem) === '')
|
||||
|
||||
<li>{{ $basisItem }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['recommended_next_action'] ?? null))
|
||||
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
|
||||
{{ $entry['recommended_next_action'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($limitationLabels !== [])
|
||||
<div class="mt-3 flex flex-wrap gap-1">
|
||||
@foreach ($limitationLabels as $label)
|
||||
@continue(! is_string($label) || trim($label) === '')
|
||||
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $label }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['proof_access_state'] ?? null))
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.proof_access_state') }}: {{ \Illuminate\Support\Str::headline((string) $entry['proof_access_state']) }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
|
||||
</div>
|
||||
|
||||
@php
|
||||
$detailParts = collect([
|
||||
$entry['severity'] ?? null,
|
||||
$entry['status'] ?? null,
|
||||
$entry['governance_state'] ?? null,
|
||||
$entry['outcome'] ?? null,
|
||||
])->filter(fn ($value) => is_string($value) && trim($value) !== '')->map(fn (string $value) => \Illuminate\Support\Str::headline($value))->all();
|
||||
@endphp
|
||||
|
||||
@if ($detailParts !== [])
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ implode(' · ', $detailParts) }}</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@ -10,6 +10,19 @@
|
||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||
$compressedOutcome = is_array($state['compressed_outcome'] ?? null) ? $state['compressed_outcome'] : [];
|
||||
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
|
||||
$customerWorkspaceMode = (bool) ($state['customer_workspace_mode'] ?? false);
|
||||
$controlInterpretation = is_array($state['control_interpretation'] ?? null) ? $state['control_interpretation'] : [];
|
||||
$governancePackage = is_array($state['governance_package'] ?? null) ? $state['governance_package'] : [];
|
||||
$packageAvailability = is_array($governancePackage['availability'] ?? null) ? $governancePackage['availability'] : [];
|
||||
$packageTopFindings = is_array($governancePackage['top_findings'] ?? null) ? $governancePackage['top_findings'] : [];
|
||||
$packageAcceptedRisks = is_array($governancePackage['accepted_risks'] ?? null) ? $governancePackage['accepted_risks'] : [];
|
||||
$packageGovernanceDecisions = is_array($governancePackage['governance_decisions'] ?? null) ? $governancePackage['governance_decisions'] : [];
|
||||
$controlControls = is_array($controlInterpretation['controls'] ?? null) ? $controlInterpretation['controls'] : [];
|
||||
$controlVersion = is_string($controlInterpretation['version_key'] ?? null) ? $controlInterpretation['version_key'] : null;
|
||||
$controlDisclosure = is_string($controlInterpretation['non_certification_disclosure'] ?? null)
|
||||
? $controlInterpretation['non_certification_disclosure']
|
||||
: null;
|
||||
$controlLimitations = is_array($controlInterpretation['limitations'] ?? null) ? $controlInterpretation['limitations'] : [];
|
||||
$decisionDirection = is_string($compressedOutcome['decisionDirection'] ?? null)
|
||||
? trim((string) $compressedOutcome['decisionDirection'])
|
||||
: null;
|
||||
@ -19,6 +32,19 @@
|
||||
$publicationReason = is_string($compressedOutcome['primaryReason'] ?? null) && trim((string) $compressedOutcome['primaryReason']) !== ''
|
||||
? trim((string) $compressedOutcome['primaryReason'])
|
||||
: null;
|
||||
$packageNextStep = $publicationNextAction;
|
||||
if ($packageNextStep === null) {
|
||||
$firstNextAction = $nextActions[0] ?? null;
|
||||
$packageNextStep = is_string($firstNextAction) && trim($firstNextAction) !== '' ? $firstNextAction : null;
|
||||
}
|
||||
$assessmentControls = array_slice($controlControls, 0, 2);
|
||||
$additionalAssessmentControls = max(count($controlControls) - count($assessmentControls), 0);
|
||||
$packageAvailabilityColor = match ($packageAvailability['state'] ?? 'gray') {
|
||||
'available' => 'success',
|
||||
'partial' => 'warning',
|
||||
'blocked', 'expired' => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -72,6 +98,136 @@
|
||||
@endforeach
|
||||
</dl>
|
||||
|
||||
@if ($customerWorkspaceMode && $governancePackage !== [])
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ __('localization.review.governance_package') }}
|
||||
</div>
|
||||
|
||||
@if (filled($governancePackage['delivery_note'] ?? null))
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $governancePackage['delivery_note'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($packageAvailability !== [])
|
||||
<x-filament::badge :color="$packageAvailabilityColor" size="sm">
|
||||
{{ $packageAvailability['label'] ?? __('localization.review.unavailable') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.primary_action') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{{ __('localization.review.download_governance_package') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.executive_entrypoint') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ __('localization.review.executive_entrypoint_description') }}</div>
|
||||
</div>
|
||||
|
||||
@if ($packageNextStep !== null)
|
||||
<div class="rounded-md border border-primary-100 bg-primary-50 px-3 py-2 dark:border-primary-900/40 dark:bg-primary-950/30">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-primary-700 dark:text-primary-200">{{ __('localization.review.next_step') }}</div>
|
||||
<div class="mt-1 text-sm text-primary-900 dark:text-primary-100">{{ $packageNextStep }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.auditor_appendix') }}</div>
|
||||
<div class="mt-1 text-sm text-gray-700 dark:text-gray-300">{{ __('localization.review.auditor_appendix_description') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (filled($governancePackage['executive_summary'] ?? null))
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $governancePackage['executive_summary'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($governancePackage['evidence_basis_summary'] ?? null))
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.evidence_basis') }}</div>
|
||||
<div class="mt-1">{{ $governancePackage['evidence_basis_summary'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($packageAvailability !== [] && filled($packageAvailability['description'] ?? null))
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||
{{ $packageAvailability['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($packageTopFindings !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_findings') }}</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($packageTopFindings as $finding)
|
||||
@php
|
||||
$findingTitle = is_string($finding['title'] ?? null) ? $finding['title'] : __('localization.review.control');
|
||||
$findingSummary = is_string($finding['summary'] ?? null) ? $finding['summary'] : null;
|
||||
@endphp
|
||||
|
||||
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $findingTitle }}</div>
|
||||
@if ($findingSummary !== null && trim($findingSummary) !== '')
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $findingSummary }}</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($packageAcceptedRisks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.accepted_risks') }}</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($packageAcceptedRisks as $risk)
|
||||
@php
|
||||
$riskTitle = is_string($risk['title'] ?? null) ? $risk['title'] : __('localization.review.accepted_risks');
|
||||
$riskSummary = is_string($risk['summary'] ?? null) ? $risk['summary'] : null;
|
||||
@endphp
|
||||
|
||||
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{{ $riskTitle }}</div>
|
||||
@if ($riskSummary !== null && trim($riskSummary) !== '')
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ $riskSummary }}</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($packageGovernanceDecisions !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.governance_decisions') }}</div>
|
||||
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
@foreach ($packageGovernanceDecisions as $decision)
|
||||
@php
|
||||
$decisionTitle = is_string($decision['title'] ?? null) ? $decision['title'] : __('localization.review.governance_decisions');
|
||||
$decisionSummary = is_string($decision['summary'] ?? null) ? $decision['summary'] : null;
|
||||
@endphp
|
||||
|
||||
<li class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 dark:border-amber-900/40 dark:bg-amber-950/30">
|
||||
<div class="font-medium">{{ $decisionTitle }}</div>
|
||||
@if ($decisionSummary !== null && trim($decisionSummary) !== '')
|
||||
<div class="mt-1 text-xs">{{ $decisionSummary }}</div>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($highlights !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
|
||||
@ -98,6 +254,106 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($customerWorkspaceMode && $controlInterpretation !== [])
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ __('localization.review.assessment_basis') }}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.assessment_basis_description') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ __('localization.review.customer_safe') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($controlDisclosure !== null)
|
||||
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ $controlDisclosure }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($assessmentControls !== [])
|
||||
<div class="space-y-2">
|
||||
@foreach ($assessmentControls as $control)
|
||||
@php
|
||||
$readinessBucket = is_string($control['readiness_bucket'] ?? null) ? $control['readiness_bucket'] : 'review_recommended';
|
||||
$readinessColor = match ($readinessBucket) {
|
||||
'follow_up_required' => 'warning',
|
||||
'review_recommended' => 'info',
|
||||
'evidence_on_record' => 'success',
|
||||
default => 'gray',
|
||||
};
|
||||
$limitationLabels = is_array($control['limitation_labels'] ?? null) ? $control['limitation_labels'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $control['control_name'] ?? __('localization.review.control') }}
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$readinessColor" size="sm">
|
||||
{{ $control['readiness_label'] ?? __('localization.review.review_recommended') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($control['customer_summary'] ?? null))
|
||||
<div class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ $control['customer_summary'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($control['evidence_basis_summary'] ?? null))
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $control['evidence_basis_summary'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($control['recommended_next_action'] ?? null))
|
||||
<div class="mt-3 rounded-md border border-primary-100 bg-primary-50 px-3 py-2 text-sm text-primary-900 dark:border-primary-900/40 dark:bg-primary-950/30 dark:text-primary-100">
|
||||
{{ $control['recommended_next_action'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($limitationLabels !== [])
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@foreach ($limitationLabels as $label)
|
||||
@continue(! is_string($label) || trim($label) === '')
|
||||
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $label }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($additionalAssessmentControls > 0)
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.additional_controls', ['count' => $additionalAssessmentControls]) }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950/60 dark:text-gray-300">
|
||||
{{ __('localization.review.control_readiness_unmapped_description') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($controlLimitations !== [])
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.control_limitations_summary', ['limitations' => implode(', ', array_map(fn (string $flag): string => \App\Support\Governance\Controls\ComplianceEvidenceMappingV1::limitationLabel($flag), array_filter($controlLimitations, 'is_string')))]) }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($contextLinks !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>
|
||||
@ -110,14 +366,20 @@
|
||||
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($title === null || $label === null || $url === null)
|
||||
@continue($title === null || $label === null)
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
|
||||
<div class="mt-2">
|
||||
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||
{{ $label }}
|
||||
</x-filament::link>
|
||||
@if ($url !== null)
|
||||
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||
{{ $label }}
|
||||
</x-filament::link>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ __('localization.review.unavailable') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($description !== null && trim($description) !== '')
|
||||
@ -130,9 +392,15 @@
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $customerWorkspaceMode ? __('localization.review.released_governance_record') : __('localization.review.publication_readiness') }}
|
||||
</div>
|
||||
|
||||
@if ($publishBlockers === [] && $decisionDirection === 'publishable')
|
||||
@if ($customerWorkspaceMode)
|
||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{{ __('localization.review.released_governance_record_available') }}
|
||||
</div>
|
||||
@elseif ($publishBlockers === [] && $decisionDirection === 'publishable')
|
||||
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{{ __('localization.review.ready_for_publication') }}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
<x-filament::section heading="Cross-tenant compare">
|
||||
<x-slot name="description">
|
||||
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only and promotion remains preflight only.
|
||||
Compare one authorized source tenant to one authorized target tenant from a canonical workspace surface. Preview stays read only until you explicitly confirm promotion execution.
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4">
|
||||
@ -147,7 +147,7 @@
|
||||
@if ($preflight !== null)
|
||||
<x-filament::section heading="Promotion preflight">
|
||||
<x-slot name="description">
|
||||
Read-only readiness view. No target mutation, queue dispatch, or operation run is created in this slice.
|
||||
Read-only readiness view until you explicitly confirm Execute promotion. Target mutation happens only through the queued operation run.
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-4" data-testid="cross-tenant-preflight">
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$scope = $this->appliedScope();
|
||||
$registerStates = $this->availableRegisterStates();
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
|
||||
<x-filament::icon icon="heroicon-o-clipboard-document-check" class="h-3.5 w-3.5" />
|
||||
Decision register
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
||||
Decision register
|
||||
</h1>
|
||||
|
||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
This workspace register shows the current exception and accepted-risk decisions that need follow-through without opening a second approval lane.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
@if (filled($scope['workspace_label'] ?? null))
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Workspace: {{ $scope['workspace_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Scope: {{ $scope['register_state_label'] ?? 'Open decisions' }}
|
||||
</span>
|
||||
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
Visible rows: {{ $scope['visible_count'] ?? 0 }}
|
||||
</span>
|
||||
|
||||
@if (filled($scope['tenant_label'] ?? null))
|
||||
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
|
||||
Tenant: {{ $scope['tenant_label'] }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($registerStates as $registerState)
|
||||
<a
|
||||
href="{{ $this->pageUrl(['register_state' => $registerState['key']]) }}"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveRegisterState($registerState['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
||||
>
|
||||
{{ $registerState['label'] }}
|
||||
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $registerState['count'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($this->hasTenantPrefilter())
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>The register is currently filtered to one tenant.</span>
|
||||
|
||||
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
Clear tenant filter
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
@ -12,6 +12,10 @@
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.customer_workspace_canonical_note') }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{{ __('localization.review.customer_workspace_non_certification_disclosure') }}
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
|
||||
uses(BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
pest()->browser()->timeout(15_000);
|
||||
|
||||
it('smokes queued promotion execution handoff from compare page into the operation viewer', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Browser Promotion Policy',
|
||||
snapshot: ['settings' => [['key' => 'browser', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->actingAs($fixture['user'])->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||
]);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||
|
||||
$page = visit(CrossTenantComparePage::getUrl(parameters: [
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
], panel: 'admin'));
|
||||
|
||||
$page
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Cross-tenant compare')
|
||||
->assertSee('Compare preview')
|
||||
->click('Generate promotion preflight')
|
||||
->waitForText('Promotion preflight')
|
||||
->assertSee('Execute promotion')
|
||||
->click('Execute promotion')
|
||||
->waitForText('Queue promotion')
|
||||
->click('Queue promotion')
|
||||
->waitForText('Promotion execution queued')
|
||||
->assertSee('Open operation');
|
||||
|
||||
$run = OperationRun::query()->latest('id')->firstOrFail();
|
||||
|
||||
$page
|
||||
->click('Open operation')
|
||||
->waitForText(OperationRunLinks::identifier((int) $run->getKey()))
|
||||
->assertRoute('admin.operations.view', ['run' => (int) $run->getKey()])
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee(OperationRunLinks::identifier((int) $run->getKey()));
|
||||
});
|
||||
@ -57,7 +57,7 @@
|
||||
|
||||
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenantPublished->getKey(),
|
||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
||||
'tenant_review_id' => (int) $publishedReview->getKey(),
|
||||
@ -67,6 +67,8 @@
|
||||
'file_disk' => 'exports',
|
||||
]);
|
||||
|
||||
$publishedReview->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
@ -80,16 +82,39 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->click('Open customer workspace')
|
||||
->waitForText('Customer-safe review workspace')
|
||||
->waitForText('Customer-safe governance package index')
|
||||
->assertSee('Clear filters')
|
||||
->assertSee('Open latest review')
|
||||
->assertSee('Open review')
|
||||
->assertSee('Governance package')
|
||||
->assertSee('Status')
|
||||
->assertSee('Evidence')
|
||||
->assertSee('Review the executive-ready governance package status')
|
||||
->assertSee('This workspace summarizes current review evidence for service delivery. It does not replace a formal audit opinion, certification, or legal attestation.')
|
||||
->assertSee('Partial')
|
||||
->assertSee('Review required')
|
||||
->assertSee('Available')
|
||||
->assertSee('Review package')
|
||||
->assertDontSee('Publishable')
|
||||
->assertDontSee('No mapped controls')
|
||||
->assertDontSee('Compliance evidence mapping v1')
|
||||
->assertDontSee('Publish review')
|
||||
->assertDontSee('Refresh review')
|
||||
->click('Clear filters')
|
||||
->waitForText('No published review available yet')
|
||||
->assertSee('No published review available yet')
|
||||
->click('Open latest review')
|
||||
->waitForText('Published Tenant')
|
||||
->assertDontSee('No Published Tenant')
|
||||
->assertDontSee('No published review available yet')
|
||||
->click('Open review')
|
||||
->waitForText('Outcome summary')
|
||||
->assertSee('Download governance package')
|
||||
->assertSee('Governance package')
|
||||
->assertSee('Released governance record')
|
||||
->assertSee('Review status')
|
||||
->assertSee('Primary action')
|
||||
->assertSee('Executive entrypoint')
|
||||
->assertSee('Structured auditor appendix')
|
||||
->assertSee('Assessment basis')
|
||||
->assertDontSee('Control readiness interpretation')
|
||||
->assertDontSee('Compliance evidence mapping v1')
|
||||
->assertDontSee('Publish review')
|
||||
->assertDontSee('Refresh review')
|
||||
->assertDontSee('Create next review')
|
||||
@ -97,4 +122,4 @@
|
||||
->assertDontSee('Archive review')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
pest()->browser()->timeout(20_000);
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function spec265ApprovedFindingException(Tenant $tenant, User $requester): FindingException
|
||||
{
|
||||
$approver = User::factory()->create();
|
||||
createUserWithTenant(
|
||||
tenant: $tenant,
|
||||
user: $approver,
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
]);
|
||||
|
||||
/** @var FindingExceptionService $service */
|
||||
$service = app(FindingExceptionService::class);
|
||||
|
||||
$requested = $service->request($finding, $tenant, $requester, [
|
||||
'owner_user_id' => (int) $requester->getKey(),
|
||||
'request_reason' => 'Spec265 browser smoke request.',
|
||||
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
return $service->approve($requested, $approver, [
|
||||
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||
'approval_reason' => 'Spec265 browser smoke approval.',
|
||||
]);
|
||||
}
|
||||
|
||||
function spec265SmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
|
||||
{
|
||||
return route('admin.local.smoke-login', array_filter([
|
||||
'email' => $user->email,
|
||||
'tenant' => $tenant->external_id,
|
||||
'workspace' => $tenant->workspace->slug,
|
||||
'redirect' => $redirect,
|
||||
], static fn (?string $value): bool => filled($value)));
|
||||
}
|
||||
|
||||
it('smokes the decision register continuity to the existing exception detail page', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(
|
||||
role: 'owner',
|
||||
workspaceRole: 'manager',
|
||||
ensureDefaultMicrosoftProviderConnection: false,
|
||||
);
|
||||
|
||||
spec265ApprovedFindingException($tenant, $user);
|
||||
|
||||
$decisionRegisterUrl = DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'tenant_id' => (string) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
visit(spec265SmokeLoginUrl($user, $tenant))
|
||||
->waitForText('Dashboard')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit($decisionRegisterUrl)
|
||||
->waitForText('Decision register')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('The register is currently filtered to one tenant.')
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Open decision')
|
||||
->click('Open decision')
|
||||
->waitForText('Opened from the workspace decision register')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('Back to decision register')
|
||||
->assertSee('Renew exception')
|
||||
->assertSee('Revoke exception')
|
||||
->click('Back to decision register')
|
||||
->waitForText('Decision register')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs()
|
||||
->assertSee('The register is currently filtered to one tenant.')
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Open decision');
|
||||
});
|
||||
@ -28,14 +28,17 @@
|
||||
$viewPage
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee((string) $tenant->name)
|
||||
->assertSee('Manage memberships')
|
||||
->assertScript("document.body.innerText.includes('Add member')", false)
|
||||
->assertScript("document.body.innerText.includes('browser-tenant-member@example.test')", false);
|
||||
|
||||
$membershipsPage = visit(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||
$membershipsPage = $viewPage->click('Manage memberships');
|
||||
|
||||
$membershipsPage
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Tenant memberships');
|
||||
->assertSee('Manage tenant memberships')
|
||||
->assertSee('Back to tenant overview')
|
||||
->assertSee('Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.');
|
||||
|
||||
$membershipsPage->script(<<<'JS'
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
@ -44,7 +47,7 @@
|
||||
$membershipsPage
|
||||
->waitForText('Add member')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertSee('Memberships')
|
||||
->assertSee('Manage tenant memberships')
|
||||
->assertSee('Add member')
|
||||
->assertSee('browser-tenant-member@example.test')
|
||||
->assertSee('Change role')
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
@ -58,3 +59,58 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('bulk export blocks provider-missing policies before creating items', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
PolicyVersion::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'version_number' => 1,
|
||||
'snapshot' => ['test' => 'data'],
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.export',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'policy-export-missing-test',
|
||||
'context' => [
|
||||
'policy_ids' => [$policy->id],
|
||||
'backup_name' => 'Missing Backup',
|
||||
],
|
||||
]);
|
||||
|
||||
$job = new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [$policy->id],
|
||||
backupName: 'Missing Backup',
|
||||
backupDescription: null,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
$job->handle(app(OperationRunService::class));
|
||||
|
||||
$opRun->refresh();
|
||||
|
||||
expect($opRun->status)->toBe('completed')
|
||||
->and($opRun->outcome)->toBe('failed')
|
||||
->and($opRun->failure_summary[0]['code'] ?? null)->toBe('policy.provider_missing');
|
||||
|
||||
$backupSet = BackupSet::query()->where('name', 'Missing Backup')->firstOrFail();
|
||||
|
||||
expect($backupSet->status)->toBe('failed')
|
||||
->and((int) $backupSet->item_count)->toBe(0)
|
||||
->and($backupSet->items()->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
@ -31,3 +34,38 @@
|
||||
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue()
|
||||
->and(data_get($expiredAudit?->metadata, 'reason'))->toBe('Evidence basis is obsolete.');
|
||||
});
|
||||
|
||||
it('records audit entries when customer review proof is opened explicitly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 1],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => '123',
|
||||
'tenant_filter_id' => (string) $tenant->getKey(),
|
||||
'interpretation_version' => 'compliance_evidence_mapping.v1',
|
||||
]))
|
||||
->assertOk();
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', AuditActionId::EvidenceSnapshotOpened->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit?->resource_type)->toBe('evidence_snapshot')
|
||||
->and(data_get($audit?->metadata, 'evidence_snapshot_id'))->toBe((int) $snapshot->getKey())
|
||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
|
||||
->and(data_get($audit?->metadata, 'review_id'))->toBe('123')
|
||||
->and(data_get($audit?->metadata, 'tenant_filter_id'))->toBe((string) $tenant->getKey())
|
||||
->and(data_get($audit?->metadata, 'interpretation_version'))->toBe('compliance_evidence_mapping.v1');
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
@ -419,6 +420,63 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
|
||||
->assertSeeText('Copy JSON');
|
||||
});
|
||||
|
||||
it('hides evidence refresh, expiry, operation, fingerprint, and raw json in the customer review proof flow', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 1],
|
||||
'fingerprint' => hash('sha256', 'customer-proof-flow'),
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
EvidenceSnapshotItem::query()->create([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'summary_payload' => ['count' => 1, 'open_count' => 0],
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant').'?'.http_build_query([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => '456',
|
||||
'tenant_filter_id' => (string) $tenant->getKey(),
|
||||
'interpretation_version' => 'compliance_evidence_mapping.v1',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('Evidence dimensions')
|
||||
->assertDontSee('Open the latest evidence refresh operation.')
|
||||
->assertDontSee('customer-proof-flow')
|
||||
->assertDontSee('Raw summary JSON')
|
||||
->assertDontSee('Copy JSON');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_surface' => CustomerReviewWorkspace::SOURCE_SURFACE,
|
||||
'review_id' => '456',
|
||||
'tenant_filter_id' => (string) $tenant->getKey(),
|
||||
'interpretation_version' => 'compliance_evidence_mapping.v1',
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionDoesNotExist('refresh_evidence')
|
||||
->assertActionDoesNotExist('expire_snapshot');
|
||||
});
|
||||
|
||||
it('hides expire actions for expired snapshots on list and detail surfaces', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -146,7 +146,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
]);
|
||||
});
|
||||
|
||||
test('backup service skips ignored policies', function () {
|
||||
test('backup service skips ignored and provider-missing policies', function () {
|
||||
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('fetch')
|
||||
->once()
|
||||
@ -194,14 +194,36 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
|
||||
$policyC = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-3',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy C',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$policyD = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-4',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Policy D',
|
||||
'platform' => 'windows',
|
||||
'last_synced_at' => now(),
|
||||
'ignored_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\Intune\BackupService::class);
|
||||
$backupSet = $service->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: [$policyA->id, $policyB->id],
|
||||
policyIds: [$policyA->id, $policyB->id, $policyC->id, $policyD->id],
|
||||
actorEmail: 'tester@example.com',
|
||||
actorName: 'Tester',
|
||||
);
|
||||
|
||||
expect($backupSet->item_count)->toBe(1);
|
||||
expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]);
|
||||
expect($policyD->currentBackupBlockedReason())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
|
||||
});
|
||||
|
||||
@ -81,6 +81,46 @@
|
||||
expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup set update queued');
|
||||
});
|
||||
|
||||
test('policy picker keeps provider-missing policies visible but blocks add run creation', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test backup',
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
'last_synced_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BackupSetPolicyPickerTable::class, [
|
||||
'backupSetId' => $backupSet->id,
|
||||
])
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$policy])
|
||||
->assertSee('Provider missing')
|
||||
->callTableBulkAction('add_selected_to_backup_set', [$policy])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_set.update')
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('policy picker table reuses an active run on double click (idempotency)', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
@ -144,6 +145,8 @@
|
||||
});
|
||||
|
||||
it('summarizes governed subjects, readiness, and save-forward feedback for current selector payloads', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
|
||||
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
|
||||
@ -158,7 +161,7 @@
|
||||
];
|
||||
|
||||
expect(BaselineProfileResource::scopeSummaryText($payload))
|
||||
->toBe('Intune policies: Device Configuration; Platform foundation configuration resources: Assignment Filter')
|
||||
->toBe(__('localization.policy.taxonomy.policies').': Device Configuration; Platform foundation configuration resources: Assignment Filter')
|
||||
->and(BaselineProfileResource::scopeSupportReadinessText($payload))
|
||||
->toBe('Capture: ready. Compare: ready.')
|
||||
->and(BaselineProfileResource::scopeSelectionFeedbackText($payload))
|
||||
@ -166,6 +169,8 @@
|
||||
});
|
||||
|
||||
it('shows normalization lineage on the baseline profile detail surface before a legacy row is saved forward', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
config()->set('tenantpilot.supported_policy_types', [
|
||||
['type' => 'deviceConfiguration', 'label' => 'Device Configuration'],
|
||||
['type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance'],
|
||||
@ -194,7 +199,7 @@
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewBaselineProfile::class, ['record' => $profileId])
|
||||
->assertSee('Governed subject summary')
|
||||
->assertSee('Intune policies: Device Configuration')
|
||||
->assertSee(__('localization.policy.taxonomy.policies').': Device Configuration')
|
||||
->assertSee('Legacy Intune buckets are being normalized and will be saved forward as canonical V2 on the next successful save.');
|
||||
});
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -67,6 +68,8 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
|
||||
}
|
||||
|
||||
it('defines the policies empty state contract and keeps the sync CTA outcome intact', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
Queue::fake();
|
||||
bindFailHardGraphClient();
|
||||
|
||||
@ -78,19 +81,19 @@ function makeWorkspaceListComponent(string $role = 'owner'): Testable
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['sync'])
|
||||
->assertSee('No policies synced yet')
|
||||
->assertSee('Sync your first tenant to see Intune policies here.');
|
||||
->assertSee(__('localization.policy.resource.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_description'));
|
||||
|
||||
$table = getFeature122EmptyStateTable($component);
|
||||
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
|
||||
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path');
|
||||
|
||||
$action = getFeature122EmptyStateAction($component, 'sync');
|
||||
|
||||
expect($action)->not->toBeNull();
|
||||
expect($action?->getLabel())->toBe('Sync from Intune');
|
||||
expect($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
|
||||
|
||||
$component
|
||||
->mountAction('sync')
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function getPolicyInventoryEmptyStateAction(Testable $component, string $name): ?Action
|
||||
{
|
||||
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
||||
if ($action instanceof Action && $action->getName() === $name) {
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('renders policy inventory list copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$component = Livewire::test(ListPolicies::class)
|
||||
->assertSee(__('localization.policy.common.policies'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.resource.empty_state_description'));
|
||||
|
||||
$action = getPolicyInventoryEmptyStateAction($component, 'sync');
|
||||
|
||||
expect($action)->not->toBeNull()
|
||||
->and($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'));
|
||||
});
|
||||
|
||||
it('renders source-unavailable policy labels from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'German Source Unavailable Policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Livewire::test(ListPolicies::class)
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertSee(__('localization.policy.badges.source_unavailable'));
|
||||
|
||||
$badge = BadgeCatalog::spec(BadgeDomain::PolicyProviderPresence, 'provider_missing');
|
||||
|
||||
expect($badge->label)->toBe(__('localization.policy.badges.source_unavailable'));
|
||||
});
|
||||
|
||||
it('renders policy version list copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListPolicyVersions::class)
|
||||
->assertSee(__('localization.policy.versions.empty_state_heading'))
|
||||
->assertSee(__('localization.policy.versions.empty_state_description'))
|
||||
->assertSee(__('localization.policy.versions.open_backup_sets'));
|
||||
});
|
||||
|
||||
it('renders the restore-to-Microsoft-Intune action from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(VersionsRelationManager::class, [
|
||||
'ownerRecord' => $policy,
|
||||
'pageClass' => ViewPolicy::class,
|
||||
])->assertSee(__('localization.policy.relation.restore_to_microsoft_intune'));
|
||||
});
|
||||
|
||||
it('renders policy version quality and related labels from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Windows Policy',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'platform' => 'all',
|
||||
'snapshot' => ['id' => 'policy-1'],
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::test(ListPolicyVersions::class)
|
||||
->assertSee(__('localization.policy.common.captured'))
|
||||
->assertSee(__('localization.policy.versions.snapshot_mode_full'))
|
||||
->assertSee(__('localization.policy.versions.compact_summary_full_payload'))
|
||||
->assertSee(__('localization.policy.versions.next_action_open_version_detail'))
|
||||
->assertSee(__('localization.policy.versions.related_action_view_policy'))
|
||||
->assertSee(__('localization.policy.common.platform_label_all'));
|
||||
});
|
||||
|
||||
it('renders policy detail and capture-snapshot copy from the active German locale', function (): void {
|
||||
App::setLocale('de');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Enrollment Notifications',
|
||||
'platform' => 'all',
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_id' => $policy->getKey(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'Enrollment Notifications',
|
||||
'platforms' => 'all',
|
||||
'lastModifiedDateTime' => '2026-01-04T11:22:52Z',
|
||||
'createdDateTime' => '2026-01-04T11:22:52Z',
|
||||
],
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['tab' => 'general::tab'])
|
||||
->test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
|
||||
->assertSee(__('localization.policy.resource.capture_snapshot_action'))
|
||||
->assertSee(__('localization.policy.resource.details_section'))
|
||||
->assertSee(__('localization.policy.resource.tab_general'))
|
||||
->assertSee(__('localization.policy.resource.general_field_platforms'))
|
||||
->assertSee(__('localization.policy.common.platform_label_all'))
|
||||
->assertSee(__('localization.policy.resource.general_field_last_modified'))
|
||||
->assertSee(__('localization.policy.resource.general_field_created'));
|
||||
|
||||
Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()])
|
||||
->assertActionExists('capture_snapshot', function (Action $action): bool {
|
||||
return $action->getLabel() === __('localization.policy.resource.capture_snapshot_action')
|
||||
&& $action->isConfirmationRequired()
|
||||
&& (string) $action->getModalHeading() === __('localization.policy.resource.capture_snapshot_modal_heading')
|
||||
&& str_contains((string) $action->getModalDescription(), __('localization.policy.resource.capture_snapshot_modal_subheading'))
|
||||
&& str_contains((string) $action->getModalDescription(), __('localization.policy.common.source_microsoft_intune'));
|
||||
});
|
||||
});
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
@ -46,6 +47,8 @@
|
||||
});
|
||||
|
||||
test('policy list keeps the standard table defaults and persists state in-session', function () {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
@ -62,7 +65,7 @@
|
||||
$table = $component->instance()->getTable();
|
||||
|
||||
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
|
||||
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\Policy;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('filters active, ignored, and provider-missing policy states distinctly', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$active = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Active policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
$missing = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$combined = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Ignored missing policy',
|
||||
'ignored_at' => now()->subDay(),
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicies::class)
|
||||
->assertCanSeeTableRecords([$active])
|
||||
->assertCanNotSeeTableRecords([$missing, $combined])
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$missing, $combined])
|
||||
->assertCanNotSeeTableRecords([$active])
|
||||
->set('tableFilters.visibility.value', 'ignored')
|
||||
->assertCanSeeTableRecords([$combined])
|
||||
->assertCanNotSeeTableRecords([$active, $missing]);
|
||||
});
|
||||
|
||||
it('keeps provider-missing sync retry available and current export disabled', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'display_name' => 'Provider missing policy',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListPolicies::class)
|
||||
->set('tableFilters.visibility.value', 'provider_missing')
|
||||
->assertCanSeeTableRecords([$policy])
|
||||
->assertSee(__('localization.policy.badges.source_unavailable'))
|
||||
->assertTableActionEnabled('sync', $policy)
|
||||
->assertTableActionDisabled('export', $policy);
|
||||
});
|
||||
@ -11,8 +11,11 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
it('shows parent policy and snapshot evidence links for policy versions', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
@ -57,6 +60,6 @@
|
||||
|
||||
$this->get(PolicyVersionResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('View policy')
|
||||
->assertSee(__('localization.policy.versions.related_action_view_policy'))
|
||||
->assertSee(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant), false);
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('restore selection options are grouped and filter ignored policies', function () {
|
||||
test('restore selection options are grouped and preserve provider-missing continuity', function () {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
@ -39,9 +39,26 @@
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => now(),
|
||||
]);
|
||||
$providerMissingPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-provider-missing',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Provider Missing Policy',
|
||||
'platform' => 'windows',
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
$combinedPolicy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-ignored-provider-missing',
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'display_name' => 'Ignored Provider Missing Policy',
|
||||
'platform' => 'windows',
|
||||
'ignored_at' => now(),
|
||||
'missing_from_provider_at' => now(),
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create([
|
||||
'item_count' => 4,
|
||||
'item_count' => 6,
|
||||
]);
|
||||
|
||||
$policyItem = BackupItem::factory()
|
||||
@ -68,6 +85,30 @@
|
||||
])
|
||||
->create();
|
||||
|
||||
$providerMissingItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => $providerMissingPolicy->id,
|
||||
'policy_identifier' => $providerMissingPolicy->external_id,
|
||||
'policy_type' => $providerMissingPolicy->policy_type,
|
||||
'platform' => $providerMissingPolicy->platform,
|
||||
'payload' => ['id' => $providerMissingPolicy->external_id],
|
||||
])
|
||||
->create();
|
||||
|
||||
$combinedItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
->state([
|
||||
'policy_id' => $combinedPolicy->id,
|
||||
'policy_identifier' => $combinedPolicy->external_id,
|
||||
'policy_type' => $combinedPolicy->policy_type,
|
||||
'platform' => $combinedPolicy->platform,
|
||||
'payload' => ['id' => $combinedPolicy->external_id],
|
||||
])
|
||||
->create();
|
||||
|
||||
$scopeTagItem = BackupItem::factory()
|
||||
->for($tenant)
|
||||
->for($backupSet)
|
||||
@ -134,6 +175,14 @@
|
||||
|
||||
expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id);
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($providerMissingItem->id);
|
||||
expect($flattenedOptions[$providerMissingItem->id])->toContain('Provider Missing Policy')
|
||||
->and($flattenedOptions[$providerMissingItem->id])->toContain('provider missing now');
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($combinedItem->id);
|
||||
expect($flattenedOptions[$combinedItem->id])->toContain('Ignored Provider Missing Policy')
|
||||
->and($flattenedOptions[$combinedItem->id])->toContain('provider missing now');
|
||||
|
||||
expect($flattenedOptions)->toHaveKey($scopeTagItem->id);
|
||||
expect($flattenedOptions[$scopeTagItem->id])->toContain('Scope Tag Alpha');
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -40,6 +41,8 @@ function spec125BaselineTenantContext(): array
|
||||
}
|
||||
|
||||
it('keeps the policy resource list as the baseline resource-standard example', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user] = spec125BaselineTenantContext();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(ListPolicies::class)
|
||||
@ -53,8 +56,8 @@ function spec125BaselineTenantContext(): array
|
||||
expect($table->persistsSearchInSession())->toBeTrue();
|
||||
expect($table->persistsSortInSession())->toBeTrue();
|
||||
expect($table->persistsFiltersInSession())->toBeTrue();
|
||||
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
|
||||
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.resource.empty_state_heading'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.resource.empty_state_description'));
|
||||
expect(array_keys($table->getVisibleColumns()))->toContain('display_name', 'policy_type', 'platform', 'last_synced_at');
|
||||
|
||||
$displayName = $table->getColumn('display_name');
|
||||
@ -70,6 +73,8 @@ function spec125BaselineTenantContext(): array
|
||||
});
|
||||
|
||||
it('keeps the policy versions relation manager on the standard relation-manager contract', function (): void {
|
||||
App::setLocale('en');
|
||||
|
||||
[$user, $tenant] = spec125BaselineTenantContext();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
@ -86,8 +91,8 @@ function spec125BaselineTenantContext(): array
|
||||
expect($table->getDefaultSortColumn())->toBe('version_number');
|
||||
expect($table->getDefaultSortDirection())->toBe('desc');
|
||||
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::relationManager());
|
||||
expect($table->getEmptyStateHeading())->toBe('No versions captured');
|
||||
expect($table->getEmptyStateDescription())->toBe('Capture or sync this policy again to create version history entries.');
|
||||
expect($table->getEmptyStateHeading())->toBe(__('localization.policy.relation.no_versions_captured'));
|
||||
expect($table->getEmptyStateDescription())->toBe(__('localization.policy.relation.no_versions_captured_description'));
|
||||
expect($table->getColumn('version_number')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('captured_at')?->isSortable())->toBeTrue();
|
||||
expect($table->getColumn('policy_type')?->isToggleable())->toBeTrue();
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('keeps the decision register read-only with one dominant row action', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Alpha Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Read only boundary test',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
|
||||
'reason' => 'Read only boundary test',
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Open decision')
|
||||
->assertDontSee('Approve exception')
|
||||
->assertDontSee('Reject exception')
|
||||
->assertDontSee('Renew exception')
|
||||
->assertDontSee('Revoke exception');
|
||||
});
|
||||
|
||||
it('omits terminal decisions outside the 30 calendar day recently closed window', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Alpha Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$createTerminalException = function (string $status, string $reason, int $daysAgo) use ($tenant, $user): FindingException {
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => $status,
|
||||
'current_validity_state' => $status === FindingException::STATUS_REJECTED
|
||||
? FindingException::VALIDITY_REJECTED
|
||||
: FindingException::VALIDITY_REVOKED,
|
||||
'request_reason' => 'Recently closed boundary test',
|
||||
'review_due_at' => now()->subDays($daysAgo + 1),
|
||||
'rejected_at' => $status === FindingException::STATUS_REJECTED ? now()->subDays($daysAgo) : null,
|
||||
'revoked_at' => $status === FindingException::STATUS_REVOKED ? now()->subDays($daysAgo) : null,
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => $status === FindingException::STATUS_REJECTED
|
||||
? FindingExceptionDecision::TYPE_REJECTED
|
||||
: FindingExceptionDecision::TYPE_REVOKED,
|
||||
'reason' => $reason,
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDays($daysAgo),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
return $exception;
|
||||
};
|
||||
|
||||
$createTerminalException(FindingException::STATUS_REJECTED, 'Recent closure reason', 2);
|
||||
$createTerminalException(FindingException::STATUS_REVOKED, 'Old closure reason', 45);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin', parameters: ['register_state' => 'recently_closed']))
|
||||
->assertOk()
|
||||
->assertSee('Recent closure reason')
|
||||
->assertDontSee('Old closure reason');
|
||||
});
|
||||
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('embeds decision register navigation context into open decision links', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Alpha Tenant',
|
||||
'external_id' => 'alpha-tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$finding = Finding::factory()
|
||||
->for($tenant)
|
||||
->riskAccepted()
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => 'Decision register continuity',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
|
||||
'reason' => 'Decision register continuity',
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
$context = CanonicalNavigationContext::forDecisionRegister(
|
||||
canonicalRouteName: DecisionRegister::getRouteName(),
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkUrl: DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'tenant_id' => (string) $tenant->getKey(),
|
||||
]),
|
||||
);
|
||||
|
||||
$expectedDetailUrl =
|
||||
FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant)
|
||||
.'?'.http_build_query($context->toQuery());
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $tenant->getKey(),
|
||||
])
|
||||
->actingAs($user)
|
||||
->test(DecisionRegister::class)
|
||||
->assertSee('Decision register')
|
||||
->assertSee('Open decision');
|
||||
|
||||
expect($component->instance()->decisionUrl($exception))
|
||||
->toBe($expectedDetailUrl)
|
||||
->toContain('nav%5Bback_label%5D=Back+to+decision+register')
|
||||
->toContain('nav%5Bsource_surface%5D=governance.decision_register');
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('adds a decision register back action while keeping existing detail actions in place', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Alpha Tenant',
|
||||
]);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create();
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $user->getKey(),
|
||||
'owner_user_id' => (int) $user->getKey(),
|
||||
'approved_by_user_id' => (int) $user->getKey(),
|
||||
'status' => FindingException::STATUS_ACTIVE,
|
||||
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||
'request_reason' => 'Detail continuity context',
|
||||
'approval_reason' => 'Active approval still visible',
|
||||
'requested_at' => now()->subDays(5),
|
||||
'approved_at' => now()->subDays(4),
|
||||
'effective_from' => now()->subDays(4),
|
||||
'review_due_at' => now()->addDays(2),
|
||||
'expires_at' => now()->addDays(10),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_APPROVED,
|
||||
'reason' => 'Approved for detail continuity test',
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDays(4),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$context = CanonicalNavigationContext::forDecisionRegister(
|
||||
canonicalRouteName: DecisionRegister::getRouteName(),
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backLinkUrl: DecisionRegister::getUrl(panel: 'admin', parameters: [
|
||||
'tenant_id' => (string) $tenant->getKey(),
|
||||
]),
|
||||
);
|
||||
|
||||
Livewire::withQueryParams($context->toQuery())
|
||||
->test(ViewFindingException::class, ['record' => $exception->getKey()])
|
||||
->assertOk()
|
||||
->assertActionVisible('return_to_decision_register')
|
||||
->assertActionVisible('renew_exception')
|
||||
->assertActionVisible('revoke_exception')
|
||||
->assertSee('Opened from the workspace decision register');
|
||||
});
|
||||
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
Filament::setCurrentPanel(null);
|
||||
});
|
||||
|
||||
it('redirects decision register visits without workspace context into the existing workspace chooser flow', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspaceA = Workspace::factory()->create();
|
||||
$workspaceB = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceA->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspaceB->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertRedirect('/admin/choose-workspace');
|
||||
});
|
||||
|
||||
it('returns 404 for users outside the active workspace on the decision register route', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for workspace members with no visible decisions in the default unfiltered register', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('hides the decision register page when the default workspace register would resolve to 403', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin')
|
||||
->assertOk();
|
||||
|
||||
$response->assertDontSee(DecisionRegister::getUrl(panel: 'admin'));
|
||||
|
||||
expect(DecisionRegister::canAccess())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
|
||||
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$hiddenTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows readonly tenant members to open the decision register when visible decisions exist', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
decisionRegisterAuthException(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_PENDING,
|
||||
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
|
||||
decisionReason: 'Visible approval request',
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Decision register');
|
||||
});
|
||||
|
||||
it('registers the decision register page once visible open decisions exist', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
decisionRegisterAuthException(
|
||||
tenant: $tenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_PENDING,
|
||||
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
|
||||
decisionReason: 'Visible approval request',
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin')
|
||||
->assertOk();
|
||||
|
||||
$response->assertSee(DecisionRegister::getUrl(panel: 'admin'));
|
||||
|
||||
expect(DecisionRegister::canAccess())->toBeTrue();
|
||||
});
|
||||
|
||||
function decisionRegisterAuthException(
|
||||
Tenant $tenant,
|
||||
User $actor,
|
||||
string $status,
|
||||
string $validityState,
|
||||
string $decisionType,
|
||||
string $decisionReason,
|
||||
): FindingException {
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $actor->getKey(),
|
||||
'owner_user_id' => (int) $actor->getKey(),
|
||||
'status' => $status,
|
||||
'current_validity_state' => $validityState,
|
||||
'request_reason' => 'Decision register authorization test',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
]);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => $decisionType,
|
||||
'reason' => $decisionReason,
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
return $exception->fresh(['currentDecision']);
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Governance\DecisionRegister;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders open and recently closed decision rows for visible tenants only', function (): void {
|
||||
$visibleTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Visible Tenant',
|
||||
'external_id' => 'visible-tenant',
|
||||
]);
|
||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$hiddenTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
||||
'name' => 'Hidden Tenant',
|
||||
'external_id' => 'hidden-tenant',
|
||||
]);
|
||||
|
||||
decisionRegisterPageException(
|
||||
tenant: $visibleTenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_PENDING,
|
||||
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
|
||||
decisionReason: 'Visible approval request',
|
||||
exceptionAttributes: [
|
||||
'requested_at' => now()->subDays(2),
|
||||
'review_due_at' => now()->addDay(),
|
||||
],
|
||||
decisionAttributes: [
|
||||
'decided_at' => now()->subDays(2),
|
||||
],
|
||||
);
|
||||
|
||||
decisionRegisterPageException(
|
||||
tenant: $visibleTenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_REJECTED,
|
||||
validityState: FindingException::VALIDITY_REJECTED,
|
||||
decisionType: FindingExceptionDecision::TYPE_REJECTED,
|
||||
decisionReason: 'Recently rejected closure reason',
|
||||
exceptionAttributes: [
|
||||
'rejected_at' => now()->subDays(2),
|
||||
'review_due_at' => now()->subDays(3),
|
||||
],
|
||||
decisionAttributes: [
|
||||
'decided_at' => now()->subDays(2),
|
||||
],
|
||||
);
|
||||
|
||||
decisionRegisterPageException(
|
||||
tenant: $hiddenTenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_PENDING,
|
||||
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
|
||||
decisionReason: 'Hidden tenant request',
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Decision register')
|
||||
->assertSee('Visible Tenant')
|
||||
->assertSee('Review approval')
|
||||
->assertSee('Open decision')
|
||||
->assertDontSee('Recently rejected closure reason')
|
||||
->assertDontSee('Hidden tenant request');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin').'?register_state=recently_closed')
|
||||
->assertOk()
|
||||
->assertSee('Recently rejected closure reason')
|
||||
->assertDontSee('Visible approval request');
|
||||
});
|
||||
|
||||
it('shows truthful filtered empty states for tenant and register-state filters', function (): void {
|
||||
$alphaTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'name' => 'Alpha Tenant',
|
||||
'external_id' => 'alpha-tenant',
|
||||
]);
|
||||
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
|
||||
|
||||
$bravoTenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
||||
'name' => 'Bravo Tenant',
|
||||
'external_id' => 'bravo-tenant',
|
||||
]);
|
||||
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
(int) $bravoTenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
decisionRegisterPageException(
|
||||
tenant: $bravoTenant,
|
||||
actor: $user,
|
||||
status: FindingException::STATUS_PENDING,
|
||||
validityState: FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
decisionType: FindingExceptionDecision::TYPE_REQUESTED,
|
||||
decisionReason: 'Bravo tenant request',
|
||||
);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
|
||||
->assertOk()
|
||||
->assertSee('This tenant filter is hiding other visible decision follow-through')
|
||||
->assertSee('Clear tenant filter');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
|
||||
->get(DecisionRegister::getUrl(panel: 'admin').'?register_state=recently_closed')
|
||||
->assertOk()
|
||||
->assertSee('No recently closed decisions match this filter right now.');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $exceptionAttributes
|
||||
* @param array<string, mixed> $decisionAttributes
|
||||
*/
|
||||
function decisionRegisterPageException(
|
||||
Tenant $tenant,
|
||||
User $actor,
|
||||
string $status,
|
||||
string $validityState,
|
||||
string $decisionType,
|
||||
string $decisionReason,
|
||||
array $exceptionAttributes = [],
|
||||
array $decisionAttributes = [],
|
||||
): FindingException {
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
$exception = FindingException::query()->create(array_merge([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'requested_by_user_id' => (int) $actor->getKey(),
|
||||
'owner_user_id' => (int) $actor->getKey(),
|
||||
'status' => $status,
|
||||
'current_validity_state' => $validityState,
|
||||
'request_reason' => 'Decision register page test',
|
||||
'requested_at' => now()->subDay(),
|
||||
'review_due_at' => now()->addDay(),
|
||||
'evidence_summary' => ['reference_count' => 0],
|
||||
], $exceptionAttributes));
|
||||
|
||||
$decision = $exception->decisions()->create(array_merge([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => $decisionType,
|
||||
'reason' => $decisionReason,
|
||||
'metadata' => [],
|
||||
'decided_at' => now()->subDay(),
|
||||
], $decisionAttributes));
|
||||
|
||||
$exception->forceFill(['current_decision_id' => (int) $decision->getKey()])->save();
|
||||
|
||||
return $exception->fresh(['currentDecision']);
|
||||
}
|
||||
@ -610,15 +610,23 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$membershipsUrl = TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin');
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Manage memberships')
|
||||
->assertSee('href="'.$membershipsUrl.'"', false)
|
||||
->assertDontSeeLivewire(TenantMembershipsRelationManager::class);
|
||||
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$membershipsPage = Livewire::actingAs($user)
|
||||
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()]);
|
||||
->test(ManageTenantMemberships::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('back_to_overview')
|
||||
->assertActionDoesNotExist('memberships')
|
||||
->assertActionExists('back_to_overview', fn ($action): bool => $action->getLabel() === 'Back to tenant overview'
|
||||
&& $action->getUrl() === TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));
|
||||
|
||||
expect($membershipsPage->instance()->getRelationManagers())
|
||||
->toContain(TenantMembershipsRelationManager::class);
|
||||
@ -626,6 +634,12 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(TenantResource::getUrl('memberships', ['record' => $tenant->getRouteKey()], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Manage tenant memberships')
|
||||
->assertSee('Tenant access is managed here. Use the tenant overview for provider state, verification, and operational context.')
|
||||
->assertSee('Back to tenant overview')
|
||||
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\RecentOperationsSummary::class)
|
||||
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\TenantVerificationReport::class)
|
||||
->assertDontSeeLivewire(\App\Filament\Widgets\Tenant\AdminRolesSummaryWidget::class)
|
||||
->assertSeeLivewire(TenantMembershipsRelationManager::class);
|
||||
});
|
||||
|
||||
@ -689,6 +703,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->assertActionVisible('syncTenant')
|
||||
->assertActionVisible('verify')
|
||||
->assertActionVisible('setup_rbac')
|
||||
->assertActionVisible('memberships')
|
||||
->assertActionVisible('refresh_rbac')
|
||||
->assertActionVisible('archive');
|
||||
|
||||
@ -698,7 +713,15 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
$instance->cacheInteractsWithHeaderActions();
|
||||
}
|
||||
|
||||
$headerGroups = collect($instance->getCachedHeaderActions())
|
||||
$headerActions = $instance->getCachedHeaderActions();
|
||||
$primaryHeaderActions = collect($headerActions)
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$headerGroups = collect($headerActions)
|
||||
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
||||
->mapWithKeys(static function (ActionGroup $group): array {
|
||||
$actionNames = collect($group->getActions())
|
||||
@ -722,6 +745,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
||||
->and($markFollowUpNeededAction)->not->toBeNull()
|
||||
->and($markFollowUpNeededAction?->getName())->toBe('markFollowUpNeeded')
|
||||
->and($markFollowUpNeededAction?->isConfirmationRequired())->toBeTrue()
|
||||
->and($primaryHeaderActions)->toEqual(['memberships'])
|
||||
->and(array_keys($headerGroups->all()))->toBe(['External links', 'Setup', 'Triage', 'Lifecycle'])
|
||||
->and($headerGroups->get('External links'))->toEqualCanonicalizing(['admin_consent', 'open_in_entra'])
|
||||
->and($headerGroups->get('Setup'))->toEqualCanonicalizing(['syncTenant', 'verify', 'setup_rbac', 'refresh_rbac'])
|
||||
|
||||
@ -96,7 +96,8 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
->where('external_id', 'config-1')
|
||||
->firstOrFail();
|
||||
|
||||
expect($existingConfig->ignored_at)->not->toBeNull();
|
||||
expect($existingConfig->ignored_at)->toBeNull();
|
||||
expect($existingConfig->missing_from_provider_at)->not->toBeNull();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'config-skip')->exists())->toBeFalse();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->exists())->toBeTrue();
|
||||
expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->whereNull('missing_from_provider_at')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -47,7 +47,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
}
|
||||
}
|
||||
|
||||
test('sync revives ignored policies when they exist in Intune', function () {
|
||||
test('sync preserves local ignore when policies still exist in Intune', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'test-tenant',
|
||||
'name' => 'Test Tenant',
|
||||
@ -88,13 +88,14 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
// Refresh the policy
|
||||
$policy->refresh();
|
||||
|
||||
// Policy should no longer be ignored
|
||||
expect($policy->ignored_at)->toBeNull();
|
||||
// Provider reappearance updates local metadata, but only a user action clears local ignore.
|
||||
expect($policy->ignored_at)->not->toBeNull();
|
||||
expect($policy->missing_from_provider_at)->toBeNull();
|
||||
expect($policy->display_name)->toBe('Test Policy (Updated)');
|
||||
expect($policy->last_synced_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('sync creates new policies even if ignored ones exist with same external_id', function () {
|
||||
test('sync updates ignored policies without reviving them', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'test-tenant-2',
|
||||
'name' => 'Test Tenant 2',
|
||||
@ -149,15 +150,17 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
// Sync policies
|
||||
app(PolicySyncService::class)->syncPolicies($tenant);
|
||||
|
||||
// All policies should now be active
|
||||
expect(Policy::active()->count())->toBe(2);
|
||||
expect(Policy::ignored()->count())->toBe(0);
|
||||
// Both provider-visible policies remain locally ignored until explicitly restored.
|
||||
expect(Policy::active()->count())->toBe(0);
|
||||
expect(Policy::ignored()->count())->toBe(2);
|
||||
|
||||
$policyAbc = Policy::where('external_id', 'policy-abc')->first();
|
||||
expect($policyAbc->display_name)->toBe('Restored Policy ABC');
|
||||
expect($policyAbc->ignored_at)->toBeNull();
|
||||
expect($policyAbc->ignored_at)->not->toBeNull();
|
||||
expect($policyAbc->missing_from_provider_at)->toBeNull();
|
||||
|
||||
$policyDef = Policy::where('external_id', 'policy-def')->first();
|
||||
expect($policyDef->display_name)->toBe('Restored Policy DEF');
|
||||
expect($policyDef->ignored_at)->toBeNull();
|
||||
expect($policyDef->ignored_at)->not->toBeNull();
|
||||
expect($policyDef->missing_from_provider_at)->toBeNull();
|
||||
});
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Policy;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\ProviderCredential;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphLogger;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function tenantWithDefaultMicrosoftConnectionForProviderMissing(array $attributes = []): Tenant
|
||||
{
|
||||
$tenant = Tenant::factory()->create($attributes + [
|
||||
'status' => 'active',
|
||||
'app_client_id' => null,
|
||||
'app_client_secret' => null,
|
||||
]);
|
||||
|
||||
$connection = ProviderConnection::factory()->consentGranted()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'provider' => 'microsoft',
|
||||
'is_default' => true,
|
||||
'consent_status' => 'granted',
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
|
||||
]);
|
||||
|
||||
ProviderCredential::factory()->create([
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'type' => 'client_secret',
|
||||
'payload' => [
|
||||
'client_id' => 'provider-client-'.$tenant->getKey(),
|
||||
'client_secret' => 'provider-secret-'.$tenant->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('marks previously observed policies missing when provider list omits them', function (): void {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
|
||||
|
||||
$present = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-present',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Old present',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
$missing = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-missing',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Missing from provider',
|
||||
'ignored_at' => null,
|
||||
'missing_from_provider_at' => null,
|
||||
]);
|
||||
|
||||
mock(GraphLogger::class)
|
||||
->shouldReceive('logRequest', 'logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->with('deviceConfiguration', mockery::type('array'))
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'policy-present',
|
||||
'displayName' => 'Provider present',
|
||||
'platform' => 'windows',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
app(PolicySyncService::class)->syncPolicies($tenant, [
|
||||
['type' => 'deviceConfiguration', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
$present->refresh();
|
||||
$missing->refresh();
|
||||
|
||||
expect($present->display_name)->toBe('Provider present')
|
||||
->and($present->ignored_at)->toBeNull()
|
||||
->and($present->missing_from_provider_at)->toBeNull()
|
||||
->and($missing->ignored_at)->toBeNull()
|
||||
->and($missing->missing_from_provider_at)->not->toBeNull()
|
||||
->and($missing->visibilityState())->toBe(Policy::VISIBILITY_PROVIDER_MISSING);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('action', AuditActionId::PolicyProviderMissingDetected->value)
|
||||
->where('resource_id', (string) $missing->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('clears provider missing on reappearance without clearing local ignore', function (): void {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForProviderMissing();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-returned',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'display_name' => 'Returned policy',
|
||||
'ignored_at' => now()->subDay(),
|
||||
'missing_from_provider_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
mock(GraphLogger::class)
|
||||
->shouldReceive('logRequest', 'logResponse')
|
||||
->zeroOrMoreTimes()
|
||||
->andReturnNull();
|
||||
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldReceive('listPolicies')
|
||||
->once()
|
||||
->with('deviceConfiguration', mockery::type('array'))
|
||||
->andReturn(new GraphResponse(
|
||||
success: true,
|
||||
data: [
|
||||
[
|
||||
'id' => 'policy-returned',
|
||||
'displayName' => 'Returned from provider',
|
||||
'platform' => 'windows',
|
||||
],
|
||||
],
|
||||
));
|
||||
|
||||
app(PolicySyncService::class)->syncPolicies($tenant, [
|
||||
['type' => 'deviceConfiguration', 'platform' => 'windows'],
|
||||
]);
|
||||
|
||||
$policy->refresh();
|
||||
|
||||
expect($policy->display_name)->toBe('Returned from provider')
|
||||
->and($policy->ignored_at)->not->toBeNull()
|
||||
->and($policy->missing_from_provider_at)->toBeNull()
|
||||
->and($policy->visibilityState())->toBe(Policy::VISIBILITY_IGNORED_LOCALLY);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('action', AuditActionId::PolicyProviderMissingCleared->value)
|
||||
->where('resource_id', (string) $policy->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
@ -40,7 +40,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
it('marks targeted managed app configurations as ignored during sync', function () {
|
||||
it('marks targeted managed app configurations as provider missing during sync', function () {
|
||||
$tenant = tenantWithDefaultMicrosoftConnectionForPolicySync();
|
||||
|
||||
$policy = Policy::factory()->create([
|
||||
@ -82,7 +82,8 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
|
||||
$policy->refresh();
|
||||
|
||||
expect($policy->ignored_at)->not->toBeNull();
|
||||
expect($policy->ignored_at)->toBeNull();
|
||||
expect($policy->missing_from_provider_at)->not->toBeNull();
|
||||
expect($synced)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
@ -338,6 +339,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
@ -345,13 +347,14 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'endpointSecurityPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->whereNull('missing_from_provider_at')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('external_id', 'esp-1')
|
||||
->where('policy_type', 'settingsCatalogPolicy')
|
||||
->whereNull('ignored_at')
|
||||
->whereNotNull('missing_from_provider_at')
|
||||
->count())->toBe(0);
|
||||
|
||||
$version->refresh();
|
||||
|
||||
@ -4,19 +4,24 @@
|
||||
|
||||
use App\Filament\Pages\CrossTenantComparePage;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Jobs\Operations\CrossTenantPromotionExecutionJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\RestoreSafety\RestoreResultAttention;
|
||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioCompareFixtures;
|
||||
use Tests\Feature\Concerns\BuildsPortfolioTriageFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class);
|
||||
uses(RefreshDatabase::class, BuildsPortfolioTriageFixtures::class, BuildsPortfolioCompareFixtures::class);
|
||||
|
||||
function crossTenantCompareLaunchQuery(string $url): array
|
||||
{
|
||||
@ -119,6 +124,80 @@ function crossTenantCompareLaunchQuery(string $url): array
|
||||
->assertActionVisible('return_to_origin');
|
||||
});
|
||||
|
||||
it('keeps launch context after queueing promotion from an exact-two registry launch', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor(
|
||||
tenantName: 'Anchor Tenant',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
createMinimalUserWithTenant(
|
||||
tenant: $targetTenant,
|
||||
user: $user,
|
||||
role: 'owner',
|
||||
workspaceRole: 'owner',
|
||||
);
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $anchorTenant,
|
||||
displayName: 'Queued Launch Context Policy',
|
||||
snapshot: ['settings' => [['key' => 'launch-context', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$triageState = $this->portfolioReturnFilters(
|
||||
[TenantBackupHealthAssessment::POSTURE_STALE],
|
||||
[TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED],
|
||||
[],
|
||||
TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
||||
);
|
||||
|
||||
$expectedUrl = TenantResource::crossTenantCompareOpenUrlForSelection(
|
||||
targetTenant: $targetTenant,
|
||||
triageState: $triageState,
|
||||
sourceTenant: $anchorTenant,
|
||||
);
|
||||
$expectedBackUrl = TenantResource::getUrl(panel: 'admin', parameters: $triageState);
|
||||
$query = crossTenantCompareLaunchQuery($expectedUrl);
|
||||
$query['policy_type'] = ['deviceConfiguration'];
|
||||
|
||||
$this->usePortfolioTriageWorkspace($user, $anchorTenant);
|
||||
|
||||
$component = Livewire::withQueryParams($query)
|
||||
->actingAs($user)
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertSet('sourceTenantId', (string) $anchorTenant->getKey())
|
||||
->assertSet('targetTenantId', (string) $targetTenant->getKey())
|
||||
->assertSet('selectedPolicyTypes', ['deviceConfiguration'])
|
||||
->assertActionVisible('return_to_origin')
|
||||
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry'
|
||||
&& $action->getUrl() === $expectedBackUrl);
|
||||
|
||||
$page = $component->instance();
|
||||
$page->generatePromotionPreflight();
|
||||
$page->executePromotion();
|
||||
|
||||
$run = OperationRun::query()->latest('id')->first();
|
||||
$navigationContext = CanonicalNavigationContext::fromPayload($page->navigationContextPayload);
|
||||
|
||||
expect($run)
|
||||
->not->toBeNull()
|
||||
->and($run?->type)->toBe('promotion.execute')
|
||||
->and(data_get($run?->context, 'selection.sourceTenantId'))->toBe((int) $anchorTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.targetTenantId'))->toBe((int) $targetTenant->getKey())
|
||||
->and(data_get($run?->context, 'selection.policyTypes'))->toBe(['deviceConfiguration'])
|
||||
->and($page->sourceTenantId)->toBe((string) $anchorTenant->getKey())
|
||||
->and($page->targetTenantId)->toBe((string) $targetTenant->getKey())
|
||||
->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration'])
|
||||
->and($page->navigationContextPayload)->toBe($query['nav'])
|
||||
->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry')
|
||||
->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl);
|
||||
|
||||
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {
|
||||
return $job->getOperationRun()?->is($run);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the bulk compare action until exactly two active tenants are selected', function (): void {
|
||||
[$user, $anchorTenant] = $this->makePortfolioTriageActor('Anchor Tenant');
|
||||
$targetTenant = $this->makePortfolioTriagePeer($user, $anchorTenant, 'Target Tenant');
|
||||
|
||||
@ -65,6 +65,32 @@
|
||||
->assertSee('Windows Compliance');
|
||||
});
|
||||
|
||||
it('shows only one dominant promotion action at a time on the compare page', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
$this->createPortfolioCompareSubject(
|
||||
tenant: $fixture['sourceTenant'],
|
||||
displayName: 'Promotable Policy',
|
||||
snapshot: ['settings' => [['key' => 'wifi', 'value' => 1]]],
|
||||
);
|
||||
|
||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
Livewire::withQueryParams([
|
||||
'source_tenant_id' => (int) $fixture['sourceTenant']->getKey(),
|
||||
'target_tenant_id' => (int) $fixture['targetTenant']->getKey(),
|
||||
'policy_type' => ['deviceConfiguration'],
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(CrossTenantComparePage::class)
|
||||
->assertActionVisible('generatePromotionPreflight')
|
||||
->assertActionHidden('executePromotion')
|
||||
->call('generatePromotionPreflight')
|
||||
->assertDontSee('Generate promotion preflight')
|
||||
->assertSee('Execute promotion')
|
||||
->assertActionVisible('executePromotion');
|
||||
});
|
||||
|
||||
it('rejects the same tenant as source and target without rendering compare results', function (): void {
|
||||
$fixture = $this->makeCrossTenantCompareFixture();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user