Compare commits

...

40 Commits

Author SHA1 Message Date
Ahmed Darrazi
b5671cbf47 chore: commit all local changes (automated by Copilot)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m49s
2026-05-02 21:00:28 +02:00
Ahmed Darrazi
df5a0e067d docs: realign implementation ledger 2026-05-02 16:51:02 +02:00
Ahmed Darrazi
15af199d4f docs: realign product roadmap 2026-05-02 16:50:09 +02:00
11247c1537 Add cross-tenant promotion execution (spec 264) (#320)
Automated PR created by Copilot: adds implementation and tests for specs/264 cross-tenant promotion execution.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #320
2026-05-02 14:38:20 +00:00
b05d5c52d4 spec(263): auditor-pack executive export - automated PR (#319)
Automated PR: commit workspace changes for spec 263 (auditor-pack executive export). Created by Copilot automation.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #319
2026-05-02 10:02:07 +00:00
8f1ceb70ec Add lifecycle governance taxonomy (spec 262) (#318)
Automated PR created by Copilot: adds lifecycle governance taxonomy spec and supporting docs (spec 262).

Includes new files under `specs/262-lifecycle-governance-taxonomy` and `docs/product/standards/lifecycle-governance.md`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #318
2026-05-01 23:16:13 +00:00
25e1f69513 docs: re-audit planning docs and prep guardrails (#317)
## Summary
- re-audit `docs/product/spec-candidates.md` so completed or already prepared specs are no longer exposed as active `next-best-prep` targets
- refresh `docs/product/implementation-ledger.md` to align maturity and readiness wording with current repo-backed evidence
- include the existing `spec-kit-next-best-prep` guardrail update so completed specs are not rewritten back into preparation state

## Validation
- not run (docs-only changes)

## Notes
- no files under `specs/` were modified
- no application or runtime files were modified

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #317
2026-05-01 21:07:35 +00:00
feeaadd5ad feat: add provider-missing policy visibility and restore continuity (#316)
## Summary
- separate provider-missing policy presence from local ignore semantics by introducing `missing_from_provider_at`
- update policy, backup, and restore surfaces so current-state capture stays honest while historical restore continuity remains available
- add focused sync, Filament, backup, restore, localization, and badge coverage for the new provider-missing behavior

## Scope
- policy sync and model truth
- policy resource visibility, badges, labels, and action gating
- backup/export eligibility and restore continuity messaging
- spec 261 artifacts and focused tests

## Validation
- feature-specific Pest coverage is included in the branch
- validation was not re-run as part of this commit/push/PR handoff

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #316
2026-05-01 20:18:27 +00:00
bcabb14480 commit alles (automatisch) → platform-dev (#315)
Automatisch erstellt: Commit aller Änderungen in Branch 260-governance-service-packaging-session-1777640889.
Bitte prüfen und mergen.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #315
2026-05-01 14:38:09 +00:00
eae06bfe05 fix(platform): resolve review and tenant review conflicts (#314)
Resolves the targeted Review / Evidence conflict set on top of `platform-dev` without introducing new features.

Included scope:
- keep `platform-dev` as the newer product truth for Customer Review Workspace and Review / Evidence surfaces
- retain the stable `evidence_proof` surface where still needed
- update the outdated TenantReview creation expectation to the current 7-section review structure

Validation run locally:
- `./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace`
- `./vendor/bin/sail artisan test --compact --filter=EvidenceSnapshot`
- `./vendor/bin/sail artisan test --compact --filter=ReviewPack`
- `./vendor/bin/sail artisan test --compact --filter=TenantReview`
- `./vendor/bin/sail artisan test --compact tests/Feature/TenantReview/TenantReviewCreationTest.php`
- `./vendor/bin/sail bin pint --dirty --format agent`

Follow-up integration path after merge:

`platform-dev` -> `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #314
2026-05-01 08:56:22 +00:00
866875559f feat(specs/259): compliance evidence mapping (#312)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m4s
Implements platform feature branch `259-compliance-evidence-mapping`.

Target branch: `platform-dev`.

Follow-up integration path after merge:

`platform-dev` -> `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #312
2026-04-30 21:27:49 +00:00
Ahmed Darrazi
0517305381 Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-30 20:22:09 +02:00
966b7af472 feat: productize customer review workspace (#310)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
## Summary
- productize the customer review workspace and released-review drilldown into a calmer customer-safe governance flow
- make review-pack and evidence-proof access explicit, capability-aware, and auditable in the shared Filament resources
- add focused Pest coverage, browser smoke coverage, and the full Spec 258 artifact package

## Notes
- Filament stays on v5 with Livewire v4 surfaces; no provider registration changes were introduced
- no new global-search scope, destructive action surface, or asset registration was added
- bounded additive audit action IDs were added for workspace open and evidence proof open events

## Validation
- focused Pest feature suites for workspace, review detail, review-pack, and evidence flows
- bounded browser smoke: `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #310
2026-04-30 18:15:32 +00:00
Ahmed Darrazi
1bf369b561 Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 57s
2026-04-30 16:36:03 +02:00
Ahmed Darrazi
a2bb5b7729 chore: commit all changes (automated)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 59s
2026-04-30 16:25:12 +02:00
Ahmed Darrazi
bb78049271 Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
# Conflicts:
#	apps/platform/app/Support/Navigation/CanonicalNavigationContext.php
2026-04-30 09:50:04 +02:00
7d17d39060 feat(specs/043): cross tenant compare and promotion (#307)
Implements platform feature branch `feat/043-cross-tenant-compare-and-promotion`.

Target branch: `platform-dev`.

Follow-up integration path after merge:

`platform-dev` → `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #307
2026-04-30 07:45:15 +00:00
Ahmed Darrazi
a35cd88bff Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
2026-04-30 00:43:39 +02:00
926b0fe4f3 feat(specs/257): governance decision convergence (#304)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
Automatisch erstellter PR: Implementiert Spec 257 — Governance decision convergence.

Branch: 257-governance-decision-convergence

Bitte Review und Merge gegen `platform-dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #304
2026-04-29 22:36:05 +00:00
Ahmed Darrazi
a74a6791ad Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m49s
2026-04-29 22:50:20 +02:00
52ebf63af1 feat(specs/256): external support desk handoff (#301)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 2m6s
Implement external support desk handoff (spec 256). Created and pushed branch `256-external-support-desk-handoff`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #301
2026-04-29 20:16:40 +00:00
Ahmed Darrazi
2e2b125107 Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m15s
2026-04-29 14:58:56 +02:00
Ahmed Darrazi
4b0dc2a62e chore: commit workspace changes (automated)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 53s
2026-04-29 14:56:17 +02:00
Ahmed Darrazi
34351a281d Merge remote-tracking branch 'origin/dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-29 14:37:00 +02:00
51ea80ca05 Automatische PR: 255-enforce-finding-creation-invariants → platform-dev (#298)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m5s
Automatisch erstellt: Commit & Push aus Workspace (WIP)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #298
2026-04-29 12:26:21 +00:00
Ahmed Darrazi
e36bd3ca9c merge: sync dev into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
2026-04-29 09:47:47 +02:00
b511b08371 feat: remove findings acknowledged compatibility and unify canonical operation types (#296)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m0s
This PR removes the legacy "acknowledged" status compatibility for findings and unifies the canonical operation types (e.g., transitioning from baseline_capture to baseline.capture). It includes updated tests, models, and services to reflect these changes.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #296
2026-04-29 07:34:39 +00:00
Ahmed Darrazi
f53f149f99 Merge remote-tracking branch 'origin/platform-dev' into platform-dev
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
# Conflicts:
#	.github/agents/copilot-instructions.md
2026-04-29 00:08:57 +02:00
2fa8fc0f87 refactor: remove findings lifecycle backfill runtime surfaces (#294)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 51s
## Summary
- decommission the legacy findings lifecycle backfill substrate across command, job, service, and UI layers
- remove related platform capabilities, operation catalog entries, and action surface exemptions
- add regression and removal verification tests to ensure runtime integrity and surface absence
- include spec, plan, tasks, and data-model artifacts for the removal slice

## Scope
- active spec: specs/253-remove-findings-backfill-runtime-surfaces
- target branch: dev

## Validation
- integrated regression and removal verification tests for console, findings, and system ops surfaces
- audit log and capability trace verification for the removal path

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #294
2026-04-28 22:00:51 +00:00
Ahmed Darrazi
44e6a1eb05 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 21:46:29 +02:00
Ahmed Darrazi
4f7c1a6c94 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 15:41:58 +02:00
Ahmed Darrazi
4325e1ed8d Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 12:18:08 +02:00
Ahmed Darrazi
4ae4c2ee95 chore: add gitea MCP helper script
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 58s
2026-04-28 09:26:51 +02:00
Ahmed Darrazi
32b6dcb937 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 09:22:09 +02:00
Ahmed Darrazi
f7bc4f2787 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 23:22:08 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
185 changed files with 20419 additions and 1070 deletions

View File

@ -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

View File

@ -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

View File

@ -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.
```

View File

@ -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;

View File

@ -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
*/

View 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;
}
}

View File

@ -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

View File

@ -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

View File

@ -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,
);
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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,
]));
}

View File

@ -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);

View File

@ -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')

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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(),

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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,

View File

@ -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([

View File

@ -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');
}
}

View File

@ -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);

View File

@ -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()]);

View File

@ -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();
}
}

View File

@ -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)))
: [];
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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 ?? [];

View File

@ -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,
);
}
}
/**

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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),
];

View File

@ -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;
}
}

View File

@ -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'])

View File

@ -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),

View File

@ -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',

View File

@ -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) {

View File

@ -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,

View File

@ -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';

View File

@ -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(),
};
}
}

View File

@ -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(),
};
}

View File

@ -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,
};

View File

@ -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),
};
}
}

View File

@ -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),

View File

@ -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));
}
}

View File

@ -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(

View File

@ -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));
}

View File

@ -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),

View File

@ -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';

View File

@ -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',

View File

@ -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),
};

View File

@ -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 = [],

View File

@ -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 !== ''));
}
}

View File

@ -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',
),
);

View File

@ -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',
),
);

View File

@ -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,

View File

@ -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');
});
}
};

View File

@ -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.',

View File

@ -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.',

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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()));
});

View File

@ -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();
});
});

View File

@ -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');
});

View File

@ -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')

View File

@ -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);
});

View File

@ -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');
});

View File

@ -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');

View File

@ -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);
});

View File

@ -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();

View File

@ -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.');
});

View File

@ -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')

View File

@ -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'));
});
});

View File

@ -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();

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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');

View File

@ -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();

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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');
});

View File

@ -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']);
}

View File

@ -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']);
}

View File

@ -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'])

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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();

View File

@ -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');

View File

@ -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