Compare commits

...

8 Commits

Author SHA1 Message Date
61feb48d8a chore(platform): merge platform-dev into dev (#308)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.

Refresh method in this update: merge from `origin/dev` into `platform-dev` on explicit user request.

This PR was created by agent on user request; do not merge automatically.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #308
2026-04-30 07:52:08 +00:00
905b595880 chore(sync): platform-dev → dev (#306)
Some checks failed
Main Confidence / confidence (push) Failing after 55s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
Automatisch erstellter PR: Synchronisiere `platform-dev` nach `dev`.

Enthält alle Änderungen, die aktuell in `platform-dev` vorhanden sind. Bitte Review und Merge gegen `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #306
2026-04-29 22:44:27 +00:00
7b394918ce chore(platform): merge platform-dev into dev (#302)
Some checks failed
Main Confidence / confidence (push) Failing after 1m48s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m43s
Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.

This PR was created by agent on user request; do not merge automatically.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #302
2026-04-29 20:53:36 +00:00
4b36d2c64f Automated PR: platform-dev → dev (#300)
Some checks failed
Main Confidence / confidence (push) Failing after 1m0s
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m50s
Automated PR created by Copilot. Commit: 4b0dc2a62e

This PR merges branch `platform-dev` into `dev`.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #300
2026-04-29 13:01:43 +00:00
ab9c36f21e Automatische PR: platform-dev → dev (#299)
Some checks failed
Main Confidence / confidence (push) Failing after 59s
Automatisch erstellt: Merge `platform-dev` into `dev` (via MCP)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #299
2026-04-29 12:37:48 +00:00
54fb65a63a chore: promote platform-dev to dev (#297)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
This pull request promotes the current state of `platform-dev` to the main integration branch `dev`. It includes recent features, fixes, and architectural refinements validated on the platform development track.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #297
2026-04-29 07:50:16 +00:00
29ad8852ca merge: platform-dev into dev (#295)
Some checks failed
Main Confidence / confidence (push) Failing after 1m1s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- integrate the current `platform-dev` branch into `dev`
- bring the latest platform work from the integration branch into the main development branch
- include the recent findings lifecycle backfill removal slice together with the already accumulated `platform-dev` changes

## Scope
- source branch: `platform-dev`
- target branch: `dev`
- branch role: integration PR, not a single-feature PR

## Validation
- branch state reviewed before PR creation
- `platform-dev` is ahead of `dev` with the expected integration history
- this PR intentionally carries the accumulated `platform-dev` commits into `dev`

## Notes
- this is the correct merge direction for the current workflow, where feature branches land in `platform-dev` first and `platform-dev` is then merged into `dev`
- after merging, `platform-dev` can be recreated fresh from `dev` as usual

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #295
2026-04-28 22:11:20 +00:00
7613e339c4 feat: implement platform localization v1 (#293)
Some checks failed
Main Confidence / confidence (push) Failing after 56s
## Summary
- add the localization v1 foundation with request-time locale resolution and workspace or user preference handling
- localize the first-wave platform surfaces for auth, shell, dashboards, findings, baseline compare, and review workspace chrome
- add Pest coverage for locale resolution, preference flows, fallback behavior, notifications, and governance surface localization

## Scope
- active spec: specs/252-platform-localization-v1
- target branch: dev

## Notes
- machine-readable artifacts remain invariant and are not localized in this slice
- the branch includes the related spec kit artifacts for the feature

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #293
2026-04-28 19:45:03 +00:00
231 changed files with 16339 additions and 4538 deletions

View File

@ -264,6 +264,8 @@ ## Active Technologies
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace) - PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state) - PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state) - 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.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -298,9 +300,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 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 - 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 - 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
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -0,0 +1,625 @@
---
name: platform-feature-finish
description: Commit, push, create a Gitea PR from a TenantPilot platform feature branch into platform-dev, and optionally refresh the platform-dev to dev integration PR by rebase.
---
# Skill: platform-feature-finish
## Purpose
Automate the TenantPilot platform feature completion workflow.
Trigger this skill when the user says something like:
- "alles committen pushen und PR gegen platform-dev"
- "feature fertig, bitte PR erstellen"
- "platform feature abschließen"
- "commit push PR mit Gitea MCP"
- "mach PR gegen platform-dev"
- "finish platform feature"
- "platform-dev nach dev vorbereiten"
- "platform-dev PR aktualisieren"
- "out-of-date mit dev beheben"
- "integration PR refresh"
- "platform-dev auf dev rebasen"
This skill handles:
1. Validate current Git branch
2. Commit all feature changes
3. Push current feature branch
4. Create a Gitea pull request into `platform-dev`
5. Refresh the `platform-dev``dev` integration PR when explicitly requested
6. Report the PR link and next integration step
---
## Branch Model
TenantPilot uses area branches:
```text
dev = shared integration branch
platform-dev = platform/application area integration branch
website-dev = website/marketing area integration branch
```
For platform features:
```text
platform-dev
feature branch
PR back to platform-dev
platform-dev → dev integration PR
```
Rules:
- Platform feature branches MUST target `platform-dev`.
- Do NOT target `dev` directly unless the user explicitly asks.
- Do NOT use `website-dev` for platform features.
- `platform-dev` is the default PR base for TenantPilot platform/application work.
- `dev` is the shared integration branch.
### Solo Workflow Rule
The user works alone on `platform-dev`.
For refreshing the integration branch before opening or updating the PR `platform-dev``dev`, prefer rebase over merge.
Do not repeatedly merge `origin/dev` into `platform-dev` for refresh.
Avoid creating repeated merge commits like:
```text
Merge remote-tracking branch 'origin/dev' into platform-dev
```
Use `--force-with-lease`, never plain `--force`.
If rebase conflicts occur, stop and report the conflict files.
---
## Preconditions
Before committing:
1. Confirm repository root.
2. Confirm current branch is not protected.
Protected branches:
```text
dev
platform-dev
website-dev
main
master
```
If the current branch is protected, STOP and report:
```text
Ich bin auf einem geschützten Branch. Bitte zuerst einen Feature-Branch auschecken.
```
3. Confirm remote exists.
4. Confirm there are local changes, untracked files, or unpushed commits.
5. Confirm there are no unresolved conflicts.
Do not ask for confirmation unless:
- The current branch is protected.
- Git status indicates unresolved conflicts.
- There is no remote configured.
- `.env` or other local secret/config files would be committed.
- Commit fails.
- Push fails.
- Gitea MCP PR creation fails.
---
## Required Tools
Use terminal for Git operations.
Use Gitea MCP for pull request creation.
Preferred Gitea MCP operation:
```text
create_pull_request
```
Required PR parameters:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "<current-feature-branch>",
"base": "platform-dev",
"title": "<generated-title>",
"body": "<generated-body>"
}
```
---
## Workflow
### Step 1 — Inspect Git state
Run:
```bash
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git status --porcelain
git status -sb
git config --get remote.origin.url
git log --oneline --max-count=5
```
Determine:
- repository root
- current branch
- changed files
- untracked files
- remote URL
- whether there are unpushed commits
- whether unresolved conflicts exist
If the current branch is protected, stop.
If unresolved conflicts exist, stop.
If no remote exists, stop.
---
### Step 2 — Check for local environment files
Before `git add -A`, check whether local environment/config files are modified or untracked:
```bash
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
```
If `.env` or another environment file is included, STOP and report:
```text
Achtung: Eine .env-/Environment-Datei ist geändert oder untracked. Ich committe das nicht automatisch. Bitte prüfen oder aus dem Commit entfernen.
```
Do not commit secrets or local runtime configuration.
---
### Step 3 — Build commit message
Use the current branch name.
If branch starts with a spec number, for example:
```text
256-external-support-desk-handoff
```
Generate:
```text
feat(specs/256): external support desk handoff
```
If branch does not contain a spec number, generate:
```text
feat(platform): complete <branch-name>
```
Rules:
- Use lowercase subject.
- Use feature-style subject.
- Do not include `WIP`.
- Do not include `final`.
- Do not include overly generic `updates`.
Examples:
```text
feat(specs/256): external support desk handoff
feat(specs/252): platform localization v1
feat(platform): improve tenant review workspace
```
---
### Step 4 — Commit all changes
Run:
```bash
git add -A
git commit -m "<commit-message>"
```
If there are no local changes to commit, continue only if the branch has unpushed commits.
Check unpushed commits with:
```bash
git status -sb
git log --oneline origin/<current-branch>..HEAD
```
If there are no local changes and no unpushed commits, report:
```text
Es gibt keine lokalen Änderungen und keine unpushed commits. Ich erstelle keinen leeren Commit.
```
Then continue to PR creation only if the branch already exists remotely or can be pushed.
---
### Step 5 — Push branch
Run:
```bash
git push --set-upstream origin <current-branch>
```
If the upstream already exists, this is acceptable.
Never force-push unless the user explicitly requests it.
---
### Step 6 — Create PR into platform-dev via Gitea MCP
Use Gitea MCP to create a pull request:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "<current-feature-branch>",
"base": "platform-dev",
"title": "<commit-message>",
"body": "Implements platform feature branch `<current-feature-branch>`.\n\nTarget branch: `platform-dev`.\n\nFollow-up integration path after merge:\n\n`platform-dev``dev`."
}
```
If a PR already exists for the same branch and base, do not create a duplicate.
Report the existing PR if available.
---
## Optional Step — Check platform-dev to dev PR
After creating the feature PR, check whether an open integration PR exists:
```text
platform-dev → dev
```
If a Gitea MCP list/search pull request function is available, use it.
If one exists, report:
```text
Der Folge-PR `platform-dev``dev` existiert bereits: <url>
```
If none exists, report:
```text
Nach dem Merge dieses Feature-PRs sollte der Integrations-PR `platform-dev``dev` erstellt oder aktualisiert werden.
```
Do not automatically create the `platform-dev``dev` PR unless the user explicitly asks for it.
Reason: before the feature PR is merged into `platform-dev`, the integration PR may not include the new feature yet.
---
## Integration Refresh Mode
Use this mode when the user explicitly says one of the following:
- "platform-dev nach dev vorbereiten"
- "platform-dev PR aktualisieren"
- "out-of-date mit dev beheben"
- "integration PR refresh"
- "platform-dev auf dev rebasen"
- "auch platform-dev nach dev"
- "und danach platform-dev nach dev"
- "full integration"
- "kompletten platform-dev zu dev PR machen"
- "folge-pr erstellen"
This mode prepares or updates the integration PR:
```text
platform-dev → dev
```
Because the user works alone on `platform-dev`, prefer rebase over merge.
### Integration Refresh Preconditions
Before running this mode:
1. Ensure the working tree is clean.
2. Ensure there are no unresolved conflicts.
3. Fetch remote branches.
4. Ensure `origin/platform-dev` exists.
5. Ensure `origin/dev` exists.
If the working tree is dirty, STOP and report:
```text
Der Working Tree ist nicht sauber. Bitte erst Änderungen committen, stashen oder verwerfen, bevor `platform-dev` auf `dev` rebased wird.
```
If unresolved conflicts exist, STOP and report the conflict files.
### Integration Refresh Workflow
Run:
```bash
git fetch origin
git checkout platform-dev
git reset --hard origin/platform-dev
git rebase origin/dev
git push --force-with-lease origin platform-dev
```
After pushing, verify that `origin/dev` is now an ancestor of `origin/platform-dev`:
```bash
git fetch origin
git merge-base --is-ancestor origin/dev origin/platform-dev \
&& echo "OK: platform-dev contains dev" \
|| echo "OUTDATED: platform-dev does not contain dev"
```
If the verification prints `OUTDATED`, stop and report it. Do not claim the PR is up-to-date.
Rules:
- Do not merge `origin/dev` into `platform-dev` for this refresh.
- Do not create repeated merge commits from `origin/dev` into `platform-dev`.
- Use `git push --force-with-lease origin platform-dev` after a successful rebase.
- Never use plain `git push --force`.
- If `git rebase origin/dev` reports conflicts, stop immediately.
- Do not continue to PR creation while a rebase is unresolved.
- Do not auto-merge the PR.
- Do not claim Gitea will remove the out-of-date warning unless the ancestor check succeeds.
If rebase conflicts occur, report:
```text
Rebase-Konflikte erkannt. Ich habe gestoppt.
Konfliktdateien:
<files>
Bitte Konflikte lösen, dann `git rebase --continue` ausführen oder den Rebase mit `git rebase --abort` abbrechen.
```
### Create or Report Integration PR
After the rebase, push, and ancestor verification succeeded, use Gitea MCP to create or report the integration PR:
```json
{
"owner": "ahmido",
"repo": "TenantAtlas",
"head": "platform-dev",
"base": "dev",
"title": "chore(platform): merge platform-dev into dev",
"body": "Integrates latest TenantPilot platform changes from `platform-dev` into `dev`.\n\nThis PR was created by agent on user request; do not merge automatically."
}
```
If an open PR already exists for `platform-dev``dev`, do not create a duplicate. Report the existing PR.
### Integration Refresh Reporting Format
Final response for this mode must include:
```text
Fertig.
- Branch aktualisiert: platform-dev
- Refresh-Methode: rebase auf origin/dev
- Ancestor-Check: origin/dev ist Ancestor von origin/platform-dev
- Push: --force-with-lease origin/platform-dev
- Integration PR: <url>
- Base: dev
- Hinweis: PR wurde nicht automatisch gemerged.
```
Do not claim tests passed unless they were actually executed.
---
## Reporting Format
Final response must be concise and include:
```text
Fertig.
- Branch: <branch>
- Commit: <commit-sha or "keine neuen Änderungen">
- Push: origin/<branch>
- PR: <url>
- Base: platform-dev
- Nächster Schritt: Nach Merge `platform-dev``dev` PR aktualisieren/erstellen
```
If tests were not run, say:
```text
Tests wurden in diesem Skill nicht automatisch ausgeführt.
```
Do not claim tests passed unless the tool actually ran them.
---
## Safety Rules
- Never commit directly to `dev`, `platform-dev`, `website-dev`, `main`, or `master`.
- Never force-push unless explicitly requested.
- For Integration Refresh Mode only, `git push --force-with-lease origin platform-dev` is allowed because the user works alone on `platform-dev`; never use plain `--force`.
- Never auto-merge PRs unless explicitly requested.
- Never target `dev` directly for platform feature PRs unless explicitly requested.
- Never delete branches unless explicitly requested.
- Never claim tests were run unless the tool actually ran them.
- Never commit `.env`, secrets, local tokens, local mock-server configuration, or temporary runtime-only changes.
- If migrations were created, mention that the target environment needs migration execution after deployment.
- If unresolved conflicts exist, stop.
---
## Useful Commands
Inspect:
```bash
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git status --porcelain
git status -sb
git config --get remote.origin.url
```
Detect protected branch:
```bash
branch="$(git rev-parse --abbrev-ref HEAD)"
case "$branch" in
dev|platform-dev|website-dev|main|master)
echo "PROTECTED_BRANCH:$branch"
exit 2
;;
esac
```
Detect unresolved conflicts:
```bash
git diff --name-only --diff-filter=U
```
Detect `.env` changes:
```bash
git status --porcelain | grep -E '(^.. \.env$|^.. apps/platform/\.env$|^.. .*\.env$)' || true
```
Commit:
```bash
git add -A
git commit -m "<message>"
```
Push:
```bash
git push --set-upstream origin "$(git rev-parse --abbrev-ref HEAD)"
```
Latest commit:
```bash
git rev-parse --short HEAD
git log -1 --pretty=%s
```
Integration refresh:
```bash
git fetch origin
git checkout platform-dev
git reset --hard origin/platform-dev
git rebase origin/dev
git push --force-with-lease origin platform-dev
```
Verify integration refresh:
```bash
git fetch origin
git merge-base --is-ancestor origin/dev origin/platform-dev \
&& echo "OK: platform-dev contains dev" \
|| echo "OUTDATED: platform-dev does not contain dev"
```
Check rebase conflicts:
```bash
git diff --name-only --diff-filter=U
```
---
## Example User Request
User:
```text
alles committen pushen und pr gegen platform-dev mit gitea mcp
```
Assistant should:
1. Check current branch.
2. Stop if branch is protected.
3. Stop if `.env` or secrets would be committed.
4. Commit all changes.
5. Push current branch.
6. Create PR into `platform-dev` with Gitea MCP.
7. Report result.
Do not ask unnecessary follow-up questions.
---
## Example Integration Refresh Request
User:
```text
platform-dev PR aktualisieren
```
Assistant should:
1. Ensure the working tree is clean.
2. Fetch origin.
3. Checkout `platform-dev`.
4. Reset local `platform-dev` to `origin/platform-dev`.
5. Rebase `platform-dev` onto `origin/dev`.
6. Push with `--force-with-lease`.
7. Verify `origin/dev` is an ancestor of `origin/platform-dev`.
8. Create or report the PR `platform-dev``dev`.
9. Report result.
Do not merge the PR automatically.

View File

@ -59,6 +59,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
SUPPORT_DESK_ENABLED=false
SUPPORT_DESK_NAME="External support desk"
SUPPORT_DESK_CREATE_URL=
SUPPORT_DESK_API_TOKEN=
SUPPORT_DESK_TICKET_URL_TEMPLATE=
SUPPORT_DESK_TIMEOUT_SECONDS=5
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1

View File

@ -6,12 +6,14 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command class PurgeLegacyBaselineGapRuns extends Command
{ {
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs} {--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids} {--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids} {--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect} {--limit=500 : Maximum candidate runs to inspect}
@ -99,21 +101,35 @@ public function handle(): int
*/ */
private function normalizedTypes(): array private function normalizedTypes(): array
{ {
$types = array_values(array_unique(array_filter( $requestedTypes = array_values(array_unique(array_filter(
array_map( array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null, static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'), (array) $this->option('type'),
), ),
))); )));
if ($types === []) { $canonicalTypes = array_values(array_unique(array_filter(array_map(
return ['baseline_compare', 'baseline_capture']; static fn (string $type): ?string => match ($type) {
OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value,
default => null,
},
$requestedTypes,
))));
if ($canonicalTypes === []) {
$canonicalTypes = [
OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value,
];
} }
return array_values(array_filter( return array_values(array_unique(array_merge(
$types, ...array_map(
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true), static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type),
)); $canonicalTypes,
),
)));
} }
/** /**

View File

@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\OperationalControls\OperationalControlBlockedException;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotBackfillFindingLifecycle extends Command
{
protected $signature = 'tenantpilot:findings:backfill-lifecycle
{--tenant=* : Limit to tenant_id/external_id}';
protected $description = 'Queue tenant-scoped findings lifecycle backfill jobs idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
if ($tenantIdentifiers === []) {
$this->error('Provide one or more tenants via --tenant={id|external_id}.');
return self::FAILURE;
}
$tenants = $this->resolveTenants($tenantIdentifiers);
if ($tenants->isEmpty()) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$queued = 0;
$skipped = 0;
$nothingToDo = 0;
foreach ($tenants as $tenant) {
if (! $tenant instanceof Tenant) {
continue;
}
try {
$run = $runbookService->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: null,
reason: null,
source: 'cli',
);
} catch (OperationalControlBlockedException $e) {
$this->error(sprintf(
'Backfill paused for tenant %d: %s',
(int) $tenant->getKey(),
$e->getMessage(),
));
return self::FAILURE;
} catch (ValidationException $e) {
$errors = $e->errors();
if (isset($errors['preflight.affected_count'])) {
$nothingToDo++;
continue;
}
$this->error(sprintf(
'Backfill blocked for tenant %d: %s',
(int) $tenant->getKey(),
$e->getMessage(),
));
return self::FAILURE;
}
if (! $run->wasRecentlyCreated) {
$skipped++;
continue;
}
$queued++;
}
$this->info(sprintf(
'Queued %d backfill run(s), skipped %d duplicate run(s), nothing to do %d.',
$queued,
$skipped,
$nothingToDo,
));
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return \Illuminate\Support\Collection<int, Tenant>
*/
private function resolveTenants(array $tenantIdentifiers)
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant instanceof Tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
$tenantIds = array_values(array_unique($tenantIds));
if ($tenantIds === []) {
return collect();
}
return Tenant::query()
->whereIn('id', $tenantIds)
->orderBy('id')
->get();
}
}

View File

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason;
use App\Support\OperationalControls\OperationalControlBlockedException;
use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException;
class TenantpilotRunDeployRunbooks extends Command
{
protected $signature = 'tenantpilot:run-deploy-runbooks';
protected $description = 'Run deploy-time runbooks idempotently.';
public function handle(FindingsLifecycleBackfillRunbookService $runbookService): int
{
try {
$runbookService->start(
scope: FindingsLifecycleBackfillScope::allTenants(),
initiator: null,
reason: new RunbookReason(
reasonCode: RunbookReason::CODE_DATA_REPAIR,
reasonText: 'Deploy hook automated runbooks',
),
source: 'deploy_hook',
);
$this->info('Deploy runbooks started (if needed).');
return self::SUCCESS;
} catch (OperationalControlBlockedException $e) {
$this->info('Deploy runbooks paused: '.$e->getMessage());
return self::SUCCESS;
} catch (ValidationException $e) {
$errors = $e->errors();
$skippable = isset($errors['preflight.affected_count']) || isset($errors['scope']);
if ($skippable) {
$this->info('Deploy runbooks skipped (nothing to do or already running).');
return self::SUCCESS;
}
$this->error('Deploy runbooks blocked by validation errors.');
return self::FAILURE;
}
}
}

View File

@ -9,4 +9,9 @@
class Login extends BaseLogin class Login extends BaseLogin
{ {
protected string $view = 'filament.pages.auth.login'; protected string $view = 'filament.pages.auth.login';
public function getTitle(): string
{
return __('localization.auth.sign_in_microsoft');
}
} }

View File

@ -0,0 +1,674 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioCompare\CrossTenantComparePreviewBuilder;
use App\Support\PortfolioCompare\CrossTenantCompareSelection;
use App\Support\PortfolioCompare\CrossTenantPromotionPreflight;
use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
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 Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum;
class CrossTenantComparePage extends Page implements HasForms
{
use InteractsWithForms;
private const string SOURCE_TENANT_QUERY_KEY = 'source_tenant_id';
private const string TARGET_TENANT_QUERY_KEY = 'target_tenant_id';
private const string POLICY_TYPE_QUERY_KEY = 'policy_type';
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $title = 'Cross-Tenant Compare';
protected static ?string $slug = 'cross-tenant-compare';
protected string $view = 'filament.pages.cross-tenant-compare';
public ?string $sourceTenantId = null;
public ?string $targetTenantId = null;
/**
* @var list<string>
*/
public array $selectedPolicyTypes = [];
/**
* @var array<string, mixed>|null
*/
public ?array $navigationContextPayload = null;
/**
* @var array<string, mixed>|null
*/
public ?array $preview = null;
/**
* @var array<string, mixed>|null
*/
public ?array $preflight = null;
public ?string $selectionMessage = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve return navigation, tenant drill-downs, and one dominant promotion-preflight action.')
->exempt(ActionSurfaceSlot::InspectAffordance, 'The compare page uses explicit selection controls instead of row-click inspection.')
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Cross-tenant compare renders focused subject summaries instead of row-level overflow actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The compare page has no bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The compare page explains when a selection is incomplete or invalid before any preview exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Cross-tenant compare is a workspace decision page, not a record detail header.');
}
public function mount(): void
{
$this->authorizePageAccess();
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
$this->hydrateSelectionFromRequest();
$this->refreshPreview();
$this->form->fill($this->formState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 3,
])
->schema([
Select::make('sourceTenantId')
->label('Source tenant')
->options(fn (): array => $this->tenantOptions())
->searchable()
->preload()
->native(false)
->placeholder('Select a source tenant')
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-source'])
->extraInputAttributes(['data-testid' => 'cross-tenant-source']),
Select::make('targetTenantId')
->label('Target tenant')
->options(fn (): array => $this->tenantOptions())
->searchable()
->preload()
->native(false)
->placeholder('Select a target tenant')
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-target'])
->extraInputAttributes(['data-testid' => 'cross-tenant-target']),
Select::make('selectedPolicyTypes')
->label('Governed subjects')
->options(fn (): array => $this->policyTypeOptions())
->multiple()
->searchable()
->preload()
->native(false)
->placeholder('All governed subjects')
->helperText(fn (): ?string => $this->policyTypeOptions() === []
? 'Governed subject filters appear after authorized tenant inventory exists in the active workspace.'
: null)
->extraFieldWrapperAttributes(['data-testid' => 'cross-tenant-policy-types'])
->extraInputAttributes(['data-testid' => 'cross-tenant-policy-types']),
]),
]);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$actions = [];
$navigationContext = $this->navigationContext();
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('return_to_origin')
->label($navigationContext->backLinkLabel)
->icon('heroicon-o-arrow-left')
->color('gray')
->url($navigationContext->backLinkUrl);
}
$sourceTenant = $this->selectedSourceTenant();
if ($sourceTenant instanceof Tenant) {
$actions[] = Action::make('open_source_tenant')
->label('Open source tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $sourceTenant], panel: 'admin'));
}
$targetTenant = $this->selectedTargetTenant();
if ($targetTenant instanceof Tenant) {
$actions[] = Action::make('open_target_tenant')
->label('Open target tenant')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(TenantResource::getUrl('view', ['record' => $targetTenant], panel: 'admin'));
}
$preflightAction = Action::make('generatePromotionPreflight')
->label('Generate promotion preflight')
->icon('heroicon-o-sparkles')
->color('primary')
->disabled(fn (): bool => $this->preflightDisabledReason() !== null)
->tooltip(fn (): ?string => $this->preflightDisabledReason())
->action(fn (): mixed => $this->generatePromotionPreflight());
$preflightAction = WorkspaceUiEnforcement::forAction(
$preflightAction,
fn (): ?Workspace => $this->workspace(),
)
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
->preserveDisabled()
->tooltip('You need workspace baseline manage access to generate a promotion preflight.')
->apply()
->tooltip(function (): ?string {
$user = auth()->user();
$workspace = $this->workspace();
if ($user instanceof User && $workspace instanceof Workspace) {
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if ($resolver->isMember($user, $workspace)
&& ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
return 'You need workspace baseline manage access to generate a promotion preflight.';
}
}
return $this->preflightDisabledReason();
});
$actions[] = $preflightAction;
return $actions;
}
public function applySelection(): void
{
$this->selectionMessage = null;
$this->preflight = null;
$this->sourceTenantId = $this->normalizeTenantIdentifier($this->sourceTenantId);
$this->targetTenantId = $this->normalizeTenantIdentifier($this->targetTenantId);
$this->selectedPolicyTypes = $this->normalizePolicyTypes($this->selectedPolicyTypes);
if ($this->sourceTenantId !== null
&& $this->targetTenantId !== null
&& $this->sourceTenantId === $this->targetTenantId) {
$this->selectionMessage = 'Choose two different tenants.';
$this->addError('targetTenantId', $this->selectionMessage);
return;
}
$this->redirect($this->selectionUrl(), navigate: true);
}
public function generatePromotionPreflight(): void
{
$this->authorizePageAccess();
$this->authorizePreflightExecution();
if ($this->preview === null) {
$this->refreshPreview();
}
if ($this->preview === null) {
return;
}
$selection = $this->compareSelection();
if (! $selection instanceof CrossTenantCompareSelection) {
return;
}
$this->preflight = app(CrossTenantPromotionPreflight::class)->build($this->preview);
$workspace = $this->workspace();
$user = auth()->user();
if ($workspace instanceof Workspace && $user instanceof User) {
app(WorkspaceAuditLogger::class)->logCrossTenantPromotionPreflightGenerated(
workspace: $workspace,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
preflight: $this->preflight,
actor: $user,
);
}
}
public function clearSelectionUrl(): string
{
return static::getUrl($this->routeParameters([
self::SOURCE_TENANT_QUERY_KEY => null,
self::TARGET_TENANT_QUERY_KEY => null,
self::POLICY_TYPE_QUERY_KEY => null,
]), panel: 'admin');
}
public function selectionUrl(): string
{
return static::getUrl($this->routeParameters(), panel: 'admin');
}
public static function launchUrl(
?Tenant $sourceTenant = null,
?Tenant $targetTenant = null,
?CanonicalNavigationContext $navigationContext = null,
): string {
$parameters = [];
if ($sourceTenant instanceof Tenant) {
$parameters[self::SOURCE_TENANT_QUERY_KEY] = (int) $sourceTenant->getKey();
}
if ($targetTenant instanceof Tenant) {
$parameters[self::TARGET_TENANT_QUERY_KEY] = (int) $targetTenant->getKey();
}
if ($navigationContext instanceof CanonicalNavigationContext) {
$parameters = array_replace($parameters, $navigationContext->toQuery());
}
return static::getUrl($parameters, panel: 'admin');
}
public function hasActiveSelection(): bool
{
return $this->sourceTenantId !== null
|| $this->targetTenantId !== null
|| $this->selectedPolicyTypes !== [];
}
public function stateColor(string $state): string
{
return match ($state) {
'match', 'ready' => 'success',
'different', 'manual_mapping_required' => 'warning',
'missing' => 'info',
'ambiguous' => 'gray',
'blocked' => 'danger',
default => 'gray',
};
}
public function stateLabel(string $value): string
{
return Str::headline(str_replace('_', ' ', $value));
}
public function reasonLabel(string $reasonCode): string
{
return Str::headline(str_replace('_', ' ', $reasonCode));
}
public function sourceTenantUrl(): ?string
{
$tenant = $this->selectedSourceTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}
public function targetTenantUrl(): ?string
{
$tenant = $this->selectedTargetTenant();
if (! $tenant instanceof Tenant) {
return null;
}
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}
/**
* @return array<string, mixed>
*/
private function formState(): array
{
return [
'sourceTenantId' => $this->sourceTenantId,
'targetTenantId' => $this->targetTenantId,
'selectedPolicyTypes' => $this->selectedPolicyTypes,
];
}
private function hydrateSelectionFromRequest(): void
{
$this->sourceTenantId = $this->normalizeTenantIdentifier(request()->query(self::SOURCE_TENANT_QUERY_KEY));
$this->targetTenantId = $this->normalizeTenantIdentifier(request()->query(self::TARGET_TENANT_QUERY_KEY));
$this->selectedPolicyTypes = $this->normalizePolicyTypes(request()->query(self::POLICY_TYPE_QUERY_KEY, []));
}
private function refreshPreview(): void
{
$this->selectionMessage = null;
$this->preview = null;
$this->preflight = null;
$selection = $this->compareSelection();
if (! $selection instanceof CrossTenantCompareSelection) {
return;
}
$this->preview = app(CrossTenantComparePreviewBuilder::class)->build($selection);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
abort(403);
}
}
private function authorizePreflightExecution(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
abort(404);
}
/** @var WorkspaceCapabilityResolver $resolver */
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
abort(404);
}
if (! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
abort(403);
}
}
private function compareSelection(): ?CrossTenantCompareSelection
{
$sourceTenant = $this->selectedSourceTenant();
$targetTenant = $this->selectedTargetTenant();
if (! $sourceTenant instanceof Tenant || ! $targetTenant instanceof Tenant) {
return null;
}
if ((int) $sourceTenant->getKey() === (int) $targetTenant->getKey()) {
$this->selectionMessage = 'Choose two different tenants.';
return null;
}
return new CrossTenantCompareSelection(
sourceTenant: $sourceTenant,
targetTenant: $targetTenant,
policyTypes: $this->selectedPolicyTypes,
);
}
private function selectedSourceTenant(): ?Tenant
{
if ($this->sourceTenantId === null) {
return null;
}
return $this->resolveAuthorizedTenant($this->sourceTenantId);
}
private function selectedTargetTenant(): ?Tenant
{
if ($this->targetTenantId === null) {
return null;
}
return $this->resolveAuthorizedTenant($this->targetTenantId);
}
private function resolveAuthorizedTenant(string $tenantId): Tenant
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
abort(404);
}
$tenant = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereKey((int) $tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $user->canAccessTenant($tenant) || ! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
abort(404);
}
return $tenant;
}
/**
* @return array<string, string>
*/
private function tenantOptions(): array
{
$workspace = $this->workspace();
$user = auth()->user();
if (! $workspace instanceof Workspace || ! $user instanceof User) {
return [];
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$tenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->select('tenants.*')
->orderBy('tenants.name')
->get();
$resolver->primeMemberships($user, $tenants->modelKeys());
return $tenants
->filter(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->mapWithKeys(fn (Tenant $tenant): array => [
(string) $tenant->getKey() => (string) $tenant->name,
])
->all();
}
/**
* @return array<string, string>
*/
private function policyTypeOptions(): array
{
$tenantIds = array_map(static fn (string $tenantId): int => (int) $tenantId, array_keys($this->tenantOptions()));
if ($tenantIds === []) {
return [];
}
return InventoryItem::query()
->whereIn('tenant_id', $tenantIds)
->whereNotNull('policy_type')
->where('policy_type', '!=', '')
->distinct()
->orderBy('policy_type')
->pluck('policy_type')
->mapWithKeys(fn (string $policyType): array => [
$policyType => Str::headline($policyType),
])
->all();
}
private function preflightDisabledReason(): ?string
{
if ($this->selectionMessage !== null) {
return $this->selectionMessage;
}
if (! is_array($this->preview)) {
return 'Select an authorized source and target tenant to generate a promotion preflight.';
}
if ((int) data_get($this->preview, 'summary.total', 0) === 0) {
return 'No governed subjects are available for this compare selection yet.';
}
return null;
}
/**
* @param mixed $value
*/
private function normalizeTenantIdentifier(mixed $value): ?string
{
if (! is_string($value) && ! is_int($value)) {
return null;
}
$normalized = trim((string) $value);
return is_numeric($normalized) && (int) $normalized > 0 ? (string) (int) $normalized : null;
}
/**
* @param mixed $value
* @return list<string>
*/
private function normalizePolicyTypes(mixed $value): array
{
$allowed = array_fill_keys(array_keys($this->policyTypeOptions()), true);
$values = match (true) {
is_string($value) && $value !== '' => [$value],
is_array($value) => $value,
default => [],
};
return array_values(array_filter(array_unique(array_map(
static fn (mixed $item): string => is_string($item) ? trim($item) : '',
$values,
)), static fn (string $item): bool => $item !== '' && isset($allowed[$item])));
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function routeParameters(array $overrides = []): array
{
$parameters = [
self::SOURCE_TENANT_QUERY_KEY => $this->sourceTenantId,
self::TARGET_TENANT_QUERY_KEY => $this->targetTenantId,
self::POLICY_TYPE_QUERY_KEY => $this->selectedPolicyTypes,
];
if (is_array($this->navigationContextPayload)) {
$parameters['nav'] = $this->navigationContextPayload;
}
foreach ($overrides as $key => $value) {
$parameters[$key] = $value;
}
return array_filter($parameters, static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
private function navigationContext(): ?CanonicalNavigationContext
{
if (! is_array($this->navigationContextPayload)) {
return CanonicalNavigationContext::fromRequest(request());
}
return CanonicalNavigationContext::fromPayload($this->navigationContextPayload);
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
return $workspace instanceof Workspace ? $workspace : null;
}
}

View File

@ -105,14 +105,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter') ->label('Clear tenant filter')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()), ->action(fn (): mixed => $this->clearTenantFilter());
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -698,6 +710,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(array $overrides = []): string private function queueUrl(array $overrides = []): string
{ {
$resolvedTenant = array_key_exists('tenant', $overrides) $resolvedTenant = array_key_exists('tenant', $overrides)

View File

@ -97,14 +97,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_tenant_filter')
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter') ->label('Clear tenant filter')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null) ->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()), ->action(fn (): mixed => $this->clearTenantFilter());
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -640,6 +652,15 @@ private function navigationContext(): CanonicalNavigationContext
); );
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = CanonicalNavigationContext::fromRequest(request());
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function queueUrl(): string private function queueUrl(): string
{ {
$tenant = $this->filteredTenant(); $tenant = $this->filteredTenant();

View File

@ -75,6 +75,8 @@ class GovernanceInbox extends Page
private ?bool $visibleAlertsFamily = null; private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = null;
public ?int $tenantId = null; public ?int $tenantId = null;
public ?string $family = null; public ?string $family = null;
@ -189,12 +191,11 @@ public function pageUrl(array $overrides = []): string
public function navigationContext(): CanonicalNavigationContext public function navigationContext(): CanonicalNavigationContext
{ {
return new CanonicalNavigationContext( return CanonicalNavigationContext::forGovernanceInbox(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')), canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId, tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(), backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
); );
} }
@ -223,6 +224,7 @@ private function ensureAtLeastOneVisibleFamily(): void
if ( if (
$this->hasVisibleOperationsFamily() $this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== [] || $this->visibleFindingTenants() !== []
|| $this->hasVisibleFindingExceptionsFamily()
|| $this->reviewTenants() !== [] || $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily() || $this->hasVisibleAlertsFamily()
) { ) {
@ -266,6 +268,27 @@ private function hasVisibleAlertsFamily(): bool
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW); return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
} }
private function hasVisibleFindingExceptionsFamily(): bool
{
if (is_bool($this->visibleFindingExceptionsFamily)) {
return $this->visibleFindingExceptionsFamily;
}
if ($this->authorizedTenants() === []) {
return $this->visibleFindingExceptionsFamily = false;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleFindingExceptionsFamily = false;
}
return $this->visibleFindingExceptionsFamily = app(WorkspaceCapabilityResolver::class)
->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
}
/** /**
* @return array<int, Tenant> * @return array<int, Tenant>
*/ */
@ -375,6 +398,7 @@ private function resolveRequestedFamily(): ?string
return in_array($family, [ return in_array($family, [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -424,6 +448,7 @@ private function inboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(), selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family, selectedFamily: $this->family,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),
@ -458,6 +483,7 @@ private function unfilteredInboxPayload(): array
visibleFindingTenants: $this->visibleFindingTenants(), visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(), reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(), canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null, selectedTenant: null,
selectedFamily: null, selectedFamily: null,
navigationContext: $this->navigationContext(), navigationContext: $this->navigationContext(),

View File

@ -208,6 +208,16 @@ protected function getHeaderActions(): array
returnActionName: 'operate_hub_return_finding_exceptions', returnActionName: 'operate_hub_return_finding_exceptions',
); );
$governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters') $actions[] = Action::make('clear_filters')
->label('Clear filters') ->label('Clear filters')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
return null; return null;
} }
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function selectedFindingUrl(): ?string public function selectedFindingUrl(): ?string
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
return null; return null;
} }
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant); return $this->appendQuery(
FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
$this->navigationContext()?->toQuery() ?? [],
);
} }
public function clearSelectedException(): void public function clearSelectedException(): void
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
return CanonicalNavigationContext::fromRequest(request()); return CanonicalNavigationContext::fromRequest(request());
} }
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function normalizeSelectedFindingExceptionId(): void private function normalizeSelectedFindingExceptionId(): void
{ {
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) { if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
return 'danger'; return 'danger';
} }
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
}
} }

View File

@ -31,6 +31,7 @@
use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService; use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
@ -49,6 +50,7 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
@ -267,42 +269,73 @@ public function authorizeOperationRunSupportRequest(): void
private function requestSupportAction(): Action private function requestSupportAction(): Action
{ {
$action = Action::make('requestSupport') $action = Action::make('requestSupport')
->label('Request support') ->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->record($this->run) ->record($this->run)
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Request support') ->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.') ->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel('Submit support request') ->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([ ->form([
Placeholder::make('primary_context') Placeholder::make('primary_context')
->label('Primary context') ->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run)) ->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('included_context') Placeholder::make('included_context')
->label('Included context') ->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->operationSupportRequestAttachmentSummary()) ->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->operationLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity') Select::make('severity')
->label('Severity') ->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions()) ->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL) ->default(SupportRequest::SEVERITY_NORMAL)
->required() ->required()
->native(false), ->native(false),
TextInput::make('summary') TextInput::make('summary')
->label('Summary') ->label(__('localization.dashboard.summary'))
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
Textarea::make('reproduction_notes') Textarea::make('reproduction_notes')
->label('Reproduction notes') ->label(__('localization.dashboard.reproduction_notes'))
->rows(4) ->rows(4)
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('contact_name') TextInput::make('contact_name')
->label('Contact name') ->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name), ->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email') TextInput::make('contact_email')
->label('Contact email') ->label(__('localization.dashboard.contact_email'))
->email() ->email()
->default(fn (): ?string => $this->resolveViewerActor()->email), ->default(fn (): ?string => $this->resolveViewerActor()->email),
]) ])
@ -312,9 +345,21 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data); $supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make() Notification::make()
->title('Support request submitted') ->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference) ->body($this->supportRequestNotificationBody($supportRequest))
->success() ->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send(); ->send();
}); });
@ -414,6 +459,98 @@ private function operationSupportRequestAttachmentSummary(): string
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.'; : 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
} }
private function operationLatestSupportRequestHandoffSummary(): string
{
$user = $this->resolveViewerActor();
$summary = app(SupportRequestSubmissionService::class)->latestOperationRunHandoffSummary($this->run, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
/** /**
* @param array<string, mixed> $bundle * @param array<string, mixed> $bundle
*/ */

View File

@ -15,7 +15,13 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
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\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome; use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
@ -57,6 +63,31 @@ class CustomerReviewWorkspace extends Page implements HasTable
protected string $view = 'filament.pages.reviews.customer-review-workspace'; protected string $view = 'filament.pages.reviews.customer-review-workspace';
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)
->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.');
}
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.customer_reviews');
}
public function getTitle(): string
{
return __('localization.review.customer_review_workspace');
}
public static function tenantPrefilterUrl(Tenant $tenant): string public static function tenantPrefilterUrl(Tenant $tenant): string
{ {
$tenantIdentifier = filled($tenant->external_id) $tenantIdentifier = filled($tenant->external_id)
@ -82,16 +113,28 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ $actions = [];
Action::make('clear_filters')
->label('Clear filters') $governanceContext = $this->incomingGovernanceContext();
if ($governanceContext?->backLinkUrl !== null) {
$actions[] = Action::make('return_to_governance_inbox')
->label($governanceContext->backLinkLabel ?? 'Back to governance inbox')
->icon('heroicon-o-arrow-left')
->color('gray')
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_filters')
->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void { ->action(function (): void {
$this->clearWorkspaceFilters(); $this->clearWorkspaceFilters();
}), });
];
return $actions;
} }
public function table(Table $table): Table public function table(Table $table): Table
@ -105,9 +148,9 @@ public function table(Table $table): Table
->persistSortInSession() ->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([ ->columns([
TextColumn::make('name')->label('Tenant')->searchable()->sortable(), TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review') TextColumn::make('latest_review')
->label('Latest review') ->label(__('localization.review.latest_review'))
->badge() ->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record)) ->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record)) ->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
@ -116,25 +159,25 @@ public function table(Table $table): Table
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record)) ->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(), ->wrap(),
TextColumn::make('finding_summary') TextColumn::make('finding_summary')
->label('Key findings') ->label(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record)) ->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(), ->wrap(),
TextColumn::make('accepted_risk_summary') TextColumn::make('accepted_risk_summary')
->label('Accepted risks') ->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record)) ->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(), ->wrap(),
TextColumn::make('published_at') TextColumn::make('published_at')
->label('Published') ->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString()) ->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime() ->dateTime()
->placeholder('—'), ->placeholder('—'),
TextColumn::make('review_pack_state') TextColumn::make('review_pack_state')
->label('Review pack') ->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)), ->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
]) ])
->filters([ ->filters([
SelectFilter::make('tenant_id') SelectFilter::make('tenant_id')
->label('Tenant') ->label(__('localization.review.tenant'))
->options(fn (): array => $this->tenantFilterOptions()) ->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter()) ->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
@ -148,25 +191,25 @@ public function table(Table $table): Table
]) ])
->actions([ ->actions([
Action::make('open_latest_review') Action::make('open_latest_review')
->label('Open latest review') ->label(__('localization.review.open_latest_review'))
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record)) ->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview), ->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack') Action::make('download_review_pack')
->label('Download review pack') ->label(__('localization.review.download_review_pack'))
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record)) ->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab() ->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))), ->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No entitled tenants match this view') ->emptyStateHeading(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters() ->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.' ? __('localization.review.clear_filters_description')
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.') : __('localization.review.adjust_filters_description'))
->emptyStateActions([ ->emptyStateActions([
Action::make('clear_filters_empty') Action::make('clear_filters_empty')
->label('Clear filters') ->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->color('gray') ->color('gray')
->visible(fn (): bool => $this->hasActiveFilters()) ->visible(fn (): bool => $this->hasActiveFilters())
@ -318,9 +361,13 @@ private function latestReviewUrl(Tenant $tenant): ?string
return null; return null;
} }
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([ return $this->appendQuery(
self::DETAIL_CONTEXT_QUERY_KEY => 1, TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
]); array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
$this->navigationContext()?->toQuery() ?? [],
),
);
} }
private function latestReviewPack(Tenant $tenant): ?ReviewPack private function latestReviewPack(Tenant $tenant): ?ReviewPack
@ -387,7 +434,7 @@ private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
private function latestReviewStateLabel(Tenant $tenant): string private function latestReviewStateLabel(Tenant $tenant): string
{ {
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review'; return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
} }
private function latestReviewStateColor(Tenant $tenant): string private function latestReviewStateColor(Tenant $tenant): string
@ -410,7 +457,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason; $primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
@ -427,7 +474,7 @@ private function reviewOutcomeDescription(Tenant $tenant): ?string
return $primaryReason; return $primaryReason;
} }
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.'); return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
} }
private function findingSummary(Tenant $tenant): string private function findingSummary(Tenant $tenant): string
@ -435,7 +482,7 @@ private function findingSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -444,14 +491,17 @@ private function findingSummary(Tenant $tenant): string
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes); $terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) { if ($findingCount === 0) {
return 'No findings recorded in the published review.'; return __('localization.review.no_findings_recorded');
} }
if ($terminalOutcomes === null) { if ($terminalOutcomes === null) {
return sprintf('%d findings summarized in the published review.', $findingCount); return __('localization.review.findings_count_summary', ['count' => $findingCount]);
} }
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes); return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
} }
private function acceptedRiskSummary(Tenant $tenant): string private function acceptedRiskSummary(Tenant $tenant): string
@ -459,7 +509,7 @@ private function acceptedRiskSummary(Tenant $tenant): string
$review = $this->latestPublishedReview($tenant); $review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) { if (! $review instanceof TenantReview) {
return 'No published review available yet'; return __('localization.review.no_published_review_available');
} }
$summary = is_array($review->summary) ? $review->summary : []; $summary = is_array($review->summary) ? $review->summary : [];
@ -469,10 +519,10 @@ private function acceptedRiskSummary(Tenant $tenant): string
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0); $warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) { return match (true) {
$statusMarkedCount === 0 => 'No accepted risks recorded.', $statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount), $warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount), $validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
default => sprintf('%d accepted risks are on record.', $statusMarkedCount), default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
}; };
} }
@ -481,17 +531,43 @@ private function reviewPackAvailability(Tenant $tenant): string
$pack = $this->latestReviewPack($tenant); $pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) { if (! $pack instanceof ReviewPack) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->status !== ReviewPackStatus::Ready->value) { if ($pack->status !== ReviewPackStatus::Ready->value) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
if ($pack->expires_at !== null && $pack->expires_at->isPast()) { if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return 'Unavailable'; return __('localization.review.unavailable');
} }
return 'Available'; return __('localization.review.available');
}
private function navigationContext(): ?CanonicalNavigationContext
{
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
} }
} }

View File

@ -12,6 +12,7 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
@ -58,6 +59,7 @@ class WorkspaceSettings extends Page
*/ */
private const SETTING_FIELDS = [ private const SETTING_FIELDS = [
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'], 'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', 'type' => 'string'],
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'], 'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'], 'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'], 'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -153,17 +155,22 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('save') Action::make('save')
->label('Save') ->label(__('localization.workspace.save'))
->action(function (): void { ->action(function (): void {
$this->save(); $this->save();
}) })
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage() ->tooltip(fn (): ?string => $this->currentUserCanManage()
? null ? null
: 'You do not have permission to manage workspace settings.'), : __('localization.workspace.no_manage_permission')),
]; ];
} }
public function getTitle(): string
{
return __('localization.workspace.title');
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -208,6 +215,18 @@ public function content(Schema $schema): Schema
return $schema return $schema
->statePath('data') ->statePath('data')
->schema([ ->schema([
Section::make(__('localization.workspace.section'))
->description($this->sectionDescription('localization', __('localization.workspace.section_description')))
->schema([
Select::make('localization_default_locale')
->label(__('localization.workspace.default_locale_label'))
->options(LocaleResolver::localeOptions())
->placeholder(__('localization.workspace.default_locale_placeholder'))
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->localeDefaultHelperText())
->hintAction($this->makeResetAction('localization_default_locale')),
]),
Section::make('Workspace entitlements') Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2) ->columns(2)
@ -507,7 +526,7 @@ public function save(): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save') ->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged'))
->success() ->success()
->send(); ->send();
} }
@ -526,7 +545,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) { if ($this->workspaceOverrideForField($field) === null) {
Notification::make() Notification::make()
->title('Setting already uses default') ->title(__('localization.notifications.setting_already_default'))
->success() ->success()
->send(); ->send();
@ -543,7 +562,7 @@ public function resetSetting(string $field): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title('Workspace setting reset to default') ->title(__('localization.notifications.workspace_setting_reset'))
->success() ->success()
->send(); ->send();
} }
@ -692,18 +711,17 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */ /** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at']; $updatedAt = $meta['updated_at'];
return sprintf( return __('localization.workspace.last_modified_by', [
'%s — Last modified by %s, %s.', 'description' => $baseDescription,
$baseDescription, 'user' => $meta['user_name'],
$meta['user_name'], 'time' => $updatedAt->diffForHumans(),
$updatedAt->diffForHumans(), ]);
);
} }
private function makeResetAction(string $field): Action private function makeResetAction(string $field): Action
{ {
return Action::make('reset_'.$field) return Action::make('reset_'.$field)
->label('Reset') ->label(__('localization.workspace.reset'))
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->action(function () use ($field): void { ->action(function () use ($field): void {
@ -718,15 +736,15 @@ private function makeResetAction(string $field): Action
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field)) ->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->tooltip(function () use ($field): ?string { ->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) { if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.'; return __('localization.workspace.no_manage_permission');
} }
if (! $this->canResetField($field)) { if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) { if ($this->isEntitlementOverrideValueField($field)) {
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return 'No workspace override to reset.'; return __('localization.workspace.no_workspace_override');
} }
return null; return null;
@ -948,6 +966,29 @@ private function helperTextFor(string $field): string
return sprintf('Effective value: %s.', $effectiveValue); return sprintf('Effective value: %s.', $effectiveValue);
} }
private function localeDefaultHelperText(): string
{
$resolved = $this->resolvedSettings['localization_default_locale'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en';
$localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale);
if (! $this->hasWorkspaceOverride('localization_default_locale')) {
return __('localization.workspace.default_locale_helper_unset', [
'locale' => $localeLabel,
'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
]);
}
return __('localization.workspace.default_locale_helper_set', [
'locale' => $localeLabel,
]);
}
private function slaFieldHelperText(string $severity): string private function slaFieldHelperText(string $severity): string
{ {
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null; $resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
@ -1353,9 +1394,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
private function sourceLabel(string $source): string private function sourceLabel(string $source): string
{ {
return match ($source) { return match ($source) {
'workspace_override' => 'workspace override', 'workspace_override' => __('localization.source.workspace_override'),
'tenant_override' => 'tenant override', 'tenant_override' => 'tenant override',
default => 'system default', default => __('localization.source.system_default'),
}; };
} }

View File

@ -21,6 +21,7 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog; use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder; use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService; use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -30,6 +31,7 @@
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration; use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
@ -42,6 +44,11 @@ class TenantDashboard extends Dashboard
*/ */
public array $supportDiagnosticsAuditKeys = []; public array $supportDiagnosticsAuditKeys = [];
public function getTitle(): string
{
return __('localization.dashboard.tenant_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -90,38 +97,69 @@ public function authorizeTenantSupportRequest(): void
private function requestSupportAction(): Action private function requestSupportAction(): Action
{ {
$action = Action::make('requestSupport') $action = Action::make('requestSupport')
->label('Request support') ->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->color('gray') ->color('gray')
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Request support') ->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.') ->modalDescription(__('localization.dashboard.support_request_description'))
->modalSubmitActionLabel('Submit request') ->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([ ->form([
Placeholder::make('included_context') Placeholder::make('included_context')
->label('Included context') ->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('latest_external_handoff')
->label(__('localization.dashboard.latest_external_handoff'))
->content(fn (): string => $this->tenantLatestSupportRequestHandoffSummary())
->columnSpanFull(),
Select::make('external_handoff_mode')
->label(__('localization.dashboard.external_handoff_mode'))
->options(fn (): array => $this->supportHandoffModeOptions())
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->helperText(fn (): string => $this->supportDeskTargetAvailable()
? __('localization.dashboard.external_handoff_mode_helper_available')
: __('localization.dashboard.external_handoff_mode_helper_unavailable'))
->required()
->live()
->native(false),
Placeholder::make('handoff_mutation_scope')
->label(__('localization.dashboard.handoff_mutation_scope'))
->content(fn (Get $get): string => $this->externalHandoffMutationScope($get('external_handoff_mode')))
->columnSpanFull(),
TextInput::make('external_ticket_reference')
->label(__('localization.dashboard.external_ticket_reference'))
->helperText(__('localization.dashboard.external_ticket_reference_helper'))
->required(fn (Get $get): bool => $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET),
TextInput::make('external_ticket_url')
->label(__('localization.dashboard.external_ticket_url'))
->helperText(__('localization.dashboard.external_ticket_url_helper'))
->url()
->visible(fn (Get $get): bool => $this->supportDeskTargetAvailable()
&& $get('external_handoff_mode') === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET)
->columnSpanFull(),
Select::make('severity') Select::make('severity')
->label('Severity') ->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions()) ->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL) ->default(SupportRequest::SEVERITY_NORMAL)
->required() ->required()
->native(false), ->native(false),
TextInput::make('summary') TextInput::make('summary')
->label('Summary') ->label(__('localization.dashboard.summary'))
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
Textarea::make('reproduction_notes') Textarea::make('reproduction_notes')
->label('Reproduction notes') ->label(__('localization.dashboard.reproduction_notes'))
->rows(4) ->rows(4)
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('contact_name') TextInput::make('contact_name')
->label('Contact name') ->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveDashboardActor()->name), ->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email') TextInput::make('contact_email')
->label('Contact email') ->label(__('localization.dashboard.contact_email'))
->email() ->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email), ->default(fn (): ?string => $this->resolveDashboardActor()->email),
]) ])
@ -132,9 +170,21 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data); $supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make() Notification::make()
->title('Support request submitted') ->title(__('localization.dashboard.support_request_submitted'))
->body('Reference '.$supportRequest->internal_reference) ->body($this->supportRequestNotificationBody($supportRequest))
->success() ->when(
$supportRequest->hasExternalHandoffFailure(),
fn (Notification $notification): Notification => $notification->warning(),
fn (Notification $notification): Notification => $notification->success(),
)
->when(
$supportRequest->external_ticket_url !== null,
fn (Notification $notification): Notification => $notification->actions([
Action::make('openExternalTicket')
->label(__('localization.dashboard.open_external_ticket'))
->url((string) $supportRequest->external_ticket_url, shouldOpenInNewTab: true),
]),
)
->send(); ->send();
}); });
@ -146,16 +196,16 @@ private function requestSupportAction(): Action
private function openSupportDiagnosticsAction(): Action private function openSupportDiagnosticsAction(): Action
{ {
$action = Action::make('openSupportDiagnostics') $action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics') ->label(__('localization.dashboard.open_support_diagnostics'))
->icon('heroicon-o-lifebuoy') ->icon('heroicon-o-lifebuoy')
->color('gray') ->color('gray')
->modal() ->modal()
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading('Support diagnostics') ->modalHeading(__('localization.dashboard.support_diagnostics'))
->modalDescription('Redacted tenant context from existing records.') ->modalDescription(__('localization.dashboard.support_diagnostics_description'))
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close')) ->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
->mountUsing(function (): void { ->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen(); $this->auditTenantSupportDiagnosticsOpen();
}) })
@ -276,4 +326,97 @@ private function tenantSupportRequestAttachmentSummary(): string
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.' ? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.'; : 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
} }
private function tenantLatestSupportRequestHandoffSummary(): string
{
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$user = $this->resolveDashboardActor();
$summary = app(SupportRequestSubmissionService::class)->latestTenantHandoffSummary($tenant, $user);
return $this->formatLatestHandoffSummary($summary);
}
/**
* @return array<string, string>
*/
private function supportHandoffModeOptions(): array
{
if (! $this->supportDeskTargetAvailable()) {
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
];
}
return [
SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => __('localization.dashboard.handoff_mode_internal_only'),
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.handoff_mode_create_external_ticket'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.handoff_mode_link_existing_ticket'),
];
}
private function supportDeskTargetAvailable(): bool
{
return app(ExternalSupportDeskHandoffService::class)->targetIsConfigured();
}
private function externalHandoffMutationScope(mixed $mode): string
{
return match ($mode) {
SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => __('localization.dashboard.mutation_scope_external_create'),
SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => __('localization.dashboard.mutation_scope_external_link'),
default => __('localization.dashboard.mutation_scope_internal_only'),
};
}
/**
* @param array<string, mixed>|null $summary
*/
private function formatLatestHandoffSummary(?array $summary): string
{
if ($summary === null) {
return __('localization.dashboard.latest_external_handoff_none');
}
$internalReference = (string) $summary['internal_reference'];
if (($summary['has_failure'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_failed', [
'reference' => $internalReference,
'failure' => (string) $summary['external_handoff_failure_summary'],
]);
}
if (($summary['has_external_link'] ?? false) === true) {
return __('localization.dashboard.latest_external_handoff_linked', [
'reference' => $internalReference,
'external' => (string) $summary['external_ticket_reference'],
]);
}
return __('localization.dashboard.latest_external_handoff_internal_only', [
'reference' => $internalReference,
]);
}
private function supportRequestNotificationBody(SupportRequest $supportRequest): string
{
return match ($supportRequest->externalHandoffOutcome()) {
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED => __('localization.dashboard.support_request_submitted_created', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED => __('localization.dashboard.support_request_submitted_linked', [
'reference' => $supportRequest->internal_reference,
'external' => $supportRequest->external_ticket_reference,
]),
SupportRequest::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED => __('localization.dashboard.support_request_submitted_failed', [
'reference' => $supportRequest->internal_reference,
'failure' => $supportRequest->external_handoff_failure_summary,
]),
default => __('localization.dashboard.support_request_submitted_internal_only', [
'reference' => $supportRequest->internal_reference,
]),
};
}
} }

View File

@ -75,8 +75,6 @@ class FindingResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
{ {
if (Filament::getCurrentPanel()?->getId() === 'admin') { if (Filament::getCurrentPanel()?->getId() === 'admin') {
@ -86,6 +84,26 @@ public static function shouldRegisterNavigation(): bool
return parent::shouldRegisterNavigation(); return parent::shouldRegisterNavigation();
} }
public static function getNavigationLabel(): string
{
return __('localization.navigation.findings');
}
public static function getNavigationGroup(): string
{
return __('localization.navigation.governance');
}
public static function getModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function getPluralModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -290,8 +308,6 @@ public static function infolist(Schema $schema): Schema
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record)) ? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
: null) : null)
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'), TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
@ -982,7 +998,6 @@ public static function table(Table $table): Table
if (! in_array((string) $record->status, [ if (! in_array((string) $record->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
$skippedCount++; $skippedCount++;
@ -1398,7 +1413,6 @@ public static function triageAction(): Actions\Action
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) ], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -1423,7 +1437,6 @@ public static function startProgressAction(): Actions\Action
->color('info') ->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true)) ], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runWorkflowMutation(

View File

@ -10,14 +10,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Filament\Actions; use Filament\Actions;
@ -77,15 +71,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant(); $stats = FindingResource::findingStatsForCurrentTenant();
return [ return [
'all' => Tab::make('All') 'all' => Tab::make(__('localization.findings.all'))
->icon('heroicon-m-list-bullet'), ->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make('Needs action') 'needs_action' => Tab::make(__('localization.findings.needs_action'))
->icon('heroicon-m-exclamation-triangle') ->icon('heroicon-m-exclamation-triangle')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())) ->whereIn('status', Finding::openStatusesForQuery()))
->badge($stats['open'] > 0 ? $stats['open'] : null) ->badge($stats['open'] > 0 ? $stats['open'] : null)
->badgeColor('warning'), ->badgeColor('warning'),
'overdue' => Tab::make('Overdue') 'overdue' => Tab::make(__('localization.findings.overdue'))
->icon('heroicon-m-clock') ->icon('heroicon-m-clock')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery()) ->whereIn('status', Finding::openStatusesForQuery())
@ -93,11 +87,11 @@ public function getTabs(): array
->where('due_at', '<', now())) ->where('due_at', '<', now()))
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null) ->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
->badgeColor('danger'), ->badgeColor('danger'),
'risk_accepted' => Tab::make('Risk accepted') 'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
->icon('heroicon-m-shield-check') ->icon('heroicon-m-shield-check')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', Finding::STATUS_RISK_ACCEPTED)), ->where('status', Finding::STATUS_RISK_ACCEPTED)),
'resolved' => Tab::make('Resolved') 'resolved' => Tab::make(__('localization.findings.resolved'))
->icon('heroicon-m-archive-box') ->icon('heroicon-m-archive-box')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])), ->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
@ -108,77 +102,6 @@ protected function getHeaderActions(): array
{ {
$actions = []; $actions = [];
$actions[] = UiEnforcement::forAction(
Actions\Action::make('backfill_lifecycle')
->label('Backfill findings lifecycle')
->icon('heroicon-o-wrench-screwdriver')
->color('gray')
->requiresConfirmation()
->modalHeading('Backfill findings lifecycle')
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
abort(404);
}
try {
$opRun = $runbookService->start(
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: $user,
reason: null,
source: 'tenant_ui',
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
$runUrl = OperationRunLinks::view($opRun, $tenant);
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type)
->body('The backfill will run in the background. You can continue working while it completes.')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
$actions[] = UiEnforcement::forAction( $actions[] = UiEnforcement::forAction(
Actions\Action::make('triage_all_matching') Actions\Action::make('triage_all_matching')
->label('Triage all matching') ->label('Triage all matching')
@ -248,7 +171,6 @@ protected function getHeaderActions(): array
if (! in_array((string) $finding->status, [ if (! in_array((string) $finding->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
$skippedCount++; $skippedCount++;

View File

@ -44,7 +44,7 @@ protected function getHeaderActions(): array
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false)) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
->color('gray'), ->color('gray'),
Actions\Action::make('open_approval_queue') Actions\Action::make('open_approval_queue')
->label('Open approval queue') ->label(__('localization.findings.open_approval_queue'))
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
->color('gray') ->color('gray')
->visible(function (): bool { ->visible(function (): bool {
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
: null; : null;
}), }),
Actions\ActionGroup::make(FindingResource::workflowActions()) Actions\ActionGroup::make(FindingResource::workflowActions())
->label('Actions') ->label(__('localization.findings.actions'))
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]); ]);

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard; use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers; use App\Filament\Resources\TenantResource\RelationManagers;
@ -15,9 +16,11 @@
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
@ -44,6 +47,7 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
@ -68,6 +72,7 @@
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms; use Filament\Forms;
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -824,6 +829,27 @@ public static function table(Table $table): Table
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding')) ->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor ->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'), && static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('compareTenants')
->label('Compare tenants')
->icon('heroicon-o-scale')
->color('gray')
->url(function (Tenant $record, mixed $livewire): string {
$triageState = $livewire instanceof Pages\ListTenants
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
return static::crossTenantCompareOpenUrl($record, $triageState);
})
->visible(fn (Tenant $record): bool => static::crossTenantCompareActionVisible($record)),
UiEnforcement::forAction( UiEnforcement::forAction(
Actions\Action::make('edit') Actions\Action::make('edit')
->label('Edit') ->label('Edit')
@ -966,6 +992,34 @@ public static function table(Table $table): Table
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
Actions\BulkAction::make('compareSelected')
->label('Compare selected')
->icon('heroicon-o-scale')
->color('gray')
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->extraAttributes(fn (mixed $livewire): array => [
'x-bind:aria-disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire).' ? true : null',
'x-bind:disabled' => static::crossTenantCompareBulkClientDisabledExpression($livewire),
'x-bind:title' => static::crossTenantCompareBulkClientTooltipExpression($livewire),
'x-bind:class' => "{ 'fi-disabled': ".static::crossTenantCompareBulkClientDisabledExpression($livewire).' }',
])
->action(function (Collection $records, mixed $livewire): void {
$disabledReason = static::crossTenantCompareBulkDisabledReason($records);
if ($disabledReason !== null) {
Notification::make()
->title($disabledReason)
->danger()
->send();
return;
}
if (method_exists($livewire, 'redirect')) {
$livewire->redirect(static::crossTenantCompareBulkOpenUrl($records, $livewire), navigate: true);
}
}),
Actions\BulkAction::make('syncSelected') Actions\BulkAction::make('syncSelected')
->label('Sync selected') ->label('Sync selected')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
@ -1158,6 +1212,52 @@ public static function tenantDashboardOpenUrl(Tenant $record, array $triageState
); );
} }
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
public static function crossTenantCompareOpenUrl(Tenant $record, array $triageState = []): string
{
return static::crossTenantCompareOpenUrlForSelection(
targetTenant: $record,
triageState: $triageState,
);
}
/**
* @param array{
* backup_posture?: list<string>,
* recovery_evidence?: list<string>,
* review_state?: list<string>,
* triage_sort?: string|null
* } $triageState
*/
public static function crossTenantCompareOpenUrlForSelection(
Tenant $targetTenant,
array $triageState = [],
?Tenant $sourceTenant = null,
): string {
$normalizedState = static::portfolioReturnFilters(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
);
return CrossTenantComparePage::launchUrl(
sourceTenant: $sourceTenant,
targetTenant: $targetTenant,
navigationContext: CanonicalNavigationContext::forTenantRegistry(
backLinkUrl: static::getUrl(panel: 'admin', parameters: $normalizedState),
tenantId: $sourceTenant instanceof Tenant ? null : (int) $targetTenant->getKey(),
),
);
}
/** /**
* @param array{ * @param array{
* backup_posture?: list<string>, * backup_posture?: list<string>,
@ -1248,6 +1348,168 @@ private static function portfolioReturnFiltersFromRequest(array $query): array
); );
} }
private static function crossTenantCompareActionVisible(Tenant $record): bool
{
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId) || $workspaceId !== (int) $record->workspace_id) {
return false;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return false;
}
/** @var WorkspaceCapabilityResolver $workspaceResolver */
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
if (! $workspaceResolver->isMember($user, $workspace)
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
return false;
}
/** @var CapabilityResolver $tenantResolver */
$tenantResolver = app(CapabilityResolver::class);
return $user->canAccessTenant($record)
&& $tenantResolver->can($user, $record, Capabilities::TENANT_VIEW);
}
private static function crossTenantCompareBulkDisabledReason(Collection $records): ?string
{
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
$tenants = $records
->filter(fn ($record): bool => $record instanceof Tenant)
->values();
if ($records->count() !== 2 || $tenants->count() !== 2) {
return 'Select exactly two tenants to compare.';
}
if ($tenants->contains(fn (Tenant $tenant): bool => ! $tenant->isActive())) {
return 'Only active tenants can be compared.';
}
$workspaceIds = $tenants
->map(fn (Tenant $tenant): int => (int) $tenant->workspace_id)
->unique()
->values();
if ($workspaceIds->count() !== 1) {
return UiTooltips::insufficientPermission();
}
$workspaceId = $workspaceIds->first();
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return UiTooltips::insufficientPermission();
}
/** @var WorkspaceCapabilityResolver $workspaceResolver */
$workspaceResolver = app(WorkspaceCapabilityResolver::class);
if (! $workspaceResolver->isMember($user, $workspace)
|| ! $workspaceResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW)) {
return UiTooltips::insufficientPermission();
}
/** @var CapabilityResolver $tenantResolver */
$tenantResolver = app(CapabilityResolver::class);
$isDenied = $tenants->contains(fn (Tenant $tenant): bool => ! $user->canAccessTenant($tenant)
|| ! $tenantResolver->can($user, $tenant, Capabilities::TENANT_VIEW));
return $isDenied ? UiTooltips::insufficientPermission() : null;
}
private static function crossTenantCompareBulkClientDisabledExpression(mixed $livewire): string
{
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
return "getSelectedRecordsCount() !== 2 || {$containsInactiveSelection}";
}
private static function crossTenantCompareBulkClientTooltipExpression(mixed $livewire): string
{
$containsInactiveSelection = static::crossTenantCompareBulkContainsInactiveSelectionExpression($livewire);
return "getSelectedRecordsCount() !== 2 ? 'Select exactly two tenants to compare.' : ({$containsInactiveSelection} ? 'Only active tenants can be compared.' : null)";
}
private static function crossTenantCompareBulkContainsInactiveSelectionExpression(mixed $livewire): string
{
$inactiveRecordKeys = \Illuminate\Support\Js::from(static::crossTenantCompareInactiveSelectionRecordKeys($livewire));
return "[...selectedRecords].some((key) => {$inactiveRecordKeys}.includes(key))";
}
/**
* @return list<string>
*/
private static function crossTenantCompareInactiveSelectionRecordKeys(mixed $livewire): array
{
if (! $livewire instanceof HasTable || ! method_exists($livewire, 'getTableRecordKey')) {
return [];
}
$tableRecords = $livewire->getTableRecords();
if (method_exists($tableRecords, 'getCollection')) {
$tableRecords = $tableRecords->getCollection();
}
return collect($tableRecords)
->filter(fn ($record): bool => $record instanceof Tenant && ! $record->isActive())
->map(fn (Tenant $tenant): string => (string) $livewire->getTableRecordKey($tenant))
->values()
->all();
}
private static function crossTenantCompareBulkOpenUrl(Collection $records, mixed $livewire): string
{
$triageState = $livewire instanceof Pages\ListTenants
? static::currentPortfolioTriageState($livewire)
: [];
if (! static::hasActivePortfolioTriageState(
static::sanitizeBackupPostures($triageState['backup_posture'] ?? []),
static::sanitizeRecoveryEvidenceStates($triageState['recovery_evidence'] ?? []),
static::sanitizeReviewStates($triageState['review_state'] ?? []),
static::sanitizeTriageSort($triageState['triage_sort'] ?? null),
)) {
$triageState = static::portfolioReturnFiltersFromRequest(request()->query());
}
$tenants = $records
->filter(fn ($record): bool => $record instanceof Tenant)
->values();
return static::crossTenantCompareOpenUrlForSelection(
targetTenant: $tenants->get(1),
triageState: $triageState,
sourceTenant: $tenants->get(0),
);
}
private static function hasActivePortfolioTriageState( private static function hasActivePortfolioTriageState(
array $backupPostures, array $backupPostures,
array $recoveryEvidence, array $recoveryEvidence,

View File

@ -85,6 +85,26 @@ public static function shouldRegisterNavigation(): bool
return Filament::getCurrentPanel()?->getId() === 'tenant'; return Filament::getCurrentPanel()?->getId() === 'tenant';
} }
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.reviews');
}
public static function getModelLabel(): string
{
return __('localization.review.review');
}
public static function getPluralModelLabel(): string
{
return __('localization.review.reviews');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -153,7 +173,7 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make('Outcome summary') Section::make(__('localization.review.outcome_summary'))
->schema([ ->schema([
ViewEntry::make('artifact_truth') ViewEntry::make('artifact_truth')
->hiddenLabel() ->hiddenLabel()
@ -162,7 +182,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Review') Section::make(__('localization.review.review'))
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
@ -171,23 +191,23 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label('Completeness') ->label(__('localization.review.completeness'))
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('tenant.name')->label('Tenant'), TextEntry::make('tenant.name')->label(__('localization.review.tenant')),
TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id') TextEntry::make('evidenceSnapshot.id')
->label('Evidence snapshot') ->label(__('localization.review.evidence_snapshot'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null), : null),
TextEntry::make('currentExportReviewPack.id') TextEntry::make('currentExportReviewPack.id')
->label('Current export') ->label(__('localization.review.current_export'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
@ -201,7 +221,7 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make('Executive posture') Section::make(__('localization.review.executive_posture'))
->schema([ ->schema([
ViewEntry::make('review_summary') ViewEntry::make('review_summary')
->hiddenLabel() ->hiddenLabel()
@ -210,21 +230,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make('Sections') Section::make(__('localization.review.sections'))
->schema([ ->schema([
RepeatableEntry::make('sections') RepeatableEntry::make('sections')
->hiddenLabel() ->hiddenLabel()
->schema([ ->schema([
TextEntry::make('title'), TextEntry::make('title'),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label('Completeness') ->label(__('localization.review.completeness'))
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('measured_at')->dateTime()->placeholder('—'), TextEntry::make('measured_at')->dateTime()->placeholder('—'),
Section::make('Details') Section::make(__('localization.review.details'))
->schema([ ->schema([
ViewEntry::make('section_payload') ViewEntry::make('section_payload')
->hiddenLabel() ->hiddenLabel()
@ -246,7 +266,7 @@ public static function table(Table $table): Table
{ {
$exportExecutivePackAction = UiEnforcement::forTableAction( $exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack') Actions\Action::make('export_executive_pack')
->label('Export executive pack') ->label(__('localization.review.export_executive_pack'))
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [ ->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value, TenantReviewStatus::Ready->value,
@ -278,7 +298,7 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->label('Outcome') ->label(__('localization.review.outcome'))
->badge() ->badge()
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color) ->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
@ -289,10 +309,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\IconColumn::make('summary.has_ready_export') Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export') ->label(__('localization.review.export'))
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('next_step') Tables\Columns\TextColumn::make('next_step')
->label('Next step') ->label(__('localization.review.next_step'))
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
->wrap(), ->wrap(),
Tables\Columns\TextColumn::make('fingerprint') Tables\Columns\TextColumn::make('fingerprint')
@ -306,18 +326,18 @@ public static function table(Table $table): Table
->all()), ->all()),
Tables\Filters\SelectFilter::make('completeness_state') Tables\Filters\SelectFilter::make('completeness_state')
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())), ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'), \App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'),
]) ])
->actions([ ->actions([
$exportExecutivePackAction, $exportExecutivePackAction,
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading('No tenant reviews yet') ->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.') ->emptyStateDescription(__('localization.review.create_first_review_description'))
->emptyStateActions([ ->emptyStateActions([
static::makeCreateReviewAction( static::makeCreateReviewAction(
name: 'create_first_review', name: 'create_first_review',
label: 'Create first review', label: __('localization.review.create_first_review'),
icon: 'heroicon-o-plus', icon: 'heroicon-o-plus',
), ),
]); ]);
@ -336,19 +356,23 @@ public static function makeCreateReviewAction(
string $label = 'Create review', string $label = 'Create review',
string $icon = 'heroicon-o-plus', string $icon = 'heroicon-o-plus',
): Actions\Action { ): Actions\Action {
$label = $label === 'Create review'
? __('localization.review.create_review')
: $label;
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make($name) Actions\Action::make($name)
->label($label) ->label($label)
->icon($icon) ->icon($icon)
->form([ ->form([
Section::make('Evidence basis') Section::make(__('localization.review.evidence_basis'))
->schema([ ->schema([
Select::make('evidence_snapshot_id') Select::make('evidence_snapshot_id')
->label('Evidence snapshot') ->label(__('localization.review.evidence_snapshot'))
->required() ->required()
->options(fn (): array => static::evidenceSnapshotOptions()) ->options(fn (): array => static::evidenceSnapshotOptions())
->searchable() ->searchable()
->helperText('Choose the anchored evidence snapshot for this review.'), ->helperText(__('localization.review.evidence_basis_helper')),
]), ]),
]) ])
->action(fn (array $data): mixed => static::executeCreateReview($data)), ->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -366,7 +390,7 @@ public static function executeCreateReview(array $data): void
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title('Unable to create review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send();
return; return;
} }
@ -388,7 +412,7 @@ public static function executeCreateReview(array $data): void
: null; : null;
if (! $snapshot instanceof EvidenceSnapshot) { if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send(); Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send();
return; return;
} }
@ -396,7 +420,7 @@ public static function executeCreateReview(array $data): void
try { try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send();
return; return;
} }
@ -406,11 +430,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) { if (! $review->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Review already available') ->title(__('localization.review.review_already_available'))
->body('A matching mutable review already exists for this evidence basis.') ->body(__('localization.review.review_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_review') Actions\Action::make('view_review')
->label('View review') ->label(__('localization.review.view_review'))
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
]) ])
->send(); ->send();
@ -419,12 +443,12 @@ public static function executeCreateReview(array $data): void
} }
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body('The review is being composed in the background.'); ->body(__('localization.review.review_composing_background'));
if ($review->operation_run_id) { if ($review->operation_run_id) {
$toast->actions([ $toast->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label(__('localization.review.open_operation'))
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]); ]);
} }
@ -496,7 +520,7 @@ public static function executeExport(TenantReview $review): void
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User || ! $review->tenant instanceof Tenant) { if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
Notification::make()->danger()->title('Unable to export review — missing context.')->send(); Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send();
return; return;
} }
@ -513,7 +537,7 @@ public static function executeExport(TenantReview $review): void
if ($service->checkActiveRunForReview($review)) { if ($service->checkActiveRunForReview($review)) {
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
->body('An executive pack export is already queued or running for this review.') ->body(__('localization.review.export_already_queued_body'))
->send(); ->send();
return; return;
@ -525,11 +549,11 @@ public static function executeExport(TenantReview $review): void
'include_operations' => true, 'include_operations' => true,
]); ]);
} catch (WorkspaceEntitlementBlockedException $exception) { } catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send(); Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
return; return;
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send(); Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send();
return; return;
} }
@ -540,11 +564,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) { if (! $pack->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title('Executive pack already available') ->title(__('localization.review.executive_pack_already_available'))
->body('A matching executive pack already exists for this review.') ->body(__('localization.review.executive_pack_already_available_body'))
->actions([ ->actions([
Actions\Action::make('view_pack') Actions\Action::make('view_pack')
->label('View pack') ->label(__('localization.review.view_pack'))
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
]) ])
->send(); ->send();
@ -553,7 +577,7 @@ public static function executeExport(TenantReview $review): void
} }
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The executive pack is being generated in the background.') ->body(__('localization.review.executive_pack_generating_background'))
->send(); ->send();
} }
@ -593,7 +617,7 @@ private static function evidenceSnapshotOptions(): array
'#%d · %s · %s', '#%d · %s · %s',
(int) $snapshot->getKey(), (int) $snapshot->getKey(),
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label, BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending' $snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending')
), ),
]) ])
->all(); ->all();
@ -617,7 +641,7 @@ private static function summaryPresentation(TenantReview $record): array
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) { if ($findingOutcomeSummary !== null) {
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.'; $highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
} }
return [ return [
@ -629,12 +653,12 @@ private static function summaryPresentation(TenantReview $record): array
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record), 'context_links' => static::summaryContextLinks($record),
'metrics' => [ 'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], ['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -648,37 +672,37 @@ private static function summaryContextLinks(TenantReview $record): array
if (is_numeric($record->operation_run_id)) { if (is_numeric($record->operation_run_id)) {
$links[] = [ $links[] = [
'title' => 'Operation', 'title' => __('localization.review.operation'),
'label' => 'Open operation', 'label' => __('localization.review.open_operation'),
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => 'Inspect the latest review composition or refresh run.', 'description' => __('localization.review.operation_description'),
]; ];
} }
if ($record->currentExportReviewPack && $record->tenant) { if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Executive pack', 'title' => __('localization.review.executive_pack'),
'label' => 'View executive pack', 'label' => __('localization.review.view_executive_pack'),
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => 'Open the current export that belongs to this review.', 'description' => __('localization.review.executive_pack_description'),
]; ];
} }
if ($record->tenant) { if ($record->tenant) {
$links[] = [ $links[] = [
'title' => 'Customer workspace', 'title' => __('localization.review.customer_workspace'),
'label' => 'Open customer workspace', 'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant), 'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.', 'description' => __('localization.review.customer_workspace_description'),
]; ];
} }
if ($record->evidenceSnapshot && $record->tenant) { if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [ $links[] = [
'title' => 'Evidence snapshot', 'title' => __('localization.review.evidence_snapshot'),
'label' => 'View evidence snapshot', 'label' => __('localization.review.view_evidence_snapshot'),
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => 'Return to the evidence basis behind this review.', 'description' => __('localization.review.evidence_snapshot_description'),
]; ];
} }

View File

@ -28,6 +28,11 @@ class Dashboard extends BaseDashboard
{ {
public string $window = SystemConsoleWindow::LastDay; public string $window = SystemConsoleWindow::LastDay;
public function getTitle(): string
{
return __('localization.dashboard.system_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -109,12 +114,12 @@ protected function getHeaderActions(): array
return [ return [
Action::make('set_window') Action::make('set_window')
->label('Time window') ->label(__('localization.dashboard.time_window'))
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->color('gray') ->color('gray')
->form([ ->form([
Select::make('window') Select::make('window')
->label('Window') ->label(__('localization.dashboard.window'))
->options(SystemConsoleWindow::options()) ->options(SystemConsoleWindow::options())
->default($this->window) ->default($this->window)
->required(), ->required(),
@ -130,7 +135,7 @@ protected function getHeaderActions(): array
}), }),
Action::make('enter_break_glass') Action::make('enter_break_glass')
->label('Enter break-glass mode') ->label(__('localization.dashboard.enter_break_glass'))
->color('danger') ->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -158,13 +163,13 @@ protected function getHeaderActions(): array
$breakGlass->start($user, (string) ($data['reason'] ?? '')); $breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make() Notification::make()
->title('Recovery mode enabled') ->title(__('localization.dashboard.recovery_mode_enabled'))
->success() ->success()
->send(); ->send();
}), }),
Action::make('exit_break_glass') Action::make('exit_break_glass')
->label('Exit break-glass') ->label(__('localization.dashboard.exit_break_glass'))
->color('gray') ->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -180,7 +185,7 @@ protected function getHeaderActions(): array
$breakGlass->exit($user); $breakGlass->exit($user);
Notification::make() Notification::make()
->title('Recovery mode ended') ->title(__('localization.dashboard.recovery_mode_ended'))
->success() ->success()
->send(); ->send();
}), }),

View File

@ -57,11 +57,6 @@ public static function canAccess(): bool
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE); && $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
} }
public function mount(): void
{
abort_unless(static::canAccess(), 403);
}
public function getHeader(): ?View public function getHeader(): ?View
{ {
return view('filament.system.pages.ops.partials.controls-header', [ return view('filament.system.pages.ops.partials.controls-header', [

View File

@ -4,26 +4,9 @@
namespace App\Filament\System\Pages\Ops; namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Services\Auth\BreakGlassSession;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason;
use App\Services\System\AllowedTenantUniverse;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\System\SystemOperationRunLinks;
use Filament\Actions\Action;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Validation\ValidationException;
class Runbooks extends Page class Runbooks extends Page
{ {
@ -37,53 +20,6 @@ class Runbooks extends Page
protected string $view = 'filament.system.pages.ops.runbooks'; protected string $view = 'filament.system.pages.ops.runbooks';
public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
public ?int $findingsTenantId = null;
public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;
public ?int $tenantId = null;
/**
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
*/
public ?array $findingsPreflight = null;
/**
* @var array{affected_count: int, total_count: int, estimated_tenants?: int|null}|null
*/
public ?array $preflight = null;
public function findingsScopeLabel(): string
{
if ($this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS) {
return 'All tenants';
}
$tenantName = $this->selectedTenantName($this->findingsTenantId);
if ($tenantName !== null) {
return "Single tenant ({$tenantName})";
}
return $this->findingsTenantId !== null ? "Single tenant (#{$this->findingsTenantId})" : 'Single tenant';
}
public function findingsLastRun(): ?OperationRun
{
return $this->lastRunForType(FindingsLifecycleBackfillRunbookService::RUNBOOK_KEY);
}
public function selectedTenantName(?int $tenantId): ?string
{
if ($tenantId === null) {
return null;
}
return Tenant::query()->whereKey($tenantId)->value('name');
}
public static function canAccess(): bool public static function canAccess(): bool
{ {
$user = auth('platform')->user(); $user = auth('platform')->user();
@ -95,231 +31,4 @@ public static function canAccess(): bool
return $user->hasCapability(PlatformCapabilities::OPS_VIEW) return $user->hasCapability(PlatformCapabilities::OPS_VIEW)
&& $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW); && $user->hasCapability(PlatformCapabilities::RUNBOOKS_VIEW);
} }
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('preflight')
->label('Preflight')
->color('gray')
->icon('heroicon-o-magnifying-glass')
->form($this->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
$this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId;
$this->scopeMode = $scope->mode;
$this->tenantId = $scope->tenantId;
$this->findingsPreflight = $runbookService->preflight($scope);
$this->preflight = $this->findingsPreflight;
Notification::make()
->title('Preflight complete')
->success()
->send();
}),
Action::make('run')
->label('Run…')
->icon('heroicon-o-play')
->color('danger')
->requiresConfirmation()
->modalHeading('Run: Rebuild Findings Lifecycle')
->modalDescription('This operation may modify customer data. Review the preflight and confirm before running.')
->form($this->findingsRunForm())
->disabled(fn (): bool => ! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
if (! is_array($this->findingsPreflight) || (int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0) {
throw ValidationException::withMessages([
'preflight' => 'Run preflight first.',
]);
}
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
abort(403);
}
if (! $user->hasCapability(PlatformCapabilities::RUNBOOKS_RUN)
|| ! $user->hasCapability(PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL)
) {
abort(403);
}
if ($scope->isAllTenants()) {
$typedConfirmation = (string) ($data['typed_confirmation'] ?? '');
if ($typedConfirmation !== 'BACKFILL') {
throw ValidationException::withMessages([
'typed_confirmation' => 'Please type BACKFILL to confirm.',
]);
}
}
$reason = RunbookReason::fromNullableArray([
'reason_code' => $data['reason_code'] ?? null,
'reason_text' => $data['reason_text'] ?? null,
]);
try {
$run = $runbookService->start(
scope: $scope,
initiator: $user,
reason: $reason,
source: 'system_ui',
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
$viewUrl = SystemOperationRunLinks::view($run);
$toast = $run->wasRecentlyCreated
? OperationUxPresenter::queuedToast((string) $run->type)->body('The runbook will execute in the background.')
: OperationUxPresenter::alreadyQueuedToast((string) $run->type);
$toast
->actions([
Action::make('view_run')
->label('View run')
->url($viewUrl),
])
->send();
}),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function findingsScopeForm(): array
{
return [
Radio::make('scope_mode')
->label('Scope')
->options([
FindingsLifecycleBackfillScope::MODE_ALL_TENANTS => 'All tenants',
FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT => 'Single tenant',
])
->default($this->findingsScopeMode)
->live()
->required(),
Select::make('tenant_id')
->label('Tenant')
->searchable()
->visible(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->required(fn (callable $get): bool => $get('scope_mode') === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT)
->getSearchResultsUsing(function (string $search, AllowedTenantUniverse $universe): array {
return $universe
->query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value, AllowedTenantUniverse $universe): ?string {
if (! is_numeric($value)) {
return null;
}
return $universe
->query()
->whereKey((int) $value)
->value('name');
}),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function findingsRunForm(): array
{
return [
TextInput::make('typed_confirmation')
->label('Type BACKFILL to confirm')
->visible(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->required(fn (): bool => $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS)
->in(['BACKFILL'])
->validationMessages([
'in' => 'Please type BACKFILL to confirm.',
]),
Select::make('reason_code')
->label('Reason code')
->options(RunbookReason::options())
->required(function (BreakGlassSession $breakGlass): bool {
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
Textarea::make('reason_text')
->label('Reason')
->rows(4)
->maxLength(500)
->required(function (BreakGlassSession $breakGlass): bool {
return $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_ALL_TENANTS || $breakGlass->isActive();
}),
];
}
private function lastRunForType(string $type): ?OperationRun
{
$platformTenant = Tenant::query()->where('external_id', 'platform')->first();
if (! $platformTenant instanceof Tenant) {
return null;
}
return OperationRun::query()
->where('workspace_id', (int) $platformTenant->workspace_id)
->where('type', $type)
->latest('id')
->first();
}
/**
* @param array<string, mixed> $data
*/
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
if (! $scope->isSingleTenant()) {
return $scope;
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
return FindingsLifecycleBackfillScope::allTenants();
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
} }

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\Localization\LocaleResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
class LocalizationController extends Controller
{
public function context(Request $request, LocaleResolver $resolver): JsonResponse
{
$plane = $request->query('plane');
$context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
if (is_string($plane) && $plane !== '') {
$context = $resolver->resolve($request, $plane);
}
return response()->json(is_array($context) ? $context : $resolver->resolve($request));
}
public function updateOverride(Request $request): RedirectResponse
{
$locale = LocaleResolver::normalize($request->input('locale'));
if ($locale === null) {
throw ValidationException::withMessages([
'locale' => [__('localization.validation.unsupported_locale')],
]);
}
$request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale);
App::setLocale($locale);
return back()->with('status', __('localization.notifications.locale_override_saved'));
}
public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse
{
$request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', __('localization.notifications.locale_override_cleared'));
}
public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse
{
$user = $request->user();
abort_unless($user instanceof User, Response::HTTP_NOT_FOUND);
$rawLocale = $request->input('preferred_locale');
$locale = $rawLocale === null || $rawLocale === ''
? null
: LocaleResolver::normalize($rawLocale);
if ($rawLocale !== null && $rawLocale !== '' && $locale === null) {
throw ValidationException::withMessages([
'preferred_locale' => [__('localization.validation.unsupported_locale')],
]);
}
$user->forceFill(['preferred_locale' => $locale])->save();
$user->refresh();
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', $locale === null
? __('localization.notifications.user_preference_cleared')
: __('localization.notifications.user_preference_saved'));
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Localization\LocaleResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class ApplyResolvedLocale
{
public function __construct(private LocaleResolver $resolver) {}
public function handle(Request $request, Closure $next, ?string $plane = null): Response
{
$context = $this->resolver->resolve($request, $plane);
App::setLocale($context['locale']);
Carbon::setLocale($context['locale']);
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
return $next($request);
}
}

View File

@ -1,398 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer;
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 Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;
class BackfillFindingLifecycleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $tenantId,
public readonly int $workspaceId,
public readonly ?int $initiatorUserId = null,
) {}
public function handle(
OperationRunService $operationRuns,
FindingSlaPolicy $slaPolicy,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
return;
}
$initiator = $this->initiatorUserId !== null
? User::query()->find($this->initiatorUserId)
: null;
$operationRun = $operationRuns->ensureRunWithIdentity(
tenant: $tenant,
type: 'findings.lifecycle.backfill',
identityInputs: [
'tenant_id' => $this->tenantId,
'trigger' => 'backfill',
],
context: [
'workspace_id' => $this->workspaceId,
'initiator_user_id' => $this->initiatorUserId,
],
initiator: $initiator instanceof User ? $initiator : null,
);
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
if (! $lock->get()) {
if ($operationRun->status !== OperationRunStatus::Completed->value) {
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Blocked->value,
failures: [
[
'code' => 'findings.lifecycle.backfill.lock_busy',
'message' => 'Another findings lifecycle backfill is already running for this tenant.',
],
],
);
}
$runbookService->maybeFinalize($operationRun);
return;
}
try {
$total = (int) Finding::query()
->where('tenant_id', $tenant->getKey())
->count();
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
summaryCounts: [
'total' => $total,
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
],
);
$operationRun->refresh();
$backfillStartedAt = $operationRun->started_at !== null
? CarbonImmutable::instance($operationRun->started_at)
: CarbonImmutable::now('UTC');
Finding::query()
->where('tenant_id', $tenant->getKey())
->orderBy('id')
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRuns, $operationRun, $backfillStartedAt): void {
$processed = 0;
$updated = 0;
$skipped = 0;
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
$processed++;
$originalAttributes = $finding->getAttributes();
$this->backfillLifecycleFields($finding, $backfillStartedAt);
$this->backfillLegacyAcknowledgedStatus($finding);
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
$this->backfillDriftRecurrenceKey($finding);
if ($finding->isDirty()) {
$finding->save();
$updated++;
} else {
$finding->setRawAttributes($originalAttributes, sync: true);
$skipped++;
}
}
$operationRuns->incrementSummaryCounts($operationRun, [
'processed' => $processed,
'updated' => $updated,
'skipped' => $skipped,
]);
});
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
if ($consolidatedDuplicates > 0) {
$operationRuns->incrementSummaryCounts($operationRun, [
'updated' => $consolidatedDuplicates,
]);
}
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$runbookService->maybeFinalize($operationRun);
} catch (Throwable $e) {
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
$operationRuns->updateRun(
$operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Failed->value,
failures: [[
'code' => 'findings.lifecycle.backfill.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : 'Findings lifecycle backfill failed.',
]],
);
$runbookService->maybeFinalize($operationRun);
throw $e;
} finally {
$lock->release();
}
}
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
{
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $createdAt;
}
if ($finding->last_seen_at === null) {
$finding->last_seen_at = $createdAt;
}
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
if ($lastSeen->lessThan($firstSeen)) {
$finding->last_seen_at = $firstSeen;
}
}
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($timesSeen < 1) {
$finding->times_seen = 1;
}
}
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
{
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
return;
}
$finding->status = Finding::STATUS_TRIAGED;
if ($finding->triaged_at === null) {
if ($finding->acknowledged_at !== null) {
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
} elseif ($finding->created_at !== null) {
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
}
}
}
private function backfillSlaFields(
Finding $finding,
Tenant $tenant,
FindingSlaPolicy $slaPolicy,
CarbonImmutable $backfillStartedAt,
): void {
if (! Finding::isOpenStatus((string) $finding->status)) {
return;
}
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
}
}
private function backfillDriftRecurrenceKey(Finding $finding): void
{
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
return;
}
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
return;
}
$tenantId = (int) ($finding->tenant_id ?? 0);
$scopeKey = (string) ($finding->scope_key ?? '');
$subjectType = (string) ($finding->subject_type ?? '');
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
return;
}
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$kind = Arr::get($evidence, 'summary.kind');
$changeType = Arr::get($evidence, 'change_type');
$kind = is_string($kind) ? $kind : '';
$changeType = is_string($changeType) ? $changeType : '';
if ($kind === '') {
return;
}
$dimension = $this->recurrenceDimension($kind, $changeType);
$finding->recurrence_key = hash('sha256', sprintf(
'drift:%d:%s:%s:%s:%s',
$tenantId,
$scopeKey,
$subjectType,
$subjectExternalId,
$dimension,
));
}
private function recurrenceDimension(string $kind, string $changeType): string
{
$kind = strtolower(trim($kind));
$changeType = strtolower(trim($changeType));
return match ($kind) {
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
default => $kind,
};
}
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
{
$duplicateKeys = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->whereNotNull('recurrence_key')
->select(['recurrence_key'])
->groupBy('recurrence_key')
->havingRaw('COUNT(*) > 1')
->pluck('recurrence_key')
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->values();
if ($duplicateKeys->isEmpty()) {
return 0;
}
$consolidated = 0;
foreach ($duplicateKeys as $recurrenceKey) {
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
continue;
}
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('recurrence_key', $recurrenceKey)
->orderBy('id')
->get();
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
continue;
}
$finding->forceFill([
'status' => Finding::STATUS_CLOSED,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null,
])->save();
$consolidated++;
}
}
return $consolidated;
}
/**
* @param Collection<int, Finding> $findings
*/
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
{
if ($findings->isEmpty()) {
return null;
}
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
if ($alreadyCanonical instanceof Finding) {
return $alreadyCanonical;
}
/** @var Finding $sorted */
$sorted = $candidates
->sortByDesc(function (Finding $finding): array {
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
return [
max($lastSeen, $createdAt),
(int) $finding->getKey(),
];
})
->first();
return $sorted;
}
}

View File

@ -1,378 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Support\OpsUx\RunFailureSanitizer;
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 Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Throwable;
class BackfillFindingLifecycleTenantIntoWorkspaceRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $operationRunId,
public readonly int $workspaceId,
public readonly int $tenantId,
) {}
public function handle(
OperationRunService $operationRunService,
FindingSlaPolicy $slaPolicy,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$tenant = Tenant::query()->find($this->tenantId);
if (! $tenant instanceof Tenant) {
return;
}
if ((int) $tenant->workspace_id !== $this->workspaceId) {
return;
}
$run = OperationRun::query()->find($this->operationRunId);
if (! $run instanceof OperationRun) {
return;
}
if ((int) $run->workspace_id !== $this->workspaceId) {
return;
}
if ($run->tenant_id !== null) {
return;
}
if ($run->status === 'queued') {
$operationRunService->updateRun($run, status: 'running');
}
$lock = Cache::lock(sprintf('tenantpilot:findings:lifecycle_backfill:tenant:%d', $this->tenantId), 900);
if (! $lock->get()) {
$operationRunService->appendFailures($run, [
[
'code' => 'findings.lifecycle.backfill.lock_busy',
'message' => sprintf('Tenant %d is already running a findings lifecycle backfill.', $this->tenantId),
],
]);
$operationRunService->incrementSummaryCounts($run, [
'failed' => 1,
'processed' => 1,
]);
$operationRunService->maybeCompleteBulkRun($run);
$runbookService->maybeFinalize($run);
return;
}
try {
$backfillStartedAt = $run->started_at !== null
? CarbonImmutable::instance($run->started_at)
: CarbonImmutable::now('UTC');
Finding::query()
->where('tenant_id', $tenant->getKey())
->orderBy('id')
->chunkById(200, function (Collection $findings) use ($tenant, $slaPolicy, $operationRunService, $run, $backfillStartedAt): void {
$updated = 0;
$skipped = 0;
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
$originalAttributes = $finding->getAttributes();
$this->backfillLifecycleFields($finding, $backfillStartedAt);
$this->backfillLegacyAcknowledgedStatus($finding);
$this->backfillSlaFields($finding, $tenant, $slaPolicy, $backfillStartedAt);
$this->backfillDriftRecurrenceKey($finding);
if ($finding->isDirty()) {
$finding->save();
$updated++;
} else {
$finding->setRawAttributes($originalAttributes, sync: true);
$skipped++;
}
}
if ($updated > 0 || $skipped > 0) {
$operationRunService->incrementSummaryCounts($run, [
'updated' => $updated,
'skipped' => $skipped,
]);
}
});
$consolidatedDuplicates = $this->consolidateDriftDuplicates($tenant, $backfillStartedAt);
if ($consolidatedDuplicates > 0) {
$operationRunService->incrementSummaryCounts($run, [
'updated' => $consolidatedDuplicates,
]);
}
$operationRunService->incrementSummaryCounts($run, [
'processed' => 1,
]);
$operationRunService->maybeCompleteBulkRun($run);
$runbookService->maybeFinalize($run);
} catch (Throwable $e) {
$message = RunFailureSanitizer::sanitizeMessage($e->getMessage());
$reasonCode = RunFailureSanitizer::normalizeReasonCode($e->getMessage());
$operationRunService->appendFailures($run, [[
'code' => 'findings.lifecycle.backfill.failed',
'reason_code' => $reasonCode,
'message' => $message !== '' ? $message : sprintf('Tenant %d findings lifecycle backfill failed.', $this->tenantId),
]]);
$operationRunService->incrementSummaryCounts($run, [
'failed' => 1,
'processed' => 1,
]);
$operationRunService->maybeCompleteBulkRun($run);
$runbookService->maybeFinalize($run);
throw $e;
} finally {
$lock->release();
}
}
private function backfillLifecycleFields(Finding $finding, CarbonImmutable $backfillStartedAt): void
{
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at) : $backfillStartedAt;
if ($finding->first_seen_at === null) {
$finding->first_seen_at = $createdAt;
}
if ($finding->last_seen_at === null) {
$finding->last_seen_at = $createdAt;
}
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
$lastSeen = CarbonImmutable::instance($finding->last_seen_at);
$firstSeen = CarbonImmutable::instance($finding->first_seen_at);
if ($lastSeen->lessThan($firstSeen)) {
$finding->last_seen_at = $firstSeen;
}
}
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($timesSeen < 1) {
$finding->times_seen = 1;
}
}
private function backfillLegacyAcknowledgedStatus(Finding $finding): void
{
if ($finding->status !== Finding::STATUS_ACKNOWLEDGED) {
return;
}
$finding->status = Finding::STATUS_TRIAGED;
if ($finding->triaged_at === null) {
if ($finding->acknowledged_at !== null) {
$finding->triaged_at = CarbonImmutable::instance($finding->acknowledged_at);
} elseif ($finding->created_at !== null) {
$finding->triaged_at = CarbonImmutable::instance($finding->created_at);
}
}
}
private function backfillSlaFields(
Finding $finding,
Tenant $tenant,
FindingSlaPolicy $slaPolicy,
CarbonImmutable $backfillStartedAt,
): void {
if (! Finding::isOpenStatus((string) $finding->status)) {
return;
}
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity((string) $finding->severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $backfillStartedAt);
}
}
private function backfillDriftRecurrenceKey(Finding $finding): void
{
if ($finding->finding_type !== Finding::FINDING_TYPE_DRIFT) {
return;
}
if ($finding->recurrence_key !== null && trim((string) $finding->recurrence_key) !== '') {
return;
}
$tenantId = (int) ($finding->tenant_id ?? 0);
$scopeKey = (string) ($finding->scope_key ?? '');
$subjectType = (string) ($finding->subject_type ?? '');
$subjectExternalId = (string) ($finding->subject_external_id ?? '');
if ($tenantId <= 0 || $scopeKey === '' || $subjectType === '' || $subjectExternalId === '') {
return;
}
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$kind = Arr::get($evidence, 'summary.kind');
$changeType = Arr::get($evidence, 'change_type');
$kind = is_string($kind) ? $kind : '';
$changeType = is_string($changeType) ? $changeType : '';
if ($kind === '') {
return;
}
$dimension = $this->recurrenceDimension($kind, $changeType);
$finding->recurrence_key = hash('sha256', sprintf(
'drift:%d:%s:%s:%s:%s',
$tenantId,
$scopeKey,
$subjectType,
$subjectExternalId,
$dimension,
));
}
private function recurrenceDimension(string $kind, string $changeType): string
{
$kind = strtolower(trim($kind));
$changeType = strtolower(trim($changeType));
return match ($kind) {
'policy_snapshot', 'baseline_compare' => sprintf('%s:%s', $kind, $changeType !== '' ? $changeType : 'modified'),
default => $kind,
};
}
private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $backfillStartedAt): int
{
$duplicateKeys = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->whereNotNull('recurrence_key')
->select(['recurrence_key'])
->groupBy('recurrence_key')
->havingRaw('COUNT(*) > 1')
->pluck('recurrence_key')
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->values();
if ($duplicateKeys->isEmpty()) {
return 0;
}
$consolidated = 0;
foreach ($duplicateKeys as $recurrenceKey) {
if (! is_string($recurrenceKey) || $recurrenceKey === '') {
continue;
}
$findings = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->where('recurrence_key', $recurrenceKey)
->orderBy('id')
->get();
$canonical = $this->chooseCanonicalDriftFinding($findings, $recurrenceKey);
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
if ($canonical instanceof Finding && (int) $finding->getKey() === (int) $canonical->getKey()) {
continue;
}
$finding->forceFill([
'status' => Finding::STATUS_CLOSED,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => $backfillStartedAt,
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
'closed_by_user_id' => null,
'recurrence_key' => null,
])->save();
$consolidated++;
}
}
return $consolidated;
}
/**
* @param Collection<int, Finding> $findings
*/
private function chooseCanonicalDriftFinding(Collection $findings, string $recurrenceKey): ?Finding
{
if ($findings->isEmpty()) {
return null;
}
$openCandidates = $findings->filter(static fn (Finding $finding): bool => Finding::isOpenStatus((string) $finding->status));
$candidates = $openCandidates->isNotEmpty() ? $openCandidates : $findings;
$alreadyCanonical = $candidates->first(static fn (Finding $finding): bool => (string) $finding->fingerprint === $recurrenceKey);
if ($alreadyCanonical instanceof Finding) {
return $alreadyCanonical;
}
/** @var Finding $sorted */
$sorted = $candidates
->sortByDesc(function (Finding $finding): array {
$lastSeen = $finding->last_seen_at !== null ? CarbonImmutable::instance($finding->last_seen_at)->getTimestamp() : 0;
$createdAt = $finding->created_at !== null ? CarbonImmutable::instance($finding->created_at)->getTimestamp() : 0;
return [
max($lastSeen, $createdAt),
(int) $finding->getKey(),
];
})
->first();
return $sorted;
}
}

View File

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\System\AllowedTenantUniverse;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class BackfillFindingLifecycleWorkspaceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly int $operationRunId,
public readonly int $workspaceId,
) {}
public function handle(
OperationRunService $operationRunService,
AllowedTenantUniverse $allowedTenantUniverse,
FindingsLifecycleBackfillRunbookService $runbookService,
): void {
$run = OperationRun::query()->find($this->operationRunId);
if (! $run instanceof OperationRun) {
return;
}
if ((int) $run->workspace_id !== $this->workspaceId) {
return;
}
if ($run->tenant_id !== null) {
return;
}
$tenantIds = $allowedTenantUniverse
->query()
->where('workspace_id', $this->workspaceId)
->orderBy('id')
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->all();
$tenantCount = count($tenantIds);
$operationRunService->updateRun(
$run,
status: OperationRunStatus::Running->value,
outcome: OperationRunOutcome::Pending->value,
summaryCounts: [
'tenants' => $tenantCount,
'total' => $tenantCount,
'processed' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
],
);
if ($tenantCount === 0) {
$operationRunService->updateRun(
$run,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::Succeeded->value,
);
$runbookService->maybeFinalize($run);
return;
}
foreach ($tenantIds as $tenantId) {
if ($tenantId <= 0) {
continue;
}
BackfillFindingLifecycleTenantIntoWorkspaceRunJob::dispatch(
operationRunId: (int) $run->getKey(),
workspaceId: $this->workspaceId,
tenantId: $tenantId,
);
}
}
}

View File

@ -1871,8 +1871,11 @@ private function upsertFindings(
} else { } else {
$this->observeFinding( $this->observeFinding(
finding: $finding, finding: $finding,
tenant: $tenant,
observedAt: $observedAt, observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(), currentOperationRunId: (int) $this->operationRun->getKey(),
severity: (string) $driftItem['severity'],
slaPolicy: $slaPolicy,
); );
} }
@ -1947,12 +1950,21 @@ private function upsertFindings(
]; ];
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void private function observeFinding(
Finding $finding,
Tenant $tenant,
CarbonImmutable $observedAt,
int $currentOperationRunId,
string $severity,
FindingSlaPolicy $slaPolicy,
): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
} }
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} elseif ($timesSeen < 1) { } elseif ($timesSeen < 1) {
$finding->times_seen = 1; $finding->times_seen = 1;
} }
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
} }
/** /**

View File

@ -33,8 +33,6 @@ class Finding extends Model
public const string STATUS_NEW = 'new'; public const string STATUS_NEW = 'new';
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged'; public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress'; public const string STATUS_IN_PROGRESS = 'in_progress';
@ -169,10 +167,7 @@ public static function terminalStatuses(): array
*/ */
public static function openStatusesForQuery(): array public static function openStatusesForQuery(): array
{ {
return [ return self::openStatuses();
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
} }
/** /**
@ -295,10 +290,6 @@ public static function isReopenReason(?string $reason): bool
public static function canonicalizeStatus(?string $status): ?string public static function canonicalizeStatus(?string $status): ?string
{ {
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status; return $status;
} }
@ -324,23 +315,6 @@ public function isRiskAccepted(): bool
return (string) $this->status === self::STATUS_RISK_ACCEPTED; return (string) $this->status === self::STATUS_RISK_ACCEPTED;
} }
public function acknowledge(User $user): self
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {
return $this;
}
$this->forceFill([
'status' => self::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$this->save();
return $this;
}
public function resolve(string $reason): self public function resolve(string $reason): self
{ {
$this->forceFill([ $this->forceFill([

View File

@ -32,6 +32,20 @@ class SupportRequest extends Model
public const string SEVERITY_BLOCKING = 'blocking'; public const string SEVERITY_BLOCKING = 'blocking';
public const string EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY = 'internal_only';
public const string EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET = 'create_external_ticket';
public const string EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET = 'link_existing_ticket';
public const string HANDOFF_OUTCOME_INTERNAL_ONLY = 'internal_only';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED = 'external_ticket_created';
public const string HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED = 'external_ticket_linked';
public const string HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED = 'external_handoff_failed';
protected $guarded = []; protected $guarded = [];
/** /**
@ -65,6 +79,53 @@ public static function severityValues(): array
return array_keys(self::severityOptions()); return array_keys(self::severityOptions());
} }
/**
* @return array<string, string>
*/
public static function externalHandoffModeOptions(): array
{
return [
self::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY => 'TenantPilot only',
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => 'Create external ticket',
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => 'Link existing ticket',
];
}
/**
* @return list<string>
*/
public static function externalHandoffModeValues(): array
{
return array_keys(self::externalHandoffModeOptions());
}
public function hasExternalTicket(): bool
{
return is_string($this->external_ticket_reference) && trim($this->external_ticket_reference) !== '';
}
public function hasExternalHandoffFailure(): bool
{
return is_string($this->external_handoff_failure_summary) && trim($this->external_handoff_failure_summary) !== '';
}
public function externalHandoffOutcome(): string
{
if ($this->hasExternalHandoffFailure()) {
return self::HANDOFF_OUTCOME_EXTERNAL_HANDOFF_FAILED;
}
if (! $this->hasExternalTicket()) {
return self::HANDOFF_OUTCOME_INTERNAL_ONLY;
}
return match ($this->external_handoff_mode) {
self::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_CREATED,
self::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET => self::HANDOFF_OUTCOME_EXTERNAL_TICKET_LINKED,
default => self::HANDOFF_OUTCOME_INTERNAL_ONLY,
};
}
/** /**
* @return list<string> * @return list<string>
*/ */

View File

@ -39,6 +39,7 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
'password', 'password',
'entra_tenant_id', 'entra_tenant_id',
'entra_object_id', 'entra_object_id',
'preferred_locale',
]; ];
/** /**

View File

@ -49,10 +49,7 @@ public function update(User $user, Finding $finding): Response|bool
public function triage(User $user, Finding $finding): Response|bool public function triage(User $user, Finding $finding): Response|bool
{ {
return $this->canMutateWithAnyCapability($user, $finding, [ return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE);
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
} }
public function assign(User $user, Finding $finding): Response|bool public function assign(User $user, Finding $finding): Response|bool

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Auth\Login; use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox; use App\Filament\Pages\Governance\GovernanceInbox;
@ -79,16 +80,16 @@ public function panel(Panel $panel): Panel
]) ])
->navigationItems([ ->navigationItems([
WorkspaceOverview::navigationItem(), WorkspaceOverview::navigationItem(),
NavigationItem::make('Integrations') NavigationItem::make(fn (): string => __('localization.navigation.integrations'))
->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(15) ->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()), ->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make('Settings') NavigationItem::make(fn (): string => __('localization.navigation.settings'))
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
->icon('heroicon-o-cog-6-tooth') ->icon('heroicon-o-cog-6-tooth')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(20) ->sort(20)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -115,12 +116,12 @@ public function panel(Panel $panel): Panel
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
}), }),
NavigationItem::make('Manage workspaces') NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces'))
->url(function (): string { ->url(function (): string {
return route('filament.admin.resources.workspaces.index'); return route('filament.admin.resources.workspaces.index');
}) })
->icon('heroicon-o-squares-2x2') ->icon('heroicon-o-squares-2x2')
->group('Settings') ->group(fn (): string => __('localization.navigation.settings'))
->sort(10) ->sort(10)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -136,15 +137,15 @@ public function panel(Panel $panel): Panel
->whereIn('role', $roles) ->whereIn('role', $roles)
->exists(); ->exists();
}), }),
NavigationItem::make('Operations') NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -181,6 +182,7 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class, InventoryCoverage::class,
TenantRequiredPermissions::class, TenantRequiredPermissions::class,
WorkspaceSettings::class, WorkspaceSettings::class,
CrossTenantComparePage::class,
GovernanceInbox::class, GovernanceInbox::class,
FindingsHygieneReport::class, FindingsHygieneReport::class,
FindingsIntakeQueue::class, FindingsIntakeQueue::class,
@ -210,6 +212,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -42,6 +42,14 @@ public function panel(Panel $panel): Panel
PanelsRenderHook::BODY_START, PanelsRenderHook::BODY_START,
fn () => view('filament.system.components.break-glass-banner')->render(), fn () => view('filament.system.components.break-glass-banner')->render(),
) )
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.locale-switcher', [
'plane' => 'system',
'showPreference' => false,
'embedded' => false,
])->render(),
)
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages') ->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
->pages([ ->pages([
Dashboard::class, Dashboard::class,
@ -59,6 +67,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:system'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,

View File

@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
'primary' => Color::Indigo, 'primary' => Color::Indigo,
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make(OperationRunLinks::collectionLabel()) NavigationItem::make(fn (): string => __('localization.navigation.operations'))
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(10), ->sort(10),
NavigationItem::make('Alerts') NavigationItem::make(fn (): string => __('localization.navigation.alerts'))
->url(fn (): string => url('/admin/alerts')) ->url(fn (): string => url('/admin/alerts'))
->icon('heroicon-o-bell-alert') ->icon('heroicon-o-bell-alert')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(20), ->sort(20),
NavigationItem::make('Audit Log') NavigationItem::make(fn (): string => __('localization.navigation.audit_log'))
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group('Monitoring') ->group(fn (): string => __('localization.navigation.monitoring'))
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -111,6 +111,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -139,6 +139,43 @@ public function logSupportDiagnosticsOpened(
); );
} }
/**
* @param array<string, mixed> $preflight
*/
public function logCrossTenantPromotionPreflightGenerated(
Workspace $workspace,
Tenant $sourceTenant,
Tenant $targetTenant,
array $preflight,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
$summary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
return $this->log(
workspace: $workspace,
action: AuditActionId::CrossTenantPromotionPreflightGenerated,
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,
'ready_count' => (int) ($summary['ready'] ?? 0),
'blocked_count' => (int) ($summary['blocked'] ?? 0),
'manual_mapping_required_count' => (int) ($summary['manual_mapping_required'] ?? 0),
'total_count' => (int) ($summary['total'] ?? 0),
'blocked_reason_counts' => is_array($preflight['blockedReasonCounts'] ?? null)
? $preflight['blockedReasonCounts']
: [],
],
actor: $actor,
status: 'success',
resourceType: 'cross_tenant_promotion_preflight',
resourceId: sprintf('%s:%s', $sourceTenant->getKey(), $targetTenant->getKey()),
targetLabel: $sourceTenant->name.' -> '.$targetTenant->name,
summary: 'Cross-tenant promotion preflight generated for '.$sourceTenant->name.' -> '.$targetTenant->name,
);
}
public function logSupportRequestCreated( public function logSupportRequestCreated(
SupportRequest $supportRequest, SupportRequest $supportRequest,
User|PlatformUser|null $actor = null, User|PlatformUser|null $actor = null,
@ -173,4 +210,87 @@ public function logSupportRequestCreated(
tenant: $tenant, tenant: $tenant,
); );
} }
public function logSupportRequestExternalTicketCreated(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketCreated,
status: 'success',
summaryPrefix: 'External ticket created for support request ',
);
}
public function logSupportRequestExternalTicketLinked(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalTicketLinked,
status: 'success',
summaryPrefix: 'External ticket linked for support request ',
);
}
public function logSupportRequestExternalHandoffFailed(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
return $this->logSupportRequestExternalHandoff(
supportRequest: $supportRequest,
actor: $actor,
action: AuditActionId::SupportRequestExternalHandoffFailed,
status: 'failed',
summaryPrefix: 'External handoff failed for support request ',
);
}
private function logSupportRequestExternalHandoff(
SupportRequest $supportRequest,
User|PlatformUser|null $actor,
AuditActionId $action,
string $status,
string $summaryPrefix,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
$metadata = [
'internal_reference' => $supportRequest->internal_reference,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (string) $supportRequest->operation_run_id
: (string) $tenant->getKey(),
'external_handoff_mode' => $supportRequest->external_handoff_mode,
'external_ticket_reference' => $supportRequest->external_ticket_reference,
];
if ($supportRequest->external_handoff_failure_summary !== null) {
$metadata['external_handoff_failure_summary'] = $supportRequest->external_handoff_failure_summary;
}
return $this->log(
workspace: $tenant->workspace,
action: $action,
context: $metadata,
actor: $actor,
status: $status,
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: $summaryPrefix.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
tenant: $tenant,
);
}
} }

View File

@ -28,7 +28,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -74,7 +73,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -112,7 +110,6 @@ class RoleCapabilityMap
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,

View File

@ -163,7 +163,7 @@ private function upsertFinding(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([ $existing->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -253,7 +253,7 @@ private function handleGaAggregate(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
$existing->forceFill([ $existing->forceFill([
'severity' => Finding::SEVERITY_HIGH, 'severity' => Finding::SEVERITY_HIGH,
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
return $this->slaPolicy ?? app(FindingSlaPolicy::class); return $this->slaPolicy ?? app(FindingSlaPolicy::class);
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at; $lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1; $finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
return; $finding->times_seen = 1;
} }
if ($timesSeen < 1) { $slaPolicy = $this->resolveSlaPolicy();
$finding->times_seen = 1;
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
} }
} }

View File

@ -46,17 +46,13 @@ public static function meaningfulActivityActionValues(): array
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{ {
$this->authorize($finding, $tenant, $actor, [ $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$currentStatus = (string) $finding->status; $currentStatus = (string) $finding->status;
if (! in_array($currentStatus, [ if (! in_array($currentStatus, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
} }
@ -82,12 +78,9 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
{ {
$this->authorize($finding, $tenant, $actor, [ $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) { if ((string) $finding->status !== Finding::STATUS_TRIAGED) {
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
} }
@ -369,10 +362,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{ {
$this->authorize($finding, $tenant, $actor, [ $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');

View File

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Services\Localization;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Settings\SettingsResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
class LocaleResolver
{
public const SESSION_OVERRIDE_KEY = 'tenantpilot.locale_override';
public const REQUEST_ATTRIBUTE = 'tenantpilot.resolved_locale';
public const SETTING_DOMAIN = 'localization';
public const SETTING_DEFAULT_LOCALE = 'default_locale';
public const SOURCE_EXPLICIT_OVERRIDE = 'explicit_override';
public const SOURCE_USER_PREFERENCE = 'user_preference';
public const SOURCE_WORKSPACE_DEFAULT = 'workspace_default';
public const SOURCE_SYSTEM_DEFAULT = 'system_default';
/**
* @var list<string>
*/
private const SUPPORTED_LOCALES = ['en', 'de'];
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspaceContext $workspaceContext,
) {}
/**
* @return list<string>
*/
public static function supportedLocales(): array
{
return self::SUPPORTED_LOCALES;
}
/**
* @return array<string, string>
*/
public static function localeOptions(): array
{
return [
'en' => __('localization.locales.en'),
'de' => __('localization.locales.de'),
];
}
public static function isSupported(mixed $locale): bool
{
return self::normalize($locale) !== null;
}
public static function normalize(mixed $locale): ?string
{
if (! is_string($locale)) {
return null;
}
$normalized = strtolower(trim($locale));
return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null;
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolve(Request $request, ?string $plane = null): array
{
$plane = $this->normalizePlane($plane, $request);
$explicitOverride = $this->explicitOverride($request);
$systemDefault = (string) config('app.fallback_locale', 'en');
if ($plane === 'system') {
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: null,
workspaceDefault: null,
systemDefault: $systemDefault,
includeUserPreference: false,
includeWorkspaceDefault: false,
);
}
$user = $request->user();
$userPreference = $user instanceof User ? $user->preferred_locale : null;
$workspaceDefault = $this->workspaceDefault($request);
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: $userPreference,
workspaceDefault: $workspaceDefault,
systemDefault: $systemDefault,
includeUserPreference: true,
includeWorkspaceDefault: true,
);
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolveFromSources(
mixed $explicitOverride,
mixed $userPreference,
mixed $workspaceDefault,
mixed $systemDefault,
bool $includeUserPreference = true,
bool $includeWorkspaceDefault = true,
): array {
$fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en';
$candidates = [
self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride),
];
if ($includeUserPreference) {
$candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference);
}
if ($includeWorkspaceDefault) {
$candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault);
}
$candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale;
foreach ($candidates as $source => $locale) {
if ($locale !== null) {
return [
'locale' => $locale,
'source' => $source,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
}
return [
'locale' => $fallbackLocale,
'source' => self::SOURCE_SYSTEM_DEFAULT,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
private function explicitOverride(Request $request): ?string
{
$queryLocale = self::normalize($request->query('locale'));
if ($queryLocale !== null) {
return $queryLocale;
}
if (! $request->hasSession()) {
return null;
}
return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY));
}
private function workspaceDefault(Request $request): ?string
{
$workspace = $this->workspaceContext->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return null;
}
return self::normalize($this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_DEFAULT_LOCALE,
));
}
private function normalizePlane(?string $plane, Request $request): string
{
$plane = strtolower(trim((string) $plane));
if (in_array($plane, ['admin', 'tenant', 'system'], true)) {
return $plane;
}
return $request->is('system', 'system/*') ? 'system' : 'admin';
}
}

View File

@ -140,7 +140,7 @@ private function handleMissingPermission(
->first(); ->first();
if ($finding instanceof Finding) { if ($finding instanceof Finding) {
$this->observeFinding($finding, $observedAt); $this->observeFinding($finding, $tenant, $observedAt, $severity);
$finding->forceFill([ $finding->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -216,7 +216,7 @@ private function handleErrorPermission(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([ $existing->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
return CarbonImmutable::now(); return CarbonImmutable::now();
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at; $lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1; $finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
return; $finding->times_seen = 1;
} }
if ($timesSeen < 1) { if ($finding->sla_days === null) {
$finding->times_seen = 1; $finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
} }
} }

View File

@ -1,739 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Runbooks;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Jobs\BackfillFindingLifecycleWorkspaceJob;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\OperationRunCompleted;
use App\Services\Alerts\AlertDispatchService;
use App\Services\Audit\AuditRecorder;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\System\AllowedTenantUniverse;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\System\SystemOperationRunLinks;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Throwable;
class FindingsLifecycleBackfillRunbookService
{
public const string RUNBOOK_KEY = 'findings.lifecycle.backfill';
public function __construct(
private readonly AllowedTenantUniverse $allowedTenantUniverse,
private readonly BreakGlassSession $breakGlassSession,
private readonly OperationRunService $operationRunService,
private readonly AuditLogger $auditLogger,
private readonly AlertDispatchService $alertDispatchService,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AuditRecorder $auditRecorder,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
/**
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
*/
public function preflight(FindingsLifecycleBackfillScope $scope): array
{
$result = $this->computePreflight($scope);
$this->auditSafely(
action: 'platform.ops.runbooks.preflight',
scope: $scope,
operationRunId: null,
initiator: null,
context: [
'preflight' => $result,
],
);
return $result;
}
public function start(
FindingsLifecycleBackfillScope $scope,
User|PlatformUser|null $initiator,
?RunbookReason $reason,
string $source,
): OperationRun {
$source = trim($source);
if ($source === '') {
throw ValidationException::withMessages([
'source' => 'A run source is required.',
]);
}
$isBreakGlassActive = $this->breakGlassSession->isActive();
if ($scope->isAllTenants() || $isBreakGlassActive) {
if (! $reason instanceof RunbookReason) {
throw ValidationException::withMessages([
'reason' => 'A reason is required for this run.',
]);
}
}
$preflight = $this->computePreflight($scope);
if (($preflight['affected_count'] ?? 0) <= 0) {
throw ValidationException::withMessages([
'preflight.affected_count' => 'Nothing to do for this scope.',
]);
}
$workspace = null;
$tenant = null;
if ($scope->isSingleTenant()) {
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
$this->allowedTenantUniverse->ensureAllowed($tenant);
$workspace = $tenant->workspace;
} else {
$platformTenant = $this->platformTenant();
$workspace = $platformTenant->workspace;
}
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Platform tenant is missing its workspace.');
}
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
if ($decision->isPaused()) {
$this->auditBlockedStart(
decision: $decision,
scope: $scope,
workspace: $workspace,
tenant: $tenant,
initiator: $initiator,
source: $source,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
);
}
if ($scope->isAllTenants()) {
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
$lock = Cache::lock($lockKey, 900);
if (! $lock->get()) {
throw ValidationException::withMessages([
'scope' => 'Another run is already in progress for this scope.',
]);
}
try {
return $this->startAllTenants(
workspace: $workspace,
initiator: $initiator,
reason: $reason,
preflight: $preflight,
source: $source,
isBreakGlassActive: $isBreakGlassActive,
);
} finally {
$lock->release();
}
}
return $this->startSingleTenant(
tenant: $tenant,
initiator: $initiator,
reason: $reason,
preflight: $preflight,
source: $source,
isBreakGlassActive: $isBreakGlassActive,
);
}
public function maybeFinalize(OperationRun $run): void
{
$run->refresh();
if ($run->status !== OperationRunStatus::Completed->value) {
return;
}
$context = is_array($run->context) ? $run->context : [];
if ((string) data_get($context, 'runbook.key') !== self::RUNBOOK_KEY) {
return;
}
$lockKey = sprintf('tenantpilot:runbooks:%s:finalize:%d', self::RUNBOOK_KEY, (int) $run->getKey());
$lock = Cache::lock($lockKey, 86400);
if (! $lock->get()) {
return;
}
try {
$this->auditSafely(
action: $run->outcome === OperationRunOutcome::Failed->value
? 'platform.ops.runbooks.failed'
: 'platform.ops.runbooks.completed',
scope: $this->scopeFromRunContext($context),
operationRunId: (int) $run->getKey(),
context: [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
'is_break_glass' => (bool) data_get($context, 'platform_initiator.is_break_glass', false),
'reason_code' => data_get($context, 'reason.reason_code'),
'reason_text' => data_get($context, 'reason.reason_text'),
],
);
$this->notifyInitiatorSafely($run);
if ($run->outcome === OperationRunOutcome::Failed->value) {
$this->dispatchFailureAlertSafely($run);
}
} finally {
$lock->release();
}
}
/**
* @return array{affected_count: int, total_count: int, estimated_tenants?: int|null}
*/
private function computePreflight(FindingsLifecycleBackfillScope $scope): array
{
if ($scope->isSingleTenant()) {
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
$this->allowedTenantUniverse->ensureAllowed($tenant);
return $this->computeTenantPreflight($tenant);
}
$platformTenant = $this->platformTenant();
$workspaceId = (int) ($platformTenant->workspace_id ?? 0);
$tenants = $this->allowedTenantUniverse
->query()
->where('workspace_id', $workspaceId)
->orderBy('id')
->get();
$affected = 0;
$total = 0;
foreach ($tenants as $tenant) {
if (! $tenant instanceof Tenant) {
continue;
}
$counts = $this->computeTenantPreflight($tenant);
$affected += (int) ($counts['affected_count'] ?? 0);
$total += (int) ($counts['total_count'] ?? 0);
}
return [
'affected_count' => $affected,
'total_count' => $total,
'estimated_tenants' => $tenants->count(),
];
}
/**
* @return array{affected_count: int, total_count: int}
*/
private function computeTenantPreflight(Tenant $tenant): array
{
$query = Finding::query()->where('tenant_id', (int) $tenant->getKey());
$total = (int) (clone $query)->count();
$affected = 0;
(clone $query)
->orderBy('id')
->chunkById(500, function ($findings) use (&$affected): void {
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
if ($this->findingNeedsBackfill($finding)) {
$affected++;
}
}
});
$affected += $this->countDriftDuplicateConsolidations($tenant);
return [
'affected_count' => $affected,
'total_count' => $total,
];
}
private function findingNeedsBackfill(Finding $finding): bool
{
if ($finding->first_seen_at === null || $finding->last_seen_at === null) {
return true;
}
if ($finding->last_seen_at !== null && $finding->first_seen_at !== null) {
if ($finding->last_seen_at->lt($finding->first_seen_at)) {
return true;
}
}
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($timesSeen < 1) {
return true;
}
if ($finding->status === Finding::STATUS_ACKNOWLEDGED) {
return true;
}
if (Finding::isOpenStatus((string) $finding->status)) {
if ($finding->sla_days === null || $finding->due_at === null) {
return true;
}
}
if ($finding->finding_type === Finding::FINDING_TYPE_DRIFT) {
$recurrenceKey = $finding->recurrence_key !== null ? trim((string) $finding->recurrence_key) : '';
if ($recurrenceKey === '') {
$scopeKey = trim((string) ($finding->scope_key ?? ''));
$subjectType = trim((string) ($finding->subject_type ?? ''));
$subjectExternalId = trim((string) ($finding->subject_external_id ?? ''));
if ($scopeKey !== '' && $subjectType !== '' && $subjectExternalId !== '') {
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
$kind = data_get($evidence, 'summary.kind');
if (is_string($kind) && trim($kind) !== '') {
return true;
}
}
}
}
return false;
}
private function countDriftDuplicateConsolidations(Tenant $tenant): int
{
$rows = Finding::query()
->where('tenant_id', (int) $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->whereNotNull('recurrence_key')
->select(['recurrence_key', DB::raw('COUNT(*) as count')])
->groupBy('recurrence_key')
->havingRaw('COUNT(*) > 1')
->get();
$duplicates = 0;
foreach ($rows as $row) {
$count = is_numeric($row->count ?? null) ? (int) $row->count : 0;
if ($count > 1) {
$duplicates += ($count - 1);
}
}
return $duplicates;
}
private function startAllTenants(
Workspace $workspace,
User|PlatformUser|null $initiator,
?RunbookReason $reason,
array $preflight,
string $source,
bool $isBreakGlassActive,
): OperationRun {
$run = $this->operationRunService->ensureWorkspaceRunWithIdentity(
workspace: $workspace,
type: self::RUNBOOK_KEY,
identityInputs: [
'runbook' => self::RUNBOOK_KEY,
'scope' => FindingsLifecycleBackfillScope::MODE_ALL_TENANTS,
],
context: $this->buildRunContext(
workspaceId: (int) $workspace->getKey(),
scope: FindingsLifecycleBackfillScope::allTenants(),
initiator: $initiator,
reason: $reason,
preflight: $preflight,
source: $source,
isBreakGlassActive: $isBreakGlassActive,
),
initiator: $initiator instanceof User ? $initiator : null,
);
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
$run->refresh();
}
$this->auditSafely(
action: 'platform.ops.runbooks.start',
scope: FindingsLifecycleBackfillScope::allTenants(),
operationRunId: (int) $run->getKey(),
initiator: $initiator,
context: [
'preflight' => $preflight,
'is_break_glass' => $isBreakGlassActive,
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
);
if (! $run->wasRecentlyCreated) {
return $run;
}
$this->operationRunService->dispatchOrFail($run, function () use ($run, $workspace): void {
BackfillFindingLifecycleWorkspaceJob::dispatch(
operationRunId: (int) $run->getKey(),
workspaceId: (int) $workspace->getKey(),
);
});
return $run;
}
private function startSingleTenant(
?Tenant $tenant,
User|PlatformUser|null $initiator,
?RunbookReason $reason,
array $preflight,
string $source,
bool $isBreakGlassActive,
): OperationRun {
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
}
$run = $this->operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: self::RUNBOOK_KEY,
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'trigger' => 'backfill',
],
context: $this->buildRunContext(
workspaceId: (int) $tenant->workspace_id,
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
initiator: $initiator,
reason: $reason,
preflight: $preflight,
source: $source,
isBreakGlassActive: $isBreakGlassActive,
),
initiator: $initiator instanceof User ? $initiator : null,
);
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
$run->update(['initiator_name' => $initiator->name ?: $initiator->email]);
$run->refresh();
}
$this->auditSafely(
action: 'platform.ops.runbooks.start',
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
operationRunId: (int) $run->getKey(),
initiator: $initiator,
context: [
'preflight' => $preflight,
'is_break_glass' => $isBreakGlassActive,
] + ($reason instanceof RunbookReason ? $reason->toArray() : []),
);
if (! $run->wasRecentlyCreated) {
return $run;
}
$this->operationRunService->dispatchOrFail($run, function () use ($tenant): void {
BackfillFindingLifecycleJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: null,
);
});
return $run;
}
private function platformTenant(): Tenant
{
$tenant = Tenant::query()->where('external_id', 'platform')->first();
if (! $tenant instanceof Tenant) {
throw new \RuntimeException('Platform tenant is missing.');
}
return $tenant;
}
/**
* @return array<string, mixed>
*/
private function buildRunContext(
int $workspaceId,
FindingsLifecycleBackfillScope $scope,
User|PlatformUser|null $initiator,
?RunbookReason $reason,
array $preflight,
string $source,
bool $isBreakGlassActive,
): array {
$context = [
'workspace_id' => $workspaceId,
'runbook' => [
'key' => self::RUNBOOK_KEY,
'scope' => $scope->mode,
'target_tenant_id' => $scope->tenantId,
'source' => $source,
],
'preflight' => [
'affected_count' => (int) ($preflight['affected_count'] ?? 0),
'total_count' => (int) ($preflight['total_count'] ?? 0),
'estimated_tenants' => $preflight['estimated_tenants'] ?? null,
],
];
if ($reason instanceof RunbookReason) {
$context['reason'] = $reason->toArray();
}
if ($initiator instanceof PlatformUser) {
$context['platform_initiator'] = [
'platform_user_id' => (int) $initiator->getKey(),
'email' => (string) $initiator->email,
'name' => (string) $initiator->name,
'is_break_glass' => $isBreakGlassActive,
];
} elseif ($initiator instanceof User) {
$context['tenant_initiator'] = [
'user_id' => (int) $initiator->getKey(),
'email' => (string) $initiator->email,
'name' => (string) $initiator->name,
];
}
return $context;
}
private function scopeFromRunContext(array $context): FindingsLifecycleBackfillScope
{
$scope = data_get($context, 'runbook.scope');
$tenantId = data_get($context, 'runbook.target_tenant_id');
if ($scope === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT && is_numeric($tenantId)) {
return FindingsLifecycleBackfillScope::singleTenant((int) $tenantId);
}
return FindingsLifecycleBackfillScope::allTenants();
}
/**
* @param array<string, mixed> $context
*/
private function auditSafely(
string $action,
FindingsLifecycleBackfillScope $scope,
?int $operationRunId,
User|PlatformUser|null $initiator,
array $context = [],
): void {
try {
$metadata = [
'runbook_key' => self::RUNBOOK_KEY,
'scope' => $scope->mode,
'target_tenant_id' => $scope->tenantId,
'operation_run_id' => $operationRunId,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
];
if ($initiator instanceof User && $scope->isSingleTenant()) {
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
if ($tenant instanceof Tenant) {
$this->auditLogger->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
] + $context,
actorId: (int) $initiator->getKey(),
actorEmail: (string) $initiator->email,
actorName: (string) $initiator->name,
status: 'success',
resourceType: 'operation_run',
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
);
return;
}
}
$platformTenant = $this->platformTenant();
$platformActor = $initiator instanceof PlatformUser
? $initiator
: auth('platform')->user();
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
$this->auditLogger->log(
tenant: $platformTenant,
action: $action,
context: [
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
] + $context,
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
status: 'success',
resourceType: 'operation_run',
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
);
} catch (Throwable) {
// Audit is fail-safe (must not crash runbooks).
}
}
private function auditBlockedStart(
\App\Support\OperationalControls\OperationalControlDecision $decision,
FindingsLifecycleBackfillScope $scope,
Workspace $workspace,
?Tenant $tenant,
User|PlatformUser|null $initiator,
string $source,
): void {
try {
$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' => $initiator instanceof User || $initiator instanceof PlatformUser ? (int) $initiator->getKey() : null,
'requested_scope' => $scope->mode,
'target_tenant_id' => $scope->tenantId,
'source' => $source,
'runbook_key' => self::RUNBOOK_KEY,
], static fn (mixed $value): bool => $value !== null && $value !== '');
$summary = sprintf('%s blocked by operational control', OperationCatalog::label(self::RUNBOOK_KEY));
if ($scope->isAllTenants()) {
$this->auditRecorder->record(
action: AuditActionId::OperationalControlExecutionBlocked,
context: ['metadata' => $metadata],
actor: $initiator instanceof PlatformUser ? AuditActorSnapshot::platform($initiator) : null,
target: new AuditTargetSnapshot(
type: 'operational_control',
id: $decision->sourceActivationId,
label: OperationCatalog::label(self::RUNBOOK_KEY),
),
outcome: 'blocked',
summary: $summary,
);
return;
}
if (! $tenant instanceof Tenant) {
return;
}
$this->workspaceAuditLogger->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: ['metadata' => $metadata],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label(self::RUNBOOK_KEY),
summary: $summary,
tenant: $tenant,
);
} catch (Throwable) {
// Audit is fail-safe (must not crash runbooks).
}
}
private function notifyInitiatorSafely(OperationRun $run): void
{
try {
$platformUserId = data_get($run->context, 'platform_initiator.platform_user_id');
if (! is_numeric($platformUserId)) {
return;
}
$platformUser = PlatformUser::query()->whereKey((int) $platformUserId)->first();
if (! $platformUser instanceof PlatformUser) {
return;
}
$platformUser->notify(new OperationRunCompleted($run));
} catch (Throwable) {
// Notifications must not crash the runbook.
}
}
private function dispatchFailureAlertSafely(OperationRun $run): void
{
try {
$platformTenant = $this->platformTenant();
$workspace = $platformTenant->workspace;
if (! $workspace instanceof Workspace) {
return;
}
$this->alertDispatchService->dispatchEvent($workspace, [
'tenant_id' => (int) $platformTenant->getKey(),
'event_type' => 'operations.run.failed',
'severity' => 'high',
'title' => 'Operation failed: Findings lifecycle backfill',
'body' => 'A findings lifecycle backfill run failed.',
'metadata' => [
'operation_run_id' => (int) $run->getKey(),
'operation_type' => $run->canonicalOperationType(),
'scope' => (string) data_get($run->context, 'runbook.scope', ''),
'view_run_url' => SystemOperationRunLinks::view($run),
],
]);
} catch (Throwable) {
// Alerts must not crash the runbook.
}
}
}

View File

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Runbooks;
use Illuminate\Validation\ValidationException;
final readonly class FindingsLifecycleBackfillScope
{
public const string MODE_ALL_TENANTS = 'all_tenants';
public const string MODE_SINGLE_TENANT = 'single_tenant';
private function __construct(
public string $mode,
public ?int $tenantId,
) {}
public static function allTenants(): self
{
return new self(
mode: self::MODE_ALL_TENANTS,
tenantId: null,
);
}
public static function singleTenant(int $tenantId): self
{
$tenantId = (int) $tenantId;
if ($tenantId <= 0) {
throw ValidationException::withMessages([
'scope.tenant_id' => 'Select a valid tenant.',
]);
}
return new self(
mode: self::MODE_SINGLE_TENANT,
tenantId: $tenantId,
);
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$mode = trim((string) ($data['mode'] ?? ''));
if ($mode === '' || $mode === self::MODE_ALL_TENANTS) {
return self::allTenants();
}
if ($mode !== self::MODE_SINGLE_TENANT) {
throw ValidationException::withMessages([
'scope.mode' => 'Select a valid scope mode.',
]);
}
$tenantId = $data['tenant_id'] ?? null;
if (! is_numeric($tenantId)) {
throw ValidationException::withMessages([
'scope.tenant_id' => 'Select a tenant.',
]);
}
return self::singleTenant((int) $tenantId);
}
public function isAllTenants(): bool
{
return $this->mode === self::MODE_ALL_TENANTS;
}
public function isSingleTenant(): bool
{
return $this->mode === self::MODE_SINGLE_TENANT;
}
}

View File

@ -17,7 +17,6 @@ final class OperationRunTriageService
'inventory.sync', 'inventory.sync',
'policy.sync', 'policy.sync',
'directory.groups.sync', 'directory.groups.sync',
'findings.lifecycle.backfill',
'rbac.health_check', 'rbac.health_check',
'entra.admin_roles.scan', 'entra.admin_roles.scan',
'tenant.review_pack.generate', 'tenant.review_pack.generate',
@ -28,7 +27,6 @@ final class OperationRunTriageService
'inventory.sync', 'inventory.sync',
'policy.sync', 'policy.sync',
'directory.groups.sync', 'directory.groups.sync',
'findings.lifecycle.backfill',
'rbac.health_check', 'rbac.health_check',
'entra.admin_roles.scan', 'entra.admin_roles.scan',
'tenant.review_pack.generate', 'tenant.review_pack.generate',

View File

@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
{ {
$summary = $this->summary($findingsItem); $summary = $this->summary($findingsItem);
$entries = collect(Arr::wrap($summary['entries'] ?? [])) $entries = collect(Arr::wrap($summary['entries'] ?? []))
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true)) ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true))
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) { ->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
'critical' => 4, 'critical' => 4,
'high' => 3, 'high' => 3,

View File

@ -69,6 +69,7 @@ enum AuditActionId: string
case BaselineCompareStarted = 'baseline_compare.started'; case BaselineCompareStarted = 'baseline_compare.started';
case BaselineCompareCompleted = 'baseline_compare.completed'; case BaselineCompareCompleted = 'baseline_compare.completed';
case BaselineCompareFailed = 'baseline_compare.failed'; case BaselineCompareFailed = 'baseline_compare.failed';
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
case BaselineAssignmentCreated = 'baseline_assignment.created'; case BaselineAssignmentCreated = 'baseline_assignment.created';
case BaselineAssignmentUpdated = 'baseline_assignment.updated'; case BaselineAssignmentUpdated = 'baseline_assignment.updated';
case BaselineAssignmentDeleted = 'baseline_assignment.deleted'; case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
@ -103,6 +104,9 @@ enum AuditActionId: string
case SupportDiagnosticsOpened = 'support_diagnostics.opened'; case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case SupportRequestCreated = 'support_request.created'; case SupportRequestCreated = 'support_request.created';
case SupportRequestExternalTicketCreated = 'support_request.external_ticket_created';
case SupportRequestExternalTicketLinked = 'support_request.external_ticket_linked';
case SupportRequestExternalHandoffFailed = 'support_request.external_handoff_failed';
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated'; case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
case OperationalControlPaused = 'operational_control.paused'; case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated'; case OperationalControlUpdated = 'operational_control.updated';
@ -215,6 +219,7 @@ private static function labels(): array
self::BaselineCompareStarted->value => 'Baseline compare started', self::BaselineCompareStarted->value => 'Baseline compare started',
self::BaselineCompareCompleted->value => 'Baseline compare completed', self::BaselineCompareCompleted->value => 'Baseline compare completed',
self::BaselineCompareFailed->value => 'Baseline compare failed', self::BaselineCompareFailed->value => 'Baseline compare failed',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::BaselineAssignmentCreated->value => 'Baseline assignment created', self::BaselineAssignmentCreated->value => 'Baseline assignment created',
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated', self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted', self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
@ -248,6 +253,9 @@ private static function labels(): array
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created', self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated', self::OperationalControlUpdated->value => 'Operational control updated',
@ -306,6 +314,7 @@ private static function summaries(): array
self::BaselineProfileUpdated->value => 'Baseline profile updated', self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived', self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled', self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::AlertDestinationCreated->value => 'Alert destination created', self::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated', self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted', self::AlertDestinationDeleted->value => 'Alert destination deleted',
@ -338,6 +347,9 @@ private static function summaries(): array
self::ReviewPackDownloaded->value => 'Review pack downloaded', self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created', self::SupportRequestCreated->value => 'Support request created',
self::SupportRequestExternalTicketCreated->value => 'Support request external ticket created',
self::SupportRequestExternalTicketLinked->value => 'Support request external ticket linked',
self::SupportRequestExternalHandoffFailed->value => 'Support request external handoff failed',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
self::OperationalControlPaused->value => 'Operational control paused', self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated', self::OperationalControlUpdated->value => 'Operational control updated',

View File

@ -91,8 +91,6 @@ class Capabilities
public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept'; public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept';
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view'; public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage'; public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';

View File

@ -30,8 +30,6 @@ class PlatformCapabilities
public const RUNBOOKS_RUN = 'platform.runbooks.run'; public const RUNBOOKS_RUN = 'platform.runbooks.run';
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage'; public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
/** /**

View File

@ -796,7 +796,6 @@ private static function findingAttentionCounts(Tenant $tenant): array
$activeNonNewFindingsCount = Finding::query() $activeNonNewFindingsCount = Finding::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('status', [ ->whereIn('status', [
Finding::STATUS_ACKNOWLEDGED,
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS, Finding::STATUS_IN_PROGRESS,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,

View File

@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
$tenant = Tenant::query()->find($tenantId); $tenant = Tenant::query()->withTrashed()->find($tenantId);
} }
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {

View File

@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
public static function findingStatuses(bool $includeLegacyAcknowledged = true): array public static function findingStatuses(): array
{ {
$options = self::badgeOptions(BadgeDomain::FindingStatus, [ return self::badgeOptions(BadgeDomain::FindingStatus, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS, Finding::STATUS_IN_PROGRESS,
@ -114,21 +114,6 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
Finding::STATUS_CLOSED, Finding::STATUS_CLOSED,
Finding::STATUS_RISK_ACCEPTED, Finding::STATUS_RISK_ACCEPTED,
]); ]);
if (! $includeLegacyAcknowledged) {
return $options;
}
return [
Finding::STATUS_NEW => $options[Finding::STATUS_NEW],
Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED],
Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(),
Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS],
Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED],
Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED],
Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED],
Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED],
];
} }
/** /**
@ -312,11 +297,6 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array
->all(); ->all();
} }
private static function legacyFindingAcknowledgedLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)';
}
private static function platformLabel(string $platform): string private static function platformLabel(string $platform): string
{ {
return match (Str::of($platform) return match (Str::of($platform)

View File

@ -6,14 +6,15 @@
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace; use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\Finding; use App\Models\Finding;
use App\Models\FindingException;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantTriageReview; use App\Models\TenantTriageReview;
@ -21,14 +22,12 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService; use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver; use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver; use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder final readonly class GovernanceInboxSectionBuilder
@ -41,6 +40,7 @@
private const FAMILY_ORDER = [ private const FAMILY_ORDER = [
'assigned_findings', 'assigned_findings',
'intake_findings', 'intake_findings',
'finding_exceptions',
'stale_operations', 'stale_operations',
'alert_delivery_failures', 'alert_delivery_failures',
'review_follow_up', 'review_follow_up',
@ -71,6 +71,7 @@ public function build(
array $visibleFindingTenants, array $visibleFindingTenants,
array $reviewTenants, array $reviewTenants,
bool $canViewAlerts, bool $canViewAlerts,
bool $canViewFindingExceptions = false,
?Tenant $selectedTenant = null, ?Tenant $selectedTenant = null,
?string $selectedFamily = null, ?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null, ?CanonicalNavigationContext $navigationContext = null,
@ -113,6 +114,22 @@ public function build(
} }
if ($authorizedTenantsById !== []) { if ($authorizedTenantsById !== []) {
if ($canViewFindingExceptions) {
$findingExceptionsSection = $this->findingExceptionsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$findingExceptionsSection['key']] = $findingExceptionsSection;
$availableFamilies[] = [
'key' => $findingExceptionsSection['key'],
'label' => $findingExceptionsSection['label'],
'count' => $findingExceptionsSection['count'],
];
$familyCounts[$findingExceptionsSection['key']] = $findingExceptionsSection['count'];
}
$operationsSection = $this->operationsSection( $operationsSection = $this->operationsSection(
workspace: $workspace, workspace: $workspace,
authorizedTenants: $authorizedTenantsById, authorizedTenants: $authorizedTenantsById,
@ -191,6 +208,59 @@ public function build(
]; ];
} }
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function findingExceptionsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->findingExceptionsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$pendingCount = (clone $baseQuery)
->where('status', FindingException::STATUS_PENDING)
->count();
$expiringCount = (clone $baseQuery)
->where('current_validity_state', FindingException::VALIDITY_EXPIRING)
->count();
$lapsedCount = (clone $baseQuery)
->where('status', '!=', FindingException::STATUS_PENDING)
->whereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
])
->count();
$entries = $this->orderedFindingExceptionsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (FindingException $exception): array => $this->findingExceptionEntry($exception, $navigationContext))
->all();
return [
'key' => 'finding_exceptions',
'label' => 'Finding exceptions',
'count' => $count,
'summary' => $this->findingExceptionsSummary($count, $pendingCount, $expiringCount, $lapsedCount),
'dominant_action_label' => 'Open finding exceptions',
'dominant_action_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No finding exceptions match this tenant filter right now.'
: 'No finding exceptions need review right now.',
];
}
/** /**
* @param array<int, Tenant> $tenants * @param array<int, Tenant> $tenants
* @return array<int, Tenant> * @return array<int, Tenant>
@ -477,28 +547,10 @@ private function reviewFollowUpSection(
'label' => 'Review follow-up', 'label' => 'Review follow-up',
'count' => count($rawEntries), 'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount), 'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up', 'dominant_action_label' => 'Open customer review workspace',
'dominant_action_url' => $selectedTenant instanceof Tenant 'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? []) ? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive( : $this->appendQuery(CustomerReviewWorkspace::getUrl(panel: 'admin'), $navigationContext?->toQuery() ?? []),
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT), 'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant 'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.' ? 'No review follow-up is visible for this tenant filter right now.'
@ -634,6 +686,62 @@ private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Te
}); });
} }
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function findingExceptionsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($authorizedTenants);
return FindingException::query()
->with([
'tenant',
'requester:id,name',
'owner:id,name',
'finding' => fn ($query) => $query->withSubjectDisplayName(),
])
->where('workspace_id', (int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where(function ($query): void {
$query
->where('status', FindingException::STATUS_PENDING)
->orWhereIn('status', [
FindingException::STATUS_EXPIRING,
FindingException::STATUS_EXPIRED,
])
->orWhereIn('current_validity_state', [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
]);
});
}
private function orderedFindingExceptionsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when status = ? then 0
when current_validity_state = ? then 1
when current_validity_state = ? then 2
when current_validity_state = ? then 3
else 4
end asc",
[
FindingException::STATUS_PENDING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
FindingException::VALIDITY_EXPIRING,
],
)
->orderByRaw('case when review_due_at is null then 1 else 0 end asc')
->orderBy('review_due_at')
->orderByDesc('id');
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -727,6 +835,52 @@ private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext
]; ];
} }
/**
* @return array<string, mixed>
*/
private function findingExceptionEntry(FindingException $exception, ?CanonicalNavigationContext $navigationContext): array
{
$findingLabel = $exception->finding?->resolvedSubjectDisplayName()
?? 'Finding #'.$exception->finding_id;
$sublineParts = array_values(array_filter([
$exception->owner?->name !== null ? 'Owner: '.$exception->owner->name : null,
FindingExceptionResource::relativeTimeDescription($exception->review_due_at)
?? FindingExceptionResource::relativeTimeDescription($exception->expires_at),
is_string($exception->request_reason) && $exception->request_reason !== ''
? $exception->request_reason
: null,
]));
return [
'family_key' => 'finding_exceptions',
'source_model' => FindingException::class,
'source_key' => (string) $exception->getKey(),
'tenant_id' => $exception->tenant ? (int) $exception->tenant->getKey() : null,
'tenant_label' => $exception->tenant?->name,
'headline' => $findingLabel,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => match (true) {
(string) $exception->status === FindingException::STATUS_PENDING => 0,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRED => 1,
(string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT => 2,
(string) $exception->current_validity_state === FindingException::VALIDITY_EXPIRING => 3,
default => 4,
},
'status_label' => $this->findingExceptionStatusLabel($exception),
'destination_url' => $this->appendQuery(
FindingExceptionsQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $exception->tenant?->external_id,
'exception' => (int) $exception->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/** /**
* @param array<string, mixed> $row * @param array<string, mixed> $row
* @return array<string, mixed> * @return array<string, mixed>
@ -855,6 +1009,39 @@ private function alertsSummary(int $count): string
); );
} }
private function findingExceptionsSummary(int $count, int $pendingCount, int $expiringCount, int $lapsedCount): string
{
if ($count === 0) {
return 'No finding exceptions need review in the current scope.';
}
return sprintf(
'%d finding exception%s need review. %d pending, %d expiring, and %d lapsed or missing support.',
$count,
$count === 1 ? '' : 's',
$pendingCount,
$expiringCount,
$lapsedCount,
);
}
private function findingExceptionStatusLabel(FindingException $exception): string
{
if ((string) $exception->status === FindingException::STATUS_PENDING) {
return 'Pending';
}
if (in_array((string) $exception->current_validity_state, [
FindingException::VALIDITY_EXPIRING,
FindingException::VALIDITY_EXPIRED,
FindingException::VALIDITY_MISSING_SUPPORT,
], true)) {
return Str::of((string) $exception->current_validity_state)->replace('_', ' ')->title()->value();
}
return Str::of((string) $exception->status)->replace('_', ' ')->title()->value();
}
private function reviewSummary(int $followUpCount, int $changedCount): string private function reviewSummary(int $followUpCount, int $changedCount): string
{ {
$total = $followUpCount + $changedCount; $total = $followUpCount + $changedCount;

View File

@ -12,8 +12,6 @@ final class TrustedStatePolicy
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions'; public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
public const SYSTEM_RUNBOOKS = 'system_runbooks';
/** /**
* @return array{ * @return array{
* name: string, * name: string,
@ -329,92 +327,6 @@ public function firstSlice(): array
'scopedTenant', 'scopedTenant',
], ],
], ],
self::SYSTEM_RUNBOOKS => [
'component_name' => 'System runbooks',
'plane' => 'system_platform',
'route_anchor' => null,
'authority_sources' => [
'allowed_tenant_universe',
'explicit_scoped_query',
],
'locked_identities' => [],
'locked_identity_fields' => [],
'mutable_selectors' => [
'findingsTenantId',
'tenantId',
'findingsScopeMode',
'scopeMode',
],
'mutable_selector_fields' => [
$this->field(
name: 'findingsTenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public ?int $findingsTenantId = null;',
'resolveAllowedOrFail($this->findingsTenantId)',
],
notes: 'Single-tenant runbook proposal only; it must be validated against the operator allowed-tenant universe.',
),
$this->field(
name: 'tenantId',
stateClass: TrustedStateClass::Presentation,
phpType: '?int',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public ?int $tenantId = null;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
$this->field(
name: 'findingsScopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'public string $findingsScopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
'trustedFindingsScopeFromState(',
],
notes: 'Scope mode remains mutable UI state but protected actions re-normalize it into a trusted scope DTO.',
),
$this->field(
name: 'scopeMode',
stateClass: TrustedStateClass::Presentation,
phpType: 'string',
sourceOfTruth: 'presentation_only',
usedForProtectedAction: false,
revalidationRequired: false,
implementationMarkers: [
'public string $scopeMode = FindingsLifecycleBackfillScope::MODE_ALL_TENANTS;',
],
notes: 'Mirrored display state for the last trusted preflight result.',
),
],
'server_derived_authority_fields' => [
$this->field(
name: 'findingsScope',
stateClass: TrustedStateClass::ServerDerivedAuthority,
phpType: 'FindingsLifecycleBackfillScope',
sourceOfTruth: 'allowed_tenant_universe',
usedForProtectedAction: true,
revalidationRequired: true,
implementationMarkers: [
'trustedFindingsScopeFromFormData(',
'trustedFindingsScopeFromState(',
'resolveAllowedOrFail(',
],
notes: 'Protected actions must convert mutable selector state into a trusted scope DTO via AllowedTenantUniverse.',
),
],
'forbidden_public_authority_fields' => [],
],
]; ];
} }

View File

@ -5,8 +5,10 @@
namespace App\Support\Navigation; namespace App\Support\Navigation;
use App\Filament\Pages\BaselineCompareMatrix; use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext final readonly class CanonicalNavigationContext
@ -18,6 +20,7 @@ public function __construct(
public string $sourceSurface, public string $sourceSurface,
public string $canonicalRouteName, public string $canonicalRouteName,
public ?int $tenantId = null, public ?int $tenantId = null,
public ?string $familyKey = null,
public ?string $backLinkLabel = null, public ?string $backLinkLabel = null,
public ?string $backLinkUrl = null, public ?string $backLinkUrl = null,
public array $filterPayload = [], public array $filterPayload = [],
@ -56,12 +59,42 @@ public static function fromRequest(Request $request): ?self
sourceSurface: $sourceSurface, sourceSurface: $sourceSurface,
canonicalRouteName: $canonicalRouteName, canonicalRouteName: $canonicalRouteName,
tenantId: is_numeric($tenantId) ? (int) $tenantId : null, tenantId: is_numeric($tenantId) ? (int) $tenantId : null,
familyKey: is_string($payload['family_key'] ?? null) && (string) $payload['family_key'] !== ''
? (string) $payload['family_key']
: null,
backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null, backLinkLabel: is_string($payload['back_label'] ?? null) ? (string) $payload['back_label'] : null,
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null, backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
filterPayload: [], filterPayload: [],
); );
} }
public static function forGovernanceInbox(
string $canonicalRouteName,
?int $tenantId,
?string $familyKey,
string $backLinkUrl,
): self {
return new self(
sourceSurface: 'governance.inbox',
canonicalRouteName: $canonicalRouteName,
tenantId: $tenantId,
familyKey: $familyKey,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $backLinkUrl,
);
}
public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = null): self
{
return new self(
sourceSurface: 'tenant_registry',
canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')),
tenantId: $tenantId,
backLinkLabel: 'Back to tenant registry',
backLinkUrl: $backLinkUrl,
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -117,6 +150,7 @@ private function navPayload(): array
'source_surface' => $this->sourceSurface, 'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName, 'canonical_route_name' => $this->canonicalRouteName,
'tenant_id' => $this->tenantId, 'tenant_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel, 'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl, 'back_url' => $this->backLinkUrl,
], static fn (mixed $value): bool => $value !== null && $value !== ''); ], static fn (mixed $value): bool => $value !== null && $value !== '');

View File

@ -278,7 +278,6 @@ private static function canonicalDefinitions(): array
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60), 'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120), 'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30), 'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
]; ];
} }
@ -290,27 +289,36 @@ private static function operationAliases(): array
return [ return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true), new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true), new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true), new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'), new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
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('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('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true), new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
@ -325,13 +333,13 @@ private static function operationAliases(): array
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true), new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true), new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true), new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true), new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true), new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true), new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
]; ];
} }
} }

View File

@ -0,0 +1,416 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\ResolvedEvidence;
use App\Support\Baselines\BaselineSubjectKey;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class CrossTenantComparePreviewBuilder
{
public function __construct(
private readonly CurrentStateHashResolver $currentStateHashResolver,
) {}
/**
* @return array{
* selection: array{
* workspaceId: int,
* sourceTenantId: int,
* sourceTenantName: string,
* targetTenantId: int,
* targetTenantName: string,
* policyTypes: list<string>
* },
* summary: array{match: int, different: int, missing: int, ambiguous: int, blocked: int, total: int},
* subjects: list<array<string, mixed>>
* }
*/
public function build(CrossTenantCompareSelection $selection): array
{
$sourceIndex = $this->indexTenantSubjects($selection->sourceTenant, $selection->policyTypes);
$targetIndex = $this->indexTenantSubjects($selection->targetTenant, $selection->policyTypes);
$sourceEvidence = $this->resolvedEvidenceMap($selection->sourceTenant, $sourceIndex['evidence_subjects']);
$targetEvidence = $this->resolvedEvidenceMap($selection->targetTenant, $targetIndex['evidence_subjects']);
$subjects = [];
$summary = [
'match' => 0,
'different' => 0,
'missing' => 0,
'ambiguous' => 0,
'blocked' => 0,
'total' => 0,
];
foreach ($sourceIndex['preview_subjects'] as $sourceSubject) {
$previewSubject = $this->buildPreviewSubject(
sourceSubject: $sourceSubject,
sourceTenant: $selection->sourceTenant,
targetTenant: $selection->targetTenant,
targetIndex: $targetIndex['subjects'],
sourceEvidence: $sourceEvidence,
targetEvidence: $targetEvidence,
);
$subjects[] = $previewSubject;
$summary[$previewSubject['state']]++;
$summary['total']++;
}
usort($subjects, function (array $left, array $right): int {
$policyTypeComparison = strcmp((string) ($left['policyType'] ?? ''), (string) ($right['policyType'] ?? ''));
if ($policyTypeComparison !== 0) {
return $policyTypeComparison;
}
$displayNameComparison = strcmp(
Str::lower((string) ($left['displayName'] ?? '')),
Str::lower((string) ($right['displayName'] ?? '')),
);
if ($displayNameComparison !== 0) {
return $displayNameComparison;
}
return strcmp((string) ($left['subjectKey'] ?? ''), (string) ($right['subjectKey'] ?? ''));
});
return [
'selection' => [
'workspaceId' => $selection->workspaceId(),
'sourceTenantId' => $selection->sourceTenantId(),
'sourceTenantName' => (string) $selection->sourceTenant->name,
'targetTenantId' => $selection->targetTenantId(),
'targetTenantName' => (string) $selection->targetTenant->name,
'policyTypes' => $selection->policyTypes,
],
'summary' => $summary,
'subjects' => $subjects,
];
}
/**
* @param Tenant $tenant
* @param list<string> $policyTypes
* @return array{
* preview_subjects: list<array<string, mixed>>,
* evidence_subjects: list<array{policy_type: string, subject_external_id: string}>,
* subjects: array<string, array<string, mixed>>
* }
*/
private function indexTenantSubjects(Tenant $tenant, array $policyTypes): array
{
$inventoryItems = InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->when(
$policyTypes !== [],
fn ($query) => $query->whereIn('policy_type', $policyTypes),
)
->orderBy('policy_type')
->orderBy('display_name')
->orderBy('id')
->get();
$subjects = [];
$previewSubjects = [];
$evidenceSubjects = [];
foreach ($inventoryItems as $inventoryItem) {
if (! $inventoryItem instanceof InventoryItem) {
continue;
}
$policyType = trim((string) $inventoryItem->policy_type);
$subjectKey = BaselineSubjectKey::forPolicy(
$policyType,
is_string($inventoryItem->display_name ?? null) ? (string) $inventoryItem->display_name : null,
is_string($inventoryItem->external_id ?? null) ? (string) $inventoryItem->external_id : null,
);
$subjectRecord = $this->inventorySubjectRecord($tenant, $inventoryItem, $policyType, $subjectKey);
if ($subjectKey === null) {
$previewSubjects[] = [
...$subjectRecord,
'resolution' => 'identifier_missing',
'duplicateCount' => 1,
];
continue;
}
$indexKey = $this->subjectIndexKey($policyType, $subjectKey);
if (! array_key_exists($indexKey, $subjects)) {
$subjects[$indexKey] = [
'policyType' => $policyType,
'subjectKey' => $subjectKey,
'displayName' => $subjectRecord['displayName'],
'items' => [],
];
}
$subjects[$indexKey]['items'][] = $subjectRecord;
}
foreach ($subjects as $indexKey => $subjectGroup) {
$items = is_array($subjectGroup['items'] ?? null) ? $subjectGroup['items'] : [];
$firstItem = $items[0] ?? null;
if (! is_array($firstItem)) {
continue;
}
$previewSubjects[] = [
...$firstItem,
'resolution' => count($items) > 1 ? 'ambiguous_match' : 'resolved',
'duplicateCount' => count($items),
];
if (count($items) === 1) {
$evidenceSubjects[] = [
'policy_type' => (string) $firstItem['policyType'],
'subject_external_id' => (string) $firstItem['subjectExternalId'],
];
}
$subjects[$indexKey]['representative'] = $firstItem;
$subjects[$indexKey]['duplicateCount'] = count($items);
}
return [
'preview_subjects' => $previewSubjects,
'evidence_subjects' => $evidenceSubjects,
'subjects' => $subjects,
];
}
/**
* @param array<string, array<string, mixed>> $targetIndex
* @param array<string, ResolvedEvidence|null> $sourceEvidence
* @param array<string, ResolvedEvidence|null> $targetEvidence
* @return array<string, mixed>
*/
private function buildPreviewSubject(
array $sourceSubject,
Tenant $sourceTenant,
Tenant $targetTenant,
array $targetIndex,
array $sourceEvidence,
array $targetEvidence,
): array {
$policyType = (string) ($sourceSubject['policyType'] ?? '');
$displayName = (string) ($sourceSubject['displayName'] ?? '');
$subjectKey = is_string($sourceSubject['subjectKey'] ?? null) ? (string) $sourceSubject['subjectKey'] : null;
$reasonCodes = [];
$state = 'blocked';
$trustLevel = 'unusable';
$sourceEvidenceRecord = $this->resolvedEvidenceForSubject($sourceEvidence, $sourceSubject);
$targetEvidenceRecord = null;
$targetSubject = null;
if (($sourceSubject['resolution'] ?? null) === 'identifier_missing') {
$reasonCodes[] = 'source_identifier_missing';
} elseif (($sourceSubject['resolution'] ?? null) === 'ambiguous_match') {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'source_subject_ambiguous';
} elseif ($subjectKey !== null) {
$targetSubject = $targetIndex[$this->subjectIndexKey($policyType, $subjectKey)] ?? null;
if (! is_array($targetSubject)) {
$state = 'missing';
$trustLevel = $sourceEvidenceRecord instanceof ResolvedEvidence
&& $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
$reasonCodes[] = 'target_subject_missing';
} elseif ((int) ($targetSubject['duplicateCount'] ?? 0) > 1) {
$state = 'ambiguous';
$trustLevel = 'diagnostic_only';
$reasonCodes[] = 'target_subject_ambiguous';
} else {
$representative = $targetSubject['representative'] ?? null;
if (is_array($representative)) {
$targetEvidenceRecord = $this->resolvedEvidenceForSubject($targetEvidence, $representative);
}
if (! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if (! $targetEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
if ($sourceEvidenceRecord instanceof ResolvedEvidence && $targetEvidenceRecord instanceof ResolvedEvidence) {
$state = $sourceEvidenceRecord->hash === $targetEvidenceRecord->hash ? 'match' : 'different';
$trustLevel = $sourceEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
&& $targetEvidenceRecord->fidelity === EvidenceProvenance::FidelityContent
? 'trustworthy'
: 'limited_confidence';
if ($sourceEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($targetEvidenceRecord->fidelity !== EvidenceProvenance::FidelityContent) {
$reasonCodes[] = 'target_evidence_refresh_required';
}
} else {
$state = 'blocked';
$trustLevel = 'unusable';
}
}
}
if ($state === 'missing' && ! $sourceEvidenceRecord instanceof ResolvedEvidence) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
if ($state === 'blocked' && $reasonCodes === []) {
$reasonCodes[] = 'source_evidence_refresh_required';
}
$reasonCodes = array_values(array_unique($reasonCodes));
return [
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'state' => $state,
'trustLevel' => $trustLevel,
'reasonCodes' => $reasonCodes,
'source' => $this->subjectSidePayload($sourceTenant, $sourceSubject, $sourceEvidenceRecord),
'target' => $this->subjectSidePayload(
$targetTenant,
is_array($targetSubject['representative'] ?? null) ? $targetSubject['representative'] : null,
$targetEvidenceRecord,
),
];
}
/**
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
* @return array<string, ResolvedEvidence|null>
*/
private function resolvedEvidenceMap(Tenant $tenant, array $subjects): array
{
return $this->currentStateHashResolver->resolveForSubjects($tenant, $subjects);
}
/**
* @param array<string, mixed>|null $subject
* @return array<string, mixed>
*/
private function subjectSidePayload(Tenant $tenant, ?array $subject, ?ResolvedEvidence $evidence): array
{
return [
'tenantId' => (int) $tenant->getKey(),
'tenantName' => (string) $tenant->name,
'inventoryItemId' => is_numeric($subject['inventoryItemId'] ?? null) ? (int) $subject['inventoryItemId'] : null,
'subjectExternalId' => is_string($subject['subjectExternalId'] ?? null) ? (string) $subject['subjectExternalId'] : null,
'displayName' => is_string($subject['displayName'] ?? null) ? (string) $subject['displayName'] : null,
'subjectKey' => is_string($subject['subjectKey'] ?? null) ? (string) $subject['subjectKey'] : null,
'lastSeenAt' => is_string($subject['lastSeenAt'] ?? null) ? (string) $subject['lastSeenAt'] : null,
'evidence' => $this->evidencePayload($evidence),
];
}
/**
* @return array{
* policyType: string,
* displayName: string,
* subjectKey: ?string,
* inventoryItemId: int,
* subjectExternalId: string,
* lastSeenAt: ?string
* }
*/
private function inventorySubjectRecord(Tenant $tenant, InventoryItem $inventoryItem, string $policyType, ?string $subjectKey): array
{
$displayName = is_string($inventoryItem->display_name ?? null) ? trim((string) $inventoryItem->display_name) : '';
$displayName = $displayName !== ''
? $displayName
: ($subjectKey !== null ? Str::headline($subjectKey) : Str::headline($policyType));
return [
'tenantId' => (int) $tenant->getKey(),
'policyType' => $policyType,
'displayName' => $displayName,
'subjectKey' => $subjectKey,
'inventoryItemId' => (int) $inventoryItem->getKey(),
'subjectExternalId' => (string) $inventoryItem->external_id,
'lastSeenAt' => $inventoryItem->last_seen_at?->toIso8601String(),
];
}
/**
* @param array<string, ResolvedEvidence|null> $evidenceMap
*/
private function resolvedEvidenceForSubject(array $evidenceMap, array $subject): ?ResolvedEvidence
{
$policyType = trim((string) ($subject['policyType'] ?? ''));
$subjectExternalId = trim((string) ($subject['subjectExternalId'] ?? ''));
if ($policyType === '' || $subjectExternalId === '') {
return null;
}
$key = $policyType.'|'.$subjectExternalId;
$evidence = $evidenceMap[$key] ?? null;
return $evidence instanceof ResolvedEvidence ? $evidence : null;
}
/**
* @return array{
* hash: string,
* fidelity: string,
* source: string,
* observedAt: ?string,
* policyVersionId: ?int,
* operationRunId: ?int,
* capturePurpose: ?string
* }|null
*/
private function evidencePayload(?ResolvedEvidence $evidence): ?array
{
if (! $evidence instanceof ResolvedEvidence) {
return null;
}
return [
'hash' => $evidence->hash,
'fidelity' => $evidence->fidelity,
'source' => $evidence->source,
'observedAt' => $evidence->observedAt?->toIso8601String(),
'policyVersionId' => is_numeric($evidence->meta['policy_version_id'] ?? null)
? (int) $evidence->meta['policy_version_id']
: null,
'operationRunId' => is_numeric($evidence->meta['operation_run_id'] ?? null)
? (int) $evidence->meta['operation_run_id']
: null,
'capturePurpose' => is_string($evidence->meta['capture_purpose'] ?? null)
? (string) $evidence->meta['capture_purpose']
: null,
];
}
private function subjectIndexKey(string $policyType, string $subjectKey): string
{
return $policyType.'|'.$subjectKey;
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
use App\Models\Tenant;
use InvalidArgumentException;
final readonly class CrossTenantCompareSelection
{
public Tenant $sourceTenant;
public Tenant $targetTenant;
/**
* @var list<string>
*/
public array $policyTypes;
/**
* @param list<string> $policyTypes
*/
public function __construct(
Tenant $sourceTenant,
Tenant $targetTenant,
array $policyTypes = [],
) {
$this->sourceTenant = $sourceTenant;
$this->targetTenant = $targetTenant;
if ((int) $this->sourceTenant->getKey() === (int) $this->targetTenant->getKey()) {
throw new InvalidArgumentException('Source and target tenants must differ.');
}
if ((int) $this->sourceTenant->workspace_id !== (int) $this->targetTenant->workspace_id) {
throw new InvalidArgumentException('Source and target tenants must belong to the same workspace.');
}
$this->policyTypes = $this->normalizePolicyTypes($policyTypes);
}
public function workspaceId(): int
{
return (int) $this->sourceTenant->workspace_id;
}
public function sourceTenantId(): int
{
return (int) $this->sourceTenant->getKey();
}
public function targetTenantId(): int
{
return (int) $this->targetTenant->getKey();
}
public function hasPolicyTypeFilter(): bool
{
return $this->policyTypes !== [];
}
/**
* @param list<string> $policyTypes
* @return list<string>
*/
private function normalizePolicyTypes(array $policyTypes): array
{
$normalized = array_values(array_unique(array_filter(array_map(static function (mixed $policyType): ?string {
if (! is_string($policyType)) {
return null;
}
$normalizedPolicyType = trim($policyType);
return $normalizedPolicyType !== '' ? $normalizedPolicyType : null;
}, $policyTypes))));
sort($normalized, SORT_STRING);
return $normalized;
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Support\PortfolioCompare;
final class CrossTenantPromotionPreflight
{
/**
* @param array{
* selection?: array<string, mixed>,
* subjects?: list<array<string, mixed>>
* } $preview
* @return array{
* selection: array<string, mixed>,
* summary: array{ready: int, blocked: int, manual_mapping_required: int, total: int},
* blockedReasonCounts: array<string, int>,
* buckets: array{
* ready: list<array<string, mixed>>,
* blocked: list<array<string, mixed>>,
* manual_mapping_required: list<array<string, mixed>>
* }
* }
*/
public function build(array $preview): array
{
$subjects = is_array($preview['subjects'] ?? null) ? $preview['subjects'] : [];
$buckets = [
'ready' => [],
'blocked' => [],
'manual_mapping_required' => [],
];
$blockedReasonCounts = [];
foreach ($subjects as $subject) {
if (! is_array($subject)) {
continue;
}
$decision = $this->classifySubject($subject);
$subject['preflight'] = $decision;
$buckets[$decision['bucket']][] = $subject;
if ($decision['bucket'] !== 'ready') {
foreach ($decision['reasonCodes'] as $reasonCode) {
$blockedReasonCounts[$reasonCode] = ($blockedReasonCounts[$reasonCode] ?? 0) + 1;
}
}
}
return [
'selection' => is_array($preview['selection'] ?? null) ? $preview['selection'] : [],
'summary' => [
'ready' => count($buckets['ready']),
'blocked' => count($buckets['blocked']),
'manual_mapping_required' => count($buckets['manual_mapping_required']),
'total' => count($buckets['ready']) + count($buckets['blocked']) + count($buckets['manual_mapping_required']),
],
'blockedReasonCounts' => $blockedReasonCounts,
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $subject
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
*/
private function classifySubject(array $subject): array
{
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
$reasonCodes = is_array($subject['reasonCodes'] ?? null)
? array_values(array_filter($subject['reasonCodes'], 'is_string'))
: [];
if (in_array('source_identifier_missing', $reasonCodes, true)) {
return $this->decision('blocked', ['source_identifier_missing']);
}
if (in_array('source_subject_ambiguous', $reasonCodes, true)) {
return $this->decision('blocked', ['source_subject_ambiguous']);
}
if (in_array('target_subject_ambiguous', $reasonCodes, true) || $state === 'ambiguous') {
return $this->decision('manual_mapping_required', ['target_subject_ambiguous']);
}
$sourceEvidence = is_array(data_get($subject, 'source.evidence')) ? data_get($subject, 'source.evidence') : null;
$targetEvidence = is_array(data_get($subject, 'target.evidence')) ? data_get($subject, 'target.evidence') : null;
if (! $this->evidenceSupportsPromotion($sourceEvidence)) {
return $this->decision('blocked', ['source_evidence_refresh_required']);
}
if ($state !== 'missing' && ! $this->evidenceSupportsPromotion($targetEvidence)) {
return $this->decision('blocked', ['target_evidence_refresh_required']);
}
return match ($state) {
'match' => $this->decision('ready', ['target_already_aligned']),
'different' => $this->decision('ready', ['target_subject_requires_update']),
'missing' => $this->decision('ready', ['target_subject_missing']),
default => $this->decision('blocked', ['source_evidence_refresh_required']),
};
}
/**
* @param array<string, mixed>|null $evidence
*/
private function evidenceSupportsPromotion(?array $evidence): bool
{
return is_array($evidence)
&& is_string($evidence['fidelity'] ?? null)
&& (string) $evidence['fidelity'] === 'content';
}
/**
* @param list<string> $reasonCodes
* @return array{bucket: 'ready'|'blocked'|'manual_mapping_required', reasonCodes: list<string>, reasonLabels: list<string>}
*/
private function decision(string $bucket, array $reasonCodes): array
{
return [
'bucket' => $bucket,
'reasonCodes' => $reasonCodes,
'reasonLabels' => array_map(fn (string $reasonCode): string => $this->reasonLabel($reasonCode), $reasonCodes),
];
}
private function reasonLabel(string $reasonCode): string
{
return match ($reasonCode) {
'source_identifier_missing' => 'Source tenant subject is missing a stable compare identifier.',
'source_subject_ambiguous' => 'Source tenant subject resolves to multiple candidates and cannot drive promotion safely.',
'target_subject_ambiguous' => 'Target tenant has multiple matching subjects and needs manual mapping.',
'source_evidence_refresh_required' => 'Refresh source evidence before relying on this subject.',
'target_evidence_refresh_required' => 'Refresh target evidence before relying on this subject.',
'target_already_aligned' => 'Target tenant already matches the source for this subject.',
'target_subject_requires_update' => 'Target tenant differs from the source but has enough evidence for later promotion planning.',
'target_subject_missing' => 'Target tenant does not currently contain this subject and can be planned as a missing target item.',
default => 'This subject needs additional review before promotion planning can continue.',
};
}
}

View File

@ -4,8 +4,9 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Support\Ai\AiPolicyMode;
use App\Models\Finding; use App\Models\Finding;
use App\Services\Localization\LocaleResolver;
use App\Support\Ai\AiPolicyMode;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
@ -29,6 +30,25 @@ public function __construct()
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
)); ));
$this->register(new SettingDefinition(
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', LocaleResolver::supportedLocales()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
return LocaleResolver::normalize($value);
},
));
$this->register(new SettingDefinition( $this->register(new SettingDefinition(
domain: 'backup', domain: 'backup',
key: 'retention_keep_last_default', key: 'retention_keep_last_default',

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\SupportRequest;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
final class ExternalSupportDeskHandoffService
{
private const int MAX_TIMEOUT_SECONDS = 5;
/**
* @return array{
* successful: bool,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* failure_summary: ?string
* }
*/
public function createTicket(SupportRequest $supportRequest): array
{
if (! $this->targetIsConfigured()) {
return $this->failed('External support desk target is not configured.');
}
try {
$response = Http::timeout($this->timeoutSeconds())
->acceptJson()
->asJson()
->withHeaders($this->headers())
->post($this->createUrl(), $this->payloadFor($supportRequest));
} catch (ConnectionException) {
return $this->failed('External support desk did not respond before the configured timeout.');
} catch (RequestException $exception) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$exception->response->status().').');
}
if (! $response->successful()) {
return $this->failed('External support desk rejected the ticket create request (HTTP '.$response->status().').');
}
$responsePayload = $response->json();
$responsePayload = is_array($responsePayload) ? $responsePayload : [];
$reference = $this->normalizeReference(
data_get($responsePayload, 'ticket_reference')
?? data_get($responsePayload, 'external_ticket_reference')
?? data_get($responsePayload, 'reference')
?? data_get($responsePayload, 'key')
?? data_get($responsePayload, 'id'),
throwOnInvalid: false,
);
if ($reference === null) {
return $this->failed('External support desk did not return a ticket reference.');
}
$url = $this->normalizeUrl(
data_get($responsePayload, 'ticket_url')
?? data_get($responsePayload, 'external_ticket_url')
?? data_get($responsePayload, 'url')
?? data_get($responsePayload, 'web_url')
?? data_get($responsePayload, 'html_url'),
throwOnInvalid: false,
) ?? $this->urlFromTemplate($reference);
return [
'successful' => true,
'external_ticket_reference' => $reference,
'external_ticket_url' => $url,
'failure_summary' => null,
];
}
/**
* @return array{external_ticket_reference: string, external_ticket_url: ?string}
*/
public function normalizeLinkedTicket(mixed $reference, mixed $url): array
{
$normalizedReference = $this->normalizeReference($reference, throwOnInvalid: true);
if ($normalizedReference === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
return [
'external_ticket_reference' => $normalizedReference,
'external_ticket_url' => $this->normalizeUrl($url, throwOnInvalid: true) ?? $this->urlFromTemplate($normalizedReference),
];
}
public function targetIsConfigured(): bool
{
return (bool) config('support_desk.target.enabled', false)
&& $this->createUrl() !== null;
}
public function targetName(): string
{
$name = config('support_desk.target.name', 'External support desk');
return is_string($name) && trim($name) !== ''
? trim($name)
: 'External support desk';
}
public function timeoutSeconds(): int
{
$configured = config('support_desk.target.timeout_seconds', self::MAX_TIMEOUT_SECONDS);
$seconds = is_numeric($configured) ? (int) $configured : self::MAX_TIMEOUT_SECONDS;
return max(1, min($seconds, self::MAX_TIMEOUT_SECONDS));
}
/**
* @return array{successful: false, external_ticket_reference: null, external_ticket_url: null, failure_summary: string}
*/
private function failed(string $summary): array
{
return [
'successful' => false,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'failure_summary' => $this->boundedFailureSummary($summary),
];
}
private function createUrl(): ?string
{
return $this->normalizeUrl(config('support_desk.target.create_url'), throwOnInvalid: false);
}
/**
* @return array<string, string>
*/
private function headers(): array
{
$headers = [];
$token = config('support_desk.target.api_token');
if (is_string($token) && trim($token) !== '') {
$headers['Authorization'] = 'Bearer '.trim($token);
}
return $headers;
}
/**
* @return array<string, mixed>
*/
private function payloadFor(SupportRequest $supportRequest): array
{
return [
'support_request' => [
'internal_reference' => $supportRequest->internal_reference,
'severity' => $supportRequest->severity,
'summary' => $supportRequest->summary,
'reproduction_notes' => $supportRequest->reproduction_notes,
'contact_name' => $supportRequest->contact_name,
'contact_email' => $supportRequest->contact_email,
'primary_context_type' => $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? $supportRequest->operation_run_id
: $supportRequest->tenant_id,
'workspace_id' => $supportRequest->workspace_id,
'tenant_id' => $supportRequest->tenant_id,
'operation_run_id' => $supportRequest->operation_run_id,
],
'context_envelope' => $supportRequest->context_envelope,
];
}
private function normalizeReference(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value) && ! is_numeric($value)) {
return null;
}
$reference = trim((string) $value);
if ($reference === '') {
return null;
}
if (mb_strlen($reference) > 128 || preg_match('/\A[A-Za-z0-9][A-Za-z0-9._:-]*\z/', $reference) !== 1) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference format is invalid.',
]);
}
return null;
}
return $reference;
}
private function normalizeUrl(mixed $value, bool $throwOnInvalid): ?string
{
if (! is_string($value)) {
return null;
}
$url = trim($value);
if ($url === '') {
return null;
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (! in_array($scheme, ['http', 'https'], true) || filter_var($url, FILTER_VALIDATE_URL) === false) {
if ($throwOnInvalid) {
throw ValidationException::withMessages([
'external_ticket_url' => 'The external ticket URL must be a valid HTTP or HTTPS URL.',
]);
}
return null;
}
return $url;
}
private function urlFromTemplate(string $reference): ?string
{
$template = config('support_desk.target.ticket_url_template');
if (! is_string($template) || trim($template) === '') {
return null;
}
$url = str_replace(
['{reference}', '{ticket}'],
rawurlencode($reference),
trim($template),
);
return $this->normalizeUrl($url, throwOnInvalid: false);
}
private function boundedFailureSummary(string $summary): string
{
$summary = trim(preg_replace('/\s+/', ' ', $summary) ?? $summary);
return mb_substr($summary, 0, 500);
}
}

View File

@ -20,6 +20,7 @@ public function __construct(
private readonly CapabilityResolver $capabilityResolver, private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder, private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator, private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
private readonly WorkspaceAuditLogger $workspaceAuditLogger, private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {} ) {}
@ -95,7 +96,7 @@ private function submit(
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email); $contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection(); $connection = SupportRequest::query()->getModel()->getConnection();
return $connection->transaction(function () use ( $supportRequest = $connection->transaction(function () use (
$actor, $actor,
$contactEmail, $contactEmail,
$contactName, $contactName,
@ -127,6 +128,181 @@ private function submit(
return $supportRequest; return $supportRequest;
}); });
return $this->finalizeExternalHandoff($supportRequest, $actor, $validated);
}
/**
* @param array{
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string
* } $validated
*/
private function finalizeExternalHandoff(SupportRequest $supportRequest, User $actor, array $validated): SupportRequest
{
$mode = $validated['external_handoff_mode'];
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
])->save();
return $supportRequest->refresh();
}
if ($mode === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$linkedTicket = $this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET,
'external_ticket_reference' => $linkedTicket['external_ticket_reference'],
'external_ticket_url' => $linkedTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketLinked($supportRequest, $actor);
return $supportRequest;
}
$createdTicket = $this->externalSupportDeskHandoffService->createTicket($supportRequest);
if ($createdTicket['successful']) {
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => $createdTicket['external_ticket_reference'],
'external_ticket_url' => $createdTicket['external_ticket_url'],
'external_handoff_failure_summary' => null,
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalTicketCreated($supportRequest, $actor);
return $supportRequest;
}
$supportRequest->forceFill([
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_CREATE_EXTERNAL_TICKET,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => $createdTicket['failure_summary'],
])->save();
$supportRequest = $supportRequest->refresh();
$this->workspaceAuditLogger->logSupportRequestExternalHandoffFailed($supportRequest, $actor);
return $supportRequest;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestTenantHandoffSummary(Tenant $tenant, User $actor): ?array
{
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_TENANT)
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }|null
*/
public function latestOperationRunHandoffSummary(OperationRun $run, User $actor): ?array
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
$supportRequest = SupportRequest::query()
->where('workspace_id', (int) $run->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->where('primary_context_type', SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
->where('operation_run_id', (int) $run->getKey())
->latest('created_at')
->latest('id')
->first();
return $supportRequest instanceof SupportRequest
? $this->summaryFor($supportRequest)
: null;
}
/**
* @return array{
* internal_reference: string,
* primary_context_type: string,
* primary_context_id: int|null,
* submitted_at: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* external_handoff_failure_summary: ?string,
* has_external_link: bool,
* has_failure: bool
* }
*/
private function summaryFor(SupportRequest $supportRequest): array
{
return [
'internal_reference' => (string) $supportRequest->internal_reference,
'primary_context_type' => (string) $supportRequest->primary_context_type,
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
? (is_numeric($supportRequest->operation_run_id) ? (int) $supportRequest->operation_run_id : null)
: (is_numeric($supportRequest->tenant_id) ? (int) $supportRequest->tenant_id : null),
'submitted_at' => $supportRequest->created_at?->toIso8601String(),
'external_handoff_mode' => (string) ($supportRequest->external_handoff_mode ?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY),
'external_ticket_reference' => $this->normalizeNullableString($supportRequest->external_ticket_reference),
'external_ticket_url' => $this->normalizeNullableString($supportRequest->external_ticket_url),
'external_handoff_failure_summary' => $this->normalizeNullableString($supportRequest->external_handoff_failure_summary),
'has_external_link' => $supportRequest->hasExternalTicket(),
'has_failure' => $supportRequest->hasExternalHandoffFailure(),
];
} }
/** /**
@ -137,10 +313,20 @@ private function submit(
* reproduction_notes: ?string, * reproduction_notes: ?string,
* contact_name: ?string, * contact_name: ?string,
* contact_email: ?string, * contact_email: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* } * }
*/ */
private function validate(array $data): array private function validate(array $data): array
{ {
$requestedHandoffMode = $this->normalizeNullableString($data['external_handoff_mode'] ?? null)
?? SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
if (! $this->externalSupportDeskHandoffService->targetIsConfigured()) {
$requestedHandoffMode = SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY;
}
$validated = validator( $validated = validator(
[ [
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL, 'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
@ -148,6 +334,9 @@ private function validate(array $data): array
'reproduction_notes' => $data['reproduction_notes'] ?? null, 'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? null, 'contact_name' => $data['contact_name'] ?? null,
'contact_email' => $data['contact_email'] ?? null, 'contact_email' => $data['contact_email'] ?? null,
'external_handoff_mode' => $requestedHandoffMode,
'external_ticket_reference' => $data['external_ticket_reference'] ?? null,
'external_ticket_url' => $data['external_ticket_url'] ?? null,
], ],
[ [
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())], 'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
@ -155,6 +344,9 @@ private function validate(array $data): array
'reproduction_notes' => ['nullable', 'string'], 'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'], 'contact_name' => ['nullable', 'string'],
'contact_email' => ['nullable', 'email'], 'contact_email' => ['nullable', 'email'],
'external_handoff_mode' => ['required', 'string', Rule::in(SupportRequest::externalHandoffModeValues())],
'external_ticket_reference' => ['nullable', 'string', 'max:255'],
'external_ticket_url' => ['nullable', 'url', 'max:2048'],
], ],
)->validate(); )->validate();
@ -169,6 +361,27 @@ private function validate(array $data): array
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null); $validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null); $validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null); $validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
$validated['external_ticket_reference'] = $this->normalizeNullableString($validated['external_ticket_reference'] ?? null);
$validated['external_ticket_url'] = $this->normalizeNullableString($validated['external_ticket_url'] ?? null);
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET
&& $validated['external_ticket_reference'] === null) {
throw ValidationException::withMessages([
'external_ticket_reference' => 'The external ticket reference field is required.',
]);
}
if ($validated['external_handoff_mode'] === SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$this->externalSupportDeskHandoffService->normalizeLinkedTicket(
$validated['external_ticket_reference'],
$validated['external_ticket_url'],
);
}
if ($validated['external_handoff_mode'] !== SupportRequest::EXTERNAL_HANDOFF_MODE_LINK_EXISTING_TICKET) {
$validated['external_ticket_reference'] = null;
$validated['external_ticket_url'] = null;
}
return $validated; return $validated;
} }

View File

@ -640,23 +640,18 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery', 'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed', 'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance', 'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Runbooks is a workflow utility hub with its own trusted-state, authorization, and confirmation semantics rather than a declaration-backed record or table surface.', 'explicitReason' => 'Runbooks remains a system utility shell outside the declaration-backed record or table surface; it currently exposes no supported launch action after lifecycle-backfill removal.',
'evidence' => [ 'evidence' => [
[ [
'kind' => 'feature_livewire_test', 'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php', 'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.', 'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
], ],
[ [
'kind' => 'authorization_test', 'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php', 'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.', 'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
], ],
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
],
], ],
'followUpAction' => 'add_guard_only', 'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false, 'mustRemainBaselineExempt' => false,

View File

@ -4,6 +4,7 @@
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\ApplyResolvedLocale;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests; use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests; use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
@ -24,7 +25,12 @@
UseSystemSessionCookieForLivewireRequests::class, UseSystemSessionCookieForLivewireRequests::class,
]); ]);
$middleware->web(append: [
ApplyResolvedLocale::class,
]);
$middleware->alias([ $middleware->alias([
'apply-resolved-locale' => ApplyResolvedLocale::class,
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return [
'target' => [
'enabled' => (bool) env('SUPPORT_DESK_ENABLED', false),
'name' => env('SUPPORT_DESK_NAME', 'External support desk'),
'create_url' => env('SUPPORT_DESK_CREATE_URL'),
'api_token' => env('SUPPORT_DESK_API_TOKEN'),
'ticket_url_template' => env('SUPPORT_DESK_TICKET_URL_TEMPLATE'),
'timeout_seconds' => (int) env('SUPPORT_DESK_TIMEOUT_SECONDS', 5),
],
];

View File

@ -73,18 +73,6 @@ public function permissionPosture(): static
]); ]);
} }
/**
* State for legacy acknowledged findings.
*/
public function acknowledged(): static
{
return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => null,
]);
}
/** /**
* State for triaged findings. * State for triaged findings.
*/ */

View File

@ -51,6 +51,10 @@ public function definition(): array
], ],
'omissions' => [], 'omissions' => [],
], ],
'external_handoff_mode' => SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY,
'external_ticket_reference' => null,
'external_ticket_url' => null,
'external_handoff_failure_summary' => null,
]; ];
} }

View File

@ -0,0 +1,25 @@
<?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
{
Schema::table('users', function (Blueprint $table): void {
$table->string('preferred_locale', 8)
->nullable()
->after('last_workspace_id')
->index();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('preferred_locale');
});
}
};

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Models\SupportRequest;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->string('external_handoff_mode')
->default(SupportRequest::EXTERNAL_HANDOFF_MODE_INTERNAL_ONLY)
->after('context_envelope');
$table->string('external_ticket_reference')->nullable()->after('external_handoff_mode');
$table->text('external_ticket_url')->nullable()->after('external_ticket_reference');
$table->text('external_handoff_failure_summary')->nullable()->after('external_ticket_url');
});
}
public function down(): void
{
Schema::table('support_requests', function (Blueprint $table): void {
$table->dropColumn([
'external_handoff_mode',
'external_ticket_reference',
'external_ticket_url',
'external_handoff_failure_summary',
]);
});
}
};

View File

@ -41,7 +41,6 @@ public function run(): void
PlatformCapabilities::OPS_VIEW, PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW, PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN, PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
PlatformCapabilities::OPS_CONTROLS_MANAGE, PlatformCapabilities::OPS_CONTROLS_MANAGE,
], ],
'is_active' => true, 'is_active' => true,

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
return [
'duplicate_warning_title' => 'Warnung',
'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.',
'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.',
'stat_assigned_baseline' => 'Zugewiesene Baseline',
'stat_total_findings' => 'Findings gesamt',
'stat_last_compared' => 'Zuletzt verglichen',
'stat_last_compared_never' => 'Nie',
'stat_error' => 'Fehler',
'badge_snapshot' => 'Snapshot #:id',
'badge_coverage_ok' => 'Abdeckung: OK',
'badge_coverage_warnings' => 'Abdeckung: Warnungen',
'badge_fidelity' => 'Fidelity: :level',
'badge_evidence_gaps' => 'Evidence Gaps: :count',
'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary',
'evidence_gap_details_heading' => 'Evidence-Gap-Details',
'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, nächster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.',
'evidence_gap_search_label' => 'Gap-Details suchen',
'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen',
'evidence_gap_search_help' => 'Filtert über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.',
'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.',
'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen Sie, ob der Inventory Sync aktuell ist.',
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Prüfen Sie, ob eine Policy erstellt werden sollte.',
'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.',
'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen Sie die betroffenen Zeilen.',
'evidence_gap_reason' => 'Grund',
'evidence_gap_reason_affected' => ':count betroffen',
'evidence_gap_reason_recorded' => ':count aufgezeichnet',
'evidence_gap_reason_missing_detail' => ':count ohne Detail',
'evidence_gap_structural' => 'Strukturell: :count',
'evidence_gap_operational' => 'Operativ: :count',
'evidence_gap_transient' => 'Temporär: :count',
'evidence_gap_bucket_structural' => ':count strukturell',
'evidence_gap_bucket_operational' => ':count operativ',
'evidence_gap_bucket_transient' => ':count temporär',
'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet',
'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.',
'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, aber Detailzeilen wurden nicht aufgezeichnet.',
'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt',
'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.',
'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence',
'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.',
'evidence_gap_policy_type' => 'Governed Subject',
'evidence_gap_subject_class' => 'Subjektklasse',
'evidence_gap_outcome' => 'Ergebnis',
'evidence_gap_next_action' => 'Nächste Aktion',
'evidence_gap_subject_key' => 'Subject Key',
'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht',
'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu prüfen.',
'comparing_indicator' => 'Vergleich läuft...',
'no_findings_all_clear' => 'Kein bestätigter Drift im letzten Vergleich',
'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich',
'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps müssen geprüft werden',
'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar',
'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen',
'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgründen unterdrückt.',
'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.',
'coverage_uncovered_label' => 'Nicht abgedeckt: :list',
'failed_title' => 'Vergleich fehlgeschlagen',
'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen Sie die Run-Details oder wiederholen Sie ihn.',
'critical_drift_title' => 'Kritischer Drift erkannt',
'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.',
'empty_no_tenant' => 'Kein Tenant ausgewählt',
'empty_no_assignment' => 'Keine Baseline zugewiesen',
'empty_no_snapshot' => 'Kein Snapshot verfügbar',
'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.',
'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen',
'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'rbac_summary_compared' => 'Verglichen',
'rbac_summary_unchanged' => 'Unverändert',
'rbac_summary_modified' => 'Geändert',
'rbac_summary_missing' => 'Fehlend',
'rbac_summary_unexpected' => 'Unerwartet',
'no_drift_title' => 'Kein Drift erkannt',
'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.',
'coverage_warnings_title' => 'Coverage-Warnungen',
'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.',
'idle_title' => 'Bereit zum Vergleich',
'button_view_run' => 'Run anzeigen',
'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen',
'button_view_findings' => 'Alle Findings anzeigen',
'button_review_last_run' => 'Letzten Run prüfen',
];

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
return [
'drift' => [
'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift',
],
'subject_types' => [
'policy' => 'Policy',
'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition',
],
'rbac' => [
'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift',
'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstützt.',
'metadata_only' => 'Nur Metadaten geändert',
'permission_change' => 'Berechtigung geändert',
'missing' => 'Im aktuellen Tenant fehlend',
'unexpected' => 'Unerwartet im aktuellen Tenant',
'changed_fields' => 'Geänderte Felder',
'baseline' => 'Baseline',
'current' => 'Aktuell',
'absent' => 'Nicht vorhanden',
'role_source' => 'Rollenquelle',
'permission_blocks' => 'Berechtigungsblöcke',
'built_in' => 'Integriert',
'custom' => 'Benutzerdefiniert',
'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstützt.',
],
];

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'Englisch',
'de' => 'Deutsch',
],
'source' => [
'explicit_override' => 'Sitzungsüberschreibung',
'user_preference' => 'persönliche Einstellung',
'workspace_default' => 'Workspace-Standard',
'workspace_override' => 'Workspace-Überschreibung',
'system_default' => 'Systemstandard',
],
'shell' => [
'language' => 'Sprache',
'current_language' => 'Aktuelle Sprache',
'language_source' => 'Quelle: :source',
'temporary_override' => 'Temporäre Überschreibung',
'switch_language' => 'Sprache wechseln',
'clear_override' => 'Geerbte Sprache verwenden',
'personal_preference' => 'Persönliche Einstellung',
'save_preference' => 'Einstellung speichern',
'inherit_workspace' => 'Workspace-Standard verwenden',
'workspace' => 'Workspace',
'choose_workspace' => 'Workspace auswählen',
'switch_workspace' => 'Workspace wechseln',
'workspace_home' => 'Workspace-Start',
'tenant_scope' => 'Tenant-Kontext',
'select_tenant' => 'Tenant auswählen',
'selected_tenant' => 'Ausgewählter Tenant',
'no_tenant_selected' => 'Kein Tenant ausgewählt',
'switch_tenant' => 'Tenant wechseln',
'clear_tenant_scope' => 'Tenant-Kontext löschen',
'context_unavailable' => 'Kontext nicht verfügbar',
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.',
'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.',
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.',
'view_managed_tenants' => 'Managed Tenants anzeigen',
'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
'search_tenants' => 'Tenants suchen...',
'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.',
],
'workspace' => [
'title' => 'Workspace-Einstellungen',
'save' => 'Speichern',
'reset' => 'Zurücksetzen',
'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.',
'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.',
'last_modified_by' => ':description - Zuletzt geändert von :user, :time.',
'section' => 'Lokalisierung',
'section_description' => 'Workspace-Standard für Benutzer ohne persönliche Spracheinstellung.',
'default_locale_label' => 'Standardsprache',
'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)',
'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).',
'default_locale_helper_set' => 'Effektive Sprache: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.',
'sign_in_microsoft' => 'Mit Microsoft anmelden',
'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Einstellungen',
'integrations' => 'Integrationen',
'manage_workspaces' => 'Workspaces verwalten',
'operations' => 'Operationen',
'audit_log' => 'Audit-Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant-Dashboard',
'system_title' => 'System-Dashboard',
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
'support_request_run_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus dem aktuellen Lauf hinzu.',
'submit_request' => 'Supportanfrage senden',
'primary_context' => 'Primärer Kontext',
'included_context' => 'Enthaltener Kontext',
'latest_external_handoff' => 'Letzte externe Übergabe',
'latest_external_handoff_none' => 'Für diesen Kontext wurde noch keine Supportanfrage gesendet.',
'latest_external_handoff_internal_only' => 'Die letzte Supportreferenz :reference ist nur in TenantPilot erfasst. Es ist noch kein externes Ticket verknüpft.',
'latest_external_handoff_linked' => 'Die letzte Supportreferenz :reference ist mit externem Ticket :external verknüpft.',
'latest_external_handoff_failed' => 'Die letzte Supportreferenz :reference hat kein externes Ticket, weil die Übergabe fehlgeschlagen ist: :failure',
'external_handoff_mode' => 'Externe Übergabe',
'handoff_mode_internal_only' => 'Nur TenantPilot',
'handoff_mode_create_external_ticket' => 'Externes Ticket erstellen',
'handoff_mode_link_existing_ticket' => 'Bestehendes Ticket verknüpfen',
'external_handoff_mode_helper_available' => 'Wählen Sie, ob diese Anfrage intern bleibt, ein externes Ticket erstellt oder ein bestehendes Ticket verknüpft.',
'external_handoff_mode_helper_unavailable' => 'Es ist kein externes Support-Desk-Ziel konfiguriert. Diese Anfrage bleibt intern.',
'handoff_mutation_scope' => 'Änderungsumfang',
'mutation_scope_internal_only' => 'Nur TenantPilot. Es wird kein externes Support-Desk-Ticket erstellt oder verknüpft.',
'mutation_scope_external_create' => 'TenantPilot + externes Support Desk. TenantPilot erstellt zuerst die interne Supportanfrage und danach genau ein externes Ticket.',
'mutation_scope_external_link' => 'TenantPilot + externes Support Desk. TenantPilot speichert die angegebene externe Ticketreferenz und erstellt kein Duplikat.',
'external_ticket_reference' => 'Externe Ticketreferenz',
'external_ticket_reference_helper' => 'Verwenden Sie den bestehenden Desk-Ticketschlüssel, zum Beispiel PSA-12345.',
'external_ticket_url' => 'Externe Ticket-URL',
'external_ticket_url_helper' => 'Optionaler HTTP- oder HTTPS-Link zum bestehenden externen Ticket.',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'support_request_submitted_internal_only' => 'Supportreferenz :reference wurde erstellt. Es ist noch kein externes Ticket verknüpft.',
'support_request_submitted_created' => 'Supportreferenz :reference wurde erstellt und externes Ticket :external wurde angelegt.',
'support_request_submitted_linked' => 'Supportreferenz :reference wurde erstellt und mit externem Ticket :external verknüpft.',
'support_request_submitted_failed' => 'Supportreferenz :reference wurde erstellt, aber die externe Übergabe ist fehlgeschlagen: :failure',
'open_external_ticket' => 'Externes Ticket öffnen',
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
'close' => 'Schließen',
'time_window' => 'Zeitfenster',
'window' => 'Fenster',
'enter_break_glass' => 'Break-Glass-Modus aktivieren',
'exit_break_glass' => 'Break-Glass beenden',
'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert',
'recovery_mode_ended' => 'Wiederherstellungsmodus beendet',
],
'review' => [
'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.',
'reviews' => 'Reviews',
'clear_filters' => 'Filter löschen',
'tenant' => 'Tenant',
'latest_review' => 'Letztes Review',
'key_findings' => 'Wichtige Findings',
'accepted_risks' => 'Akzeptierte Risiken',
'published' => 'Veröffentlicht',
'review_pack' => 'Review-Pack',
'open_latest_review' => 'Letztes Review öffnen',
'download_review_pack' => 'Review-Pack herunterladen',
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
'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',
'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar',
'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.',
'findings_count_summary' => ':count Findings im veröffentlichten Review zusammengefasst.',
'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.',
'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.',
'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.',
'unavailable' => 'Nicht verfügbar',
'available' => 'Verfügbar',
'outcome_summary' => 'Ergebniszusammenfassung',
'review' => 'Review',
'review_date' => 'Review-Datum',
'completeness' => 'Vollständigkeit',
'evidence_snapshot' => 'Evidence-Snapshot',
'current_export' => 'Aktueller Export',
'executive_posture' => 'Executive-Status',
'sections' => 'Abschnitte',
'details' => 'Details',
'export_executive_pack' => 'Executive-Pack exportieren',
'outcome' => 'Ergebnis',
'export' => 'Export',
'next_step' => 'Nächster Schritt',
'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',
'create_review' => 'Review erstellen',
'evidence_basis' => 'Evidence-Basis',
'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.',
'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.',
'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.',
'unable_create_review' => 'Review kann nicht erstellt werden',
'review_already_available' => 'Review bereits verfügbar',
'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.',
'view_review' => 'Review anzeigen',
'open_operation' => 'Operation öffnen',
'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.',
'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.',
'export_already_queued_body' => 'Ein Executive-Pack-Export ist für dieses Review bereits eingereiht oder läuft.',
'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar',
'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden',
'executive_pack_already_available' => 'Executive-Pack bereits verfügbar',
'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.',
'view_pack' => 'Pack anzeigen',
'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.',
'review_explanation' => 'Review-Erklärung',
'reason_owner' => 'Reason Owner',
'platform_core' => 'Platform Core',
'platform_reason_family' => 'Platform-Reason-Familie',
'compatibility' => 'Kompatibilität',
'highlights' => 'Highlights',
'next_actions' => 'Nächste Aktionen',
'related_context' => 'Verwandter Kontext',
'publication_readiness' => 'Veröffentlichungsreife',
'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.',
'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.',
'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.',
'key_entries' => 'Wichtige Einträge',
'entry' => 'Eintrag',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnosen',
'result_meaning' => 'Ergebnisbedeutung',
'result_trust' => 'Ergebnisvertrauen',
'artifact_truth' => 'Artifact Truth',
'no_action_needed' => 'Keine Aktion erforderlich',
'count' => 'Anzahl',
'guidance' => 'Orientierung',
'findings' => 'Findings',
'reports' => 'Berichte',
'operations' => 'Operationen',
'pending_verification' => 'Verifizierung ausstehend',
'verified_cleared' => 'Verifiziert bereinigt',
'terminal_outcomes' => 'Terminale Ergebnisse',
'pending' => 'Ausstehend',
'operation' => 'Operation',
'operation_description' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.',
'executive_pack' => 'Executive-Pack',
'view_executive_pack' => 'Executive-Pack anzeigen',
'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.',
'customer_workspace' => 'Kunden-Workspace',
'open_customer_workspace' => 'Kunden-Workspace öffnen',
'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.',
'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen',
'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.',
],
'findings' => [
'all' => 'Alle',
'needs_action' => 'Handlungsbedarf',
'overdue' => 'Überfällig',
'risk_accepted' => 'Risiko akzeptiert',
'resolved' => 'Gelöst',
'actions' => 'Aktionen',
'open_approval_queue' => 'Freigabewarteschlange öffnen',
],
'notifications' => [
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
'user_preference_saved' => 'Spracheinstellung gespeichert.',
'user_preference_cleared' => 'Spracheinstellung gelöscht.',
'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert',
'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern',
'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt',
'setting_already_default' => 'Einstellung verwendet bereits den Standard',
],
'validation' => [
'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.',
],
];

View File

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'English',
'de' => 'German',
],
'source' => [
'explicit_override' => 'session override',
'user_preference' => 'personal preference',
'workspace_default' => 'workspace default',
'workspace_override' => 'workspace override',
'system_default' => 'system default',
],
'shell' => [
'language' => 'Language',
'current_language' => 'Current language',
'language_source' => 'Source: :source',
'temporary_override' => 'Temporary override',
'switch_language' => 'Switch language',
'clear_override' => 'Use inherited language',
'personal_preference' => 'Personal preference',
'save_preference' => 'Save preference',
'inherit_workspace' => 'Use workspace default',
'workspace' => 'Workspace',
'choose_workspace' => 'Choose workspace',
'switch_workspace' => 'Switch workspace',
'workspace_home' => 'Workspace Home',
'tenant_scope' => 'Tenant scope',
'select_tenant' => 'Select tenant',
'selected_tenant' => 'Selected tenant',
'no_tenant_selected' => 'No tenant selected',
'switch_tenant' => 'Switch tenant',
'clear_tenant_scope' => 'Clear tenant scope',
'context_unavailable' => 'Context unavailable',
'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.',
'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.',
'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.',
'view_managed_tenants' => 'View managed tenants',
'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.',
'search_tenants' => 'Search tenants...',
'choose_workspace_first' => 'Choose a workspace first.',
],
'workspace' => [
'title' => 'Workspace settings',
'save' => 'Save',
'reset' => 'Reset',
'no_manage_permission' => 'You do not have permission to manage workspace settings.',
'no_workspace_override' => 'No workspace override to reset.',
'last_modified_by' => ':description - Last modified by :user, :time.',
'section' => 'Localization settings',
'section_description' => 'Workspace default used by users without a personal language preference.',
'default_locale_label' => 'Default language',
'default_locale_placeholder' => 'Unset (uses system default)',
'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).',
'default_locale_helper_set' => 'Effective language: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft sign-in is not configured.',
'sign_in_microsoft' => 'Sign in with Microsoft',
'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Settings',
'integrations' => 'Integrations',
'manage_workspaces' => 'Manage workspaces',
'operations' => 'Operations',
'audit_log' => 'Audit Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant dashboard',
'system_title' => 'System dashboard',
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'support_request_run_description' => 'Share a concise summary and TenantAtlas will attach redacted context from the current run.',
'submit_request' => 'Submit support request',
'primary_context' => 'Primary context',
'included_context' => 'Included context',
'latest_external_handoff' => 'Latest external handoff',
'latest_external_handoff_none' => 'No support request has been submitted for this context yet.',
'latest_external_handoff_internal_only' => 'Latest support reference :reference is TenantPilot only. No external ticket is linked yet.',
'latest_external_handoff_linked' => 'Latest support reference :reference is linked to external ticket :external.',
'latest_external_handoff_failed' => 'Latest support reference :reference has no external ticket because handoff failed: :failure',
'external_handoff_mode' => 'External handoff',
'handoff_mode_internal_only' => 'TenantPilot only',
'handoff_mode_create_external_ticket' => 'Create external ticket',
'handoff_mode_link_existing_ticket' => 'Link existing ticket',
'external_handoff_mode_helper_available' => 'Choose whether this request stays internal, creates an external ticket, or links an existing one.',
'external_handoff_mode_helper_unavailable' => 'No external support desk target is configured. This request will stay internal.',
'handoff_mutation_scope' => 'Mutation scope',
'mutation_scope_internal_only' => 'TenantPilot only. No external support desk ticket will be created or linked.',
'mutation_scope_external_create' => 'TenantPilot + external support desk. TenantPilot creates the internal support request first, then creates one external ticket.',
'mutation_scope_external_link' => 'TenantPilot + external support desk. TenantPilot stores the external ticket reference you provide and does not create a duplicate ticket.',
'external_ticket_reference' => 'External ticket reference',
'external_ticket_reference_helper' => 'Use the existing desk ticket key, for example PSA-12345.',
'external_ticket_url' => 'External ticket URL',
'external_ticket_url_helper' => 'Optional HTTP or HTTPS link to the existing external ticket.',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'support_request_submitted_internal_only' => 'Support reference :reference was created. No external ticket is linked yet.',
'support_request_submitted_created' => 'Support reference :reference was created and external ticket :external was created.',
'support_request_submitted_linked' => 'Support reference :reference was created and linked to external ticket :external.',
'support_request_submitted_failed' => 'Support reference :reference was created, but external handoff failed: :failure',
'open_external_ticket' => 'Open external ticket',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
'close' => 'Close',
'time_window' => 'Time window',
'window' => 'Window',
'enter_break_glass' => 'Enter break-glass mode',
'exit_break_glass' => 'Exit break-glass',
'recovery_mode_enabled' => 'Recovery mode enabled',
'recovery_mode_ended' => 'Recovery mode ended',
],
'review' => [
'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.',
'reviews' => 'Reviews',
'clear_filters' => 'Clear filters',
'tenant' => 'Tenant',
'latest_review' => 'Latest review',
'key_findings' => 'Key findings',
'accepted_risks' => 'Accepted risks',
'published' => 'Published',
'review_pack' => 'Review pack',
'open_latest_review' => 'Open latest review',
'download_review_pack' => 'Download review pack',
'no_entitled_tenants' => 'No entitled tenants match this view',
'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',
'no_published_review_available' => 'No published review available yet',
'no_findings_recorded' => 'No findings recorded in the published review.',
'findings_count_summary' => ':count findings summarized in the published review.',
'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.',
'no_accepted_risks_recorded' => 'No accepted risks recorded.',
'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.',
'unavailable' => 'Unavailable',
'available' => 'Available',
'outcome_summary' => 'Outcome summary',
'review' => 'Review',
'review_date' => 'Review date',
'completeness' => 'Completeness',
'evidence_snapshot' => 'Evidence snapshot',
'current_export' => 'Current export',
'executive_posture' => 'Executive posture',
'sections' => 'Sections',
'details' => 'Details',
'export_executive_pack' => 'Export executive pack',
'outcome' => 'Outcome',
'export' => 'Export',
'next_step' => 'Next step',
'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',
'create_review' => 'Create review',
'evidence_basis' => 'Evidence basis',
'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.',
'unable_create_missing_context' => 'Unable to create review - missing context.',
'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.',
'unable_create_review' => 'Unable to create review',
'review_already_available' => 'Review already available',
'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.',
'view_review' => 'View review',
'open_operation' => 'Open operation',
'review_composing_background' => 'The review is being composed in the background.',
'unable_export_missing_context' => 'Unable to export review - missing context.',
'export_already_queued_body' => 'An executive pack export is already queued or running for this review.',
'executive_pack_export_unavailable' => 'Executive pack export unavailable',
'unable_export_executive_pack' => 'Unable to export executive pack',
'executive_pack_already_available' => 'Executive pack already available',
'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.',
'view_pack' => 'View pack',
'executive_pack_generating_background' => 'The executive pack is being generated in the background.',
'review_explanation' => 'Review explanation',
'reason_owner' => 'Reason owner',
'platform_core' => 'Platform core',
'platform_reason_family' => 'Platform reason family',
'compatibility' => 'Compatibility',
'highlights' => 'Highlights',
'next_actions' => 'Next actions',
'related_context' => 'Related context',
'publication_readiness' => 'Publication readiness',
'ready_for_publication' => 'This review is ready for publication and executive-pack export.',
'internal_only' => 'This review is currently safe for internal use only.',
'needs_follow_up' => 'This review still needs follow-up before publication.',
'key_entries' => 'Key entries',
'entry' => 'Entry',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnostics',
'result_meaning' => 'Result meaning',
'result_trust' => 'Result trust',
'artifact_truth' => 'Artifact truth',
'no_action_needed' => 'No action needed',
'count' => 'Count',
'guidance' => 'Guidance',
'findings' => 'Findings',
'reports' => 'Reports',
'operations' => 'Operations',
'pending_verification' => 'Pending verification',
'verified_cleared' => 'Verified cleared',
'terminal_outcomes' => 'Terminal outcomes',
'pending' => 'Pending',
'operation' => 'Operation',
'operation_description' => 'Inspect the latest review composition or refresh run.',
'executive_pack' => 'Executive pack',
'view_executive_pack' => 'View executive pack',
'executive_pack_description' => 'Open the current export that belongs to this review.',
'customer_workspace' => 'Customer workspace',
'open_customer_workspace' => 'Open customer workspace',
'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
'view_evidence_snapshot' => 'View evidence snapshot',
'evidence_snapshot_description' => 'Return to the evidence basis behind this review.',
],
'findings' => [
'all' => 'All',
'needs_action' => 'Needs action',
'overdue' => 'Overdue',
'risk_accepted' => 'Risk accepted',
'resolved' => 'Resolved',
'actions' => 'Actions',
'open_approval_queue' => 'Open approval queue',
],
'notifications' => [
'locale_override_saved' => 'Language override applied.',
'locale_override_cleared' => 'Language override cleared.',
'user_preference_saved' => 'Language preference saved.',
'user_preference_cleared' => 'Language preference cleared.',
'workspace_settings_saved' => 'Workspace settings saved',
'workspace_settings_unchanged' => 'No settings changes to save',
'workspace_setting_reset' => 'Workspace setting reset to default',
'setting_already_default' => 'Setting already uses default',
],
'validation' => [
'unsupported_locale' => 'Choose a supported language.',
],
];

View File

@ -1,7 +1,10 @@
@php @php
use App\Support\Verification\VerificationLinkBehavior;
$help = is_array($help ?? null) ? $help : []; $help = is_array($help ?? null) ? $help : [];
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : []; $links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : []; $steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
$linkBehavior = app(VerificationLinkBehavior::class);
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== '' $headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
? (string) ($help['headline']) ? (string) ($help['headline'])
: 'Contextual help'; : 'Contextual help';
@ -57,9 +60,16 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@foreach ($links as $link) @foreach ($links as $link)
@php @php
$linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== ''
? (string) $link['label']
: 'Open';
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== '' $linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url'] ? (string) $link['url']
: null; : null;
$behavior = $linkUrl !== null
? $linkBehavior->describe($linkLabel, $linkUrl)
: null;
$testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel);
@endphp @endphp
@if ($linkUrl) @if ($linkUrl)
@ -68,8 +78,11 @@
:href="$linkUrl" :href="$linkUrl"
size="sm" size="sm"
color="primary" color="primary"
:target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null"
:rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null"
:data-testid="$testId"
> >
{{ (string) ($link['label'] ?? 'Open') }} {{ $linkLabel }}
</x-filament::button> </x-filament::button>
@endif @endif
@endforeach @endforeach

View File

@ -37,7 +37,7 @@
$compressedOutcome['primaryLabel'] ?? null, $compressedOutcome['primaryLabel'] ?? null,
$state['primaryLabel'] ?? null, $state['primaryLabel'] ?? null,
$operatorExplanation['headline'] ?? null, $operatorExplanation['headline'] ?? null,
'Artifact truth', __('localization.review.artifact_truth'),
]); ]);
$primaryReason = $firstArtifactTruthText([ $primaryReason = $firstArtifactTruthText([
$compressedOutcome['primaryReason'] ?? null, $compressedOutcome['primaryReason'] ?? null,
@ -49,7 +49,7 @@
$compressedOutcome['nextActionText'] ?? null, $compressedOutcome['nextActionText'] ?? null,
data_get($operatorExplanation, 'nextAction.text'), data_get($operatorExplanation, 'nextAction.text'),
$state['nextActionLabel'] ?? null, $state['nextActionLabel'] ?? null,
'No action needed', __('localization.review.no_action_needed'),
]); ]);
$diagnosticsSummary = $firstArtifactTruthText([ $diagnosticsSummary = $firstArtifactTruthText([
$compressedOutcome['diagnosticsSummary'] ?? null, $compressedOutcome['diagnosticsSummary'] ?? null,
@ -81,7 +81,7 @@
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') { if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => 'Result meaning', 'label' => __('localization.review.result_meaning'),
'value' => $evaluationSpec->label, 'value' => $evaluationSpec->label,
'badge' => BadgeCatalog::summaryData($evaluationSpec), 'badge' => BadgeCatalog::summaryData($evaluationSpec),
]); ]);
@ -89,7 +89,7 @@
if ($trustSpec && $trustSpec->label !== 'Unknown') { if ($trustSpec && $trustSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => 'Result trust', 'label' => __('localization.review.result_trust'),
'value' => $trustSpec->label, 'value' => $trustSpec->label,
'badge' => BadgeCatalog::summaryData($trustSpec), 'badge' => BadgeCatalog::summaryData($trustSpec),
]); ]);
@ -133,7 +133,7 @@
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400"> <div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
Diagnostics {{ __('localization.review.diagnostics') }}
</div> </div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@ -164,7 +164,7 @@
<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="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"> <div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? 'Count' }} {{ $count['label'] ?? __('localization.review.count') }}
</div> </div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }} {{ (int) ($count['value'] ?? 0) }}
@ -211,7 +211,7 @@
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<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="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_step') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $nextActionText }} {{ $nextActionText }}
</dd> </dd>
@ -237,7 +237,7 @@
@if ($nextSteps !== []) @if ($nextSteps !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.guidance') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextSteps as $step) @foreach ($nextSteps as $step)
@continue(! is_string($step) || trim($step) === '') @continue(! is_string($step) || trim($step) === '')

View File

@ -42,14 +42,14 @@
@if ($entries !== []) @if ($entries !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_entries') }}</div>
<div class="space-y-2"> <div class="space-y-2">
@foreach ($entries as $entry) @foreach ($entries as $entry)
@continue(! is_array($entry)) @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="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"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }} {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }}
</div> </div>
@php @php
@ -82,7 +82,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.follow_up') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')

View File

@ -25,7 +25,7 @@
@if ($operatorExplanation !== []) @if ($operatorExplanation !== [])
<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="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-sm font-semibold text-gray-950 dark:text-white"> <div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $operatorExplanation['headline'] ?? 'Review explanation' }} {{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }}
</div> </div>
@if (filled($operatorExplanation['reliabilityStatement'] ?? null)) @if (filled($operatorExplanation['reliabilityStatement'] ?? null))
@ -45,13 +45,13 @@
@if ($reasonSemantics !== []) @if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2"> <dl 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="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.reason_owner') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}</dd>
</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="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.platform_reason_family') }}</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}</dd>
</div> </div>
</dl> </dl>
@endif @endif
@ -74,7 +74,7 @@
@if ($highlights !== []) @if ($highlights !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($highlights as $highlight) @foreach ($highlights as $highlight)
@continue(! is_string($highlight) || trim($highlight) === '') @continue(! is_string($highlight) || trim($highlight) === '')
@ -87,7 +87,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_actions') }}</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')
@ -100,7 +100,7 @@
@if ($contextLinks !== []) @if ($contextLinks !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div>
<div class="grid gap-3 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-3">
@foreach ($contextLinks as $link) @foreach ($contextLinks as $link)
@php @php
@ -130,11 +130,11 @@
@endif @endif
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div>
@if ($publishBlockers === [] && $decisionDirection === 'publishable') @if ($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"> <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">
This review is ready for publication and executive-pack export. {{ __('localization.review.ready_for_publication') }}
</div> </div>
@elseif ($publishBlockers !== []) @elseif ($publishBlockers !== [])
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200"> <ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@ -146,7 +146,7 @@
</ul> </ul>
@elseif ($decisionDirection === 'internal_only') @elseif ($decisionDirection === 'internal_only')
<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"> <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">
<div>This review is currently safe for internal use only.</div> <div>{{ __('localization.review.internal_only') }}</div>
@if ($publicationNextAction !== null) @if ($publicationNextAction !== null)
<div class="mt-1">{{ $publicationNextAction }}</div> <div class="mt-1">{{ $publicationNextAction }}</div>
@ -154,7 +154,7 @@
</div> </div>
@else @else
<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"> <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">
{{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }} {{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }}
</div> </div>
@endif @endif
</div> </div>

View File

@ -14,7 +14,7 @@
@if (! $isConfigured) @if (! $isConfigured)
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
Microsoft sign-in is not configured. {{ __('localization.auth.microsoft_not_configured') }}
</div> </div>
@endif @endif
@ -25,11 +25,11 @@
:disabled="! $isConfigured" :disabled="! $isConfigured"
color="primary" color="primary"
> >
Sign in with Microsoft {{ __('localization.auth.sign_in_microsoft') }}
</x-filament::button> </x-filament::button>
<div class="text-center text-sm text-gray-500 dark:text-gray-400"> <div class="text-center text-sm text-gray-500 dark:text-gray-400">
Tenant Admin access requires a tenant membership. {{ __('localization.auth.tenant_admin_membership_required') }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,208 @@
<x-filament-panels::page>
@php
$preview = is_array($preview ?? null) ? $preview : null;
$preflight = is_array($preflight ?? null) ? $preflight : null;
$previewSummary = is_array($preview['summary'] ?? null) ? $preview['summary'] : [];
$preflightSummary = is_array($preflight['summary'] ?? null) ? $preflight['summary'] : [];
$blockedReasonCounts = is_array($preflight['blockedReasonCounts'] ?? null) ? $preflight['blockedReasonCounts'] : [];
$sourceTenantName = data_get($preview, 'selection.sourceTenantName');
$targetTenantName = data_get($preview, 'selection.targetTenantName');
$selectedPolicyTypes = data_get($preview, 'selection.policyTypes', []);
@endphp
<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.
</x-slot>
<div class="space-y-4">
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<form wire:submit.prevent="applySelection" class="space-y-4">
{{ $this->form }}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Shareable compare scope</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Source tenant, target tenant, and governed-subject filters live on the URL so the same compare preview can be reopened or shared.
</p>
</div>
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::button type="submit" color="primary" size="sm" wire:loading.attr="disabled" wire:target="applySelection,generatePromotionPreflight">
Run compare preview
</x-filament::button>
@if ($this->hasActiveSelection())
<x-filament::button tag="a" :href="$this->clearSelectionUrl()" color="gray" size="sm">
Clear selection
</x-filament::button>
@endif
</div>
</div>
</form>
</div>
@if (filled($selectionMessage))
<div class="rounded-2xl border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-900 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-100">
{{ $selectionMessage }}
</div>
@endif
@if ($preview === null)
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/70 px-5 py-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
Choose a source tenant and a target tenant to build a compare preview. The source and target must be different tenants inside the active workspace.
</div>
@endif
</div>
</x-filament::section>
@if ($preview !== null)
<x-filament::section heading="Compare preview">
<x-slot name="description">
Decision-first summary of governed subjects. Raw payloads stay on the existing tenant and baseline surfaces.
</x-slot>
<div class="space-y-4" data-testid="cross-tenant-compare-preview">
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" size="sm">Source tenant: {{ $sourceTenantName }}</x-filament::badge>
<x-filament::badge color="gray" size="sm">Target tenant: {{ $targetTenantName }}</x-filament::badge>
@foreach ($selectedPolicyTypes as $policyType)
<x-filament::badge color="gray" size="sm">{{ $this->stateLabel($policyType) }}</x-filament::badge>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
@if (filled($this->sourceTenantUrl()))
<a href="{{ $this->sourceTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Open source tenant
</a>
@endif
@if (filled($this->targetTenantUrl()))
<a href="{{ $this->targetTenantUrl() }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Open target tenant
</a>
@endif
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
The preview groups governed subjects into reproducible compare states so you can decide whether the target is aligned, missing, blocked, or needs manual review.
</p>
</div>
<dl class="grid gap-3 sm:grid-cols-3 xl:w-[28rem]">
@foreach (['match', 'different', 'missing', 'ambiguous', 'blocked', 'total'] as $state)
<div class="rounded-2xl border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $this->stateLabel($state) }}</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($previewSummary[$state] ?? 0) }}</dd>
</div>
@endforeach
</dl>
</div>
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:text-gray-400">
<div>Governed subject</div>
<div>Reasoning</div>
<div>Compare state</div>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach (data_get($preview, 'subjects', []) as $subject)
<div class="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,0.8fr)] gap-3 px-4 py-4" data-testid="cross-tenant-compare-subject">
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{{ $this->stateLabel((string) data_get($subject, 'policyType', 'unknown')) }}</span>
@if (filled(data_get($subject, 'subjectKey')))
<x-filament::badge color="gray" size="sm">{{ data_get($subject, 'subjectKey') }}</x-filament::badge>
@endif
</div>
</div>
<div class="flex flex-wrap gap-2">
@forelse (data_get($subject, 'reasonCodes', []) as $reasonCode)
<x-filament::badge color="gray" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}</x-filament::badge>
@empty
<span class="text-sm text-gray-500 dark:text-gray-400">No blocking reason.</span>
@endforelse
</div>
<div class="flex items-start justify-start xl:justify-end">
<x-filament::badge :color="$this->stateColor((string) data_get($subject, 'state', 'unknown'))" size="sm">
{{ $this->stateLabel((string) data_get($subject, 'state', 'unknown')) }}
</x-filament::badge>
</div>
</div>
@endforeach
</div>
</div>
</div>
</x-filament::section>
@endif
@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.
</x-slot>
<div class="space-y-4" data-testid="cross-tenant-preflight">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach (['ready', 'blocked', 'manual_mapping_required', 'total'] as $state)
<div class="rounded-2xl 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">{{ $this->stateLabel($state) }}</div>
<div class="mt-1 text-2xl font-semibold text-gray-950 dark:text-white">{{ (int) ($preflightSummary[$state] ?? 0) }}</div>
</div>
@endforeach
</div>
@if ($blockedReasonCounts !== [])
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/50">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Top blocked reasons</div>
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($blockedReasonCounts as $reasonCode => $count)
<x-filament::badge color="danger" size="sm">{{ $this->reasonLabel((string) $reasonCode) }}: {{ (int) $count }}</x-filament::badge>
@endforeach
</div>
</div>
@endif
<div class="grid gap-4 xl:grid-cols-3">
@foreach (['ready', 'manual_mapping_required', 'blocked'] as $bucket)
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ $this->stateLabel($bucket) }}</div>
<x-filament::badge :color="$this->stateColor($bucket)" size="sm">
{{ count(data_get($preflight, 'buckets.'.$bucket, [])) }}
</x-filament::badge>
</div>
<div class="mt-3 space-y-3">
@forelse (data_get($preflight, 'buckets.'.$bucket, []) as $subject)
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-3 py-3 dark:border-gray-800 dark:bg-gray-900/50">
<div class="text-sm font-semibold text-gray-950 dark:text-white">{{ data_get($subject, 'displayName') }}</div>
@if (data_get($subject, 'preflight.reasonLabels', []) !== [])
<div class="mt-2 flex flex-wrap gap-2">
@foreach (data_get($subject, 'preflight.reasonLabels', []) as $reasonLabel)
<x-filament::badge color="gray" size="sm">{{ $reasonLabel }}</x-filament::badge>
@endforeach
</div>
@endif
</div>
@empty
<div class="text-sm text-gray-500 dark:text-gray-400">No governed subjects in this bucket.</div>
@endforelse
</div>
</div>
@endforeach
</div>
</div>
</x-filament::section>
@endif
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@ -18,7 +18,7 @@
</h1> </h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300"> <p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state. This workspace decision surface routes you into the existing findings, finding exceptions, operations, alerts, and review surfaces without introducing a second workflow state.
</p> </p>
</div> </div>

View File

@ -2,15 +2,15 @@
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customer-safe review workspace {{ __('localization.review.customer_safe_review_workspace') }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context. {{ __('localization.review.customer_workspace_intro') }}
</div> </div>
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
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. {{ __('localization.review.customer_workspace_canonical_note') }}
</div> </div>
</div> </div>
</x-filament::section> </x-filament::section>

View File

@ -31,8 +31,8 @@
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? 'No tenant selected'; $tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected');
$workspaceLabel = $workspace?->name ?? 'Choose workspace'; $workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
$hasActiveTenant = $currentTenantName !== null; $hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace $managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -40,7 +40,8 @@
$workspaceUrl = $workspace $workspaceUrl = $workspace
? route('admin.home') ? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin'); : ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace'; $tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace');
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
@endphp @endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5"> <div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
@ -63,7 +64,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
<x-slot name="trigger"> <x-slot name="trigger">
<button <button
type="button" type="button"
aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}" aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10" class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
> >
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}"> <span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
@ -78,12 +79,12 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }"> <div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice()) @if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">Context unavailable</div> <div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div>
@if ($workspace) @if ($workspace)
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div> <div>{{ __('localization.shell.context_unavailable_workspace') }}</div>
@else @else
<div>Choose a workspace to continue with a valid admin context.</div> <div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div>
@endif @endif
</div> </div>
@endif @endif
@ -91,7 +92,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
{{-- Workspace section --}} {{-- Workspace section --}}
<div class="space-y-1"> <div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Workspace {{ __('localization.shell.workspace') }}
</div> </div>
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5"> <div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
@ -104,7 +105,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}" href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
Switch workspace {{ __('localization.shell.switch_workspace') }}
</a> </a>
</div> </div>
@ -113,7 +114,7 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-pri
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5" class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
> >
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" /> <x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
Workspace Home {{ __('localization.shell.workspace_home') }}
</a> </a>
</div> </div>
@ -124,7 +125,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Selected tenant {{ __('localization.shell.selected_tenant') }}
</div> </div>
</div> </div>
@ -137,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
href="{{ ChooseTenant::getUrl(panel: 'admin') }}" href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
Switch tenant {{ __('localization.shell.switch_tenant') }}
</a> </a>
</div> </div>
@ -146,7 +147,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -154,23 +155,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@else @else
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
<div>No active tenants are available for the standard operating context in this workspace.</div> <div>{{ __('localization.shell.no_active_tenants') }}</div>
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"> <a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" /> <x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
View managed tenants {{ __('localization.shell.view_managed_tenants') }}
</a> </a>
</div> </div>
@else @else
@if (! $hasActiveTenant) @if (! $hasActiveTenant)
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400"> <div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context. {{ __('localization.shell.workspace_wide_available') }}
</div> </div>
@endif @endif
<input <input
type="text" type="text"
class="fi-input fi-text-input w-full" class="fi-input fi-text-input w-full"
placeholder="Search tenants…" placeholder="{{ __('localization.shell.search_tenants') }}"
x-model="query" x-model="query"
/> />
@ -207,7 +208,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope {{ __('localization.shell.clear_tenant_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -216,10 +217,12 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
</div> </div>
@else @else
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
Choose a workspace first. {{ __('localization.shell.choose_workspace_first') }}
</div> </div>
@endif @endif
</div> </div>
</x-filament::dropdown.list> </x-filament::dropdown.list>
</x-filament::dropdown> </x-filament::dropdown>
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
</div> </div>

View File

@ -0,0 +1,110 @@
@php
use App\Models\User;
use App\Services\Localization\LocaleResolver;
$plane = $plane ?? 'admin';
$showPreference = (bool) ($showPreference ?? true);
$embedded = (bool) ($embedded ?? false);
/** @var LocaleResolver $localeResolver */
$localeResolver = app(LocaleResolver::class);
$localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
$localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane);
$localeOptions = LocaleResolver::localeOptions();
$currentLocale = (string) ($localeContext['locale'] ?? 'en');
$source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT);
$sourceLabel = __('localization.source.'.$source);
$user = auth()->user();
$preferredLocale = $user instanceof User ? $user->preferred_locale : null;
@endphp
<div class="{{ $embedded ? 'border-l border-gray-200 dark:border-white/10' : 'inline-flex rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5' }}">
<x-filament::dropdown placement="bottom-end" teleport width="sm">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ __('localization.shell.language') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 text-sm transition hover:bg-gray-50 dark:hover:bg-white/10"
>
<x-filament::icon icon="heroicon-o-language" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-200">{{ strtoupper($currentLocale) }}</span>
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.current_language') }}
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.shell.language_source', ['source' => $sourceLabel]) }}
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.override.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-override-{{ $plane }}">
{{ __('localization.shell.temporary_override') }}
</label>
<x-filament::input.wrapper class="w-full">
<x-filament::input.select
id="tenantpilot-locale-override-{{ $plane }}"
name="locale"
>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($currentLocale === $locale)>{{ $label }}</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
<button type="submit" class="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-primary-500">
{{ __('localization.shell.switch_language') }}
</button>
</form>
@if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE)
<form method="POST" action="{{ route('localization.override.clear') }}">
@csrf
@method('DELETE')
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_override') }}
</button>
</form>
@endif
@if ($showPreference && $user instanceof User)
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.preference.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-preference-{{ $plane }}">
{{ __('localization.shell.personal_preference') }}
</label>
<x-filament::input.wrapper class="w-full">
<x-filament::input.select
id="tenantpilot-locale-preference-{{ $plane }}"
name="preferred_locale"
>
<option value="" @selected($preferredLocale === null)>{{ __('localization.shell.inherit_workspace') }}</option>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($preferredLocale === $locale)>{{ $label }}</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-sm font-medium text-primary-600 transition hover:bg-primary-50 hover:text-primary-500 dark:text-primary-400 dark:hover:bg-primary-500/10 dark:hover:text-primary-300">
{{ __('localization.shell.save_preference') }}
</button>
</form>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
</div>

View File

@ -1,13 +1,3 @@
@php
$findingsLastRun = $this->findingsLastRun();
$findingsLastRunStatusSpec = $findingsLastRun
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunStatus, (string) $findingsLastRun->status)
: null;
$findingsLastRunOutcomeSpec = $findingsLastRun && (string) $findingsLastRun->status === 'completed'
? \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::OperationRunOutcome, (string) $findingsLastRun->outcome)
: null;
@endphp
<x-filament-panels::page> <x-filament-panels::page>
<div class="space-y-6"> <div class="space-y-6">
<x-filament::section> <x-filament::section>
@ -17,7 +7,7 @@
<div> <div>
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p> <p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Operator warning</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300"> <p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Runbooks can modify or assess customer data across tenants. Always run preflight first, and ensure you have the correct scope selected. Runbooks can modify or assess customer data across tenants. When supported runbooks are available, verify scope and confirmation requirements before execution.
</p> </p>
</div> </div>
</div> </div>
@ -25,100 +15,17 @@
<x-filament::section> <x-filament::section>
<x-slot name="heading"> <x-slot name="heading">
Rebuild Findings Lifecycle No supported runbooks
</x-slot> </x-slot>
<x-slot name="description"> <x-slot name="description">
Backfills legacy findings lifecycle fields, SLA due dates, and consolidates drift duplicate findings. Supported platform runbooks will appear here when they are part of current product truth.
</x-slot> </x-slot>
<x-slot name="afterHeader">
<x-filament::badge color="info" size="sm">
{{ $this->findingsScopeLabel() }}
</x-filament::badge>
</x-slot>
<div class="space-y-4">
@if ($findingsLastRun)
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last run</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ $findingsLastRun->created_at?->diffForHumans() ?? '—' }}
</span>
@if ($findingsLastRunStatusSpec)
<x-filament::badge
:color="$findingsLastRunStatusSpec->color"
:icon="$findingsLastRunStatusSpec->icon"
size="sm"
>
{{ $findingsLastRunStatusSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRunOutcomeSpec)
<x-filament::badge
:color="$findingsLastRunOutcomeSpec->color"
:icon="$findingsLastRunOutcomeSpec->icon"
size="sm"
>
{{ $findingsLastRunOutcomeSpec->label }}
</x-filament::badge>
@endif
@if ($findingsLastRun->initiator_name)
<span class="text-xs text-gray-500 dark:text-gray-400">
by {{ $findingsLastRun->initiator_name }}
</span>
@endif
</div>
@endif
@if (is_array($this->findingsPreflight))
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Affected</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['affected_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Total scanned</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ number_format((int) ($this->findingsPreflight['total_count'] ?? 0)) }}
</p>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Estimated tenants</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-gray-950 dark:text-white">
{{ is_numeric($this->findingsPreflight['estimated_tenants'] ?? null) ? number_format((int) $this->findingsPreflight['estimated_tenants']) : '—' }}
</p>
</div>
</x-filament::section>
</div>
@if ((int) ($this->findingsPreflight['affected_count'] ?? 0) <= 0)
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-check-circle class="h-5 w-5 text-success-500" /> <x-heroicon-m-check-circle class="h-5 w-5 text-success-500" />
Nothing to do for the current scope. There are no operator-run repair runbooks exposed on this surface.
</div>
@endif
@else
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<x-heroicon-m-magnifying-glass class="h-5 w-5" />
Run <span class="mx-1 font-semibold text-gray-700 dark:text-gray-200">Preflight</span> to see how many findings would change for the selected scope.
</div>
@endif
</div> </div>
</x-filament::section> </x-filament::section>
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController; use App\Http\Controllers\ClearTenantContextController;
use App\Http\Controllers\LocalizationController;
use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\OpenFindingExceptionsQueueController;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\ReviewPackDownloadController; use App\Http\Controllers\ReviewPackDownloadController;
@ -67,6 +68,21 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::middleware(['web'])->group(function (): void {
Route::get('/localization/context', [LocalizationController::class, 'context'])
->name('localization.context');
Route::post('/localization/override', [LocalizationController::class, 'updateOverride'])
->name('localization.override.update');
Route::delete('/localization/override', [LocalizationController::class, 'clearOverride'])
->name('localization.override.clear');
});
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference'])
->name('localization.preference.update');
$makeSmokeCookie = static fn () => cookie()->make( $makeSmokeCookie = static fn () => cookie()->make(
SuppressDebugbarForSmokeRequests::COOKIE_NAME, SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE, SuppressDebugbarForSmokeRequests::COOKIE_VALUE,

View File

@ -61,18 +61,6 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page $page
@ -87,8 +75,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access') ->assertSee('Verify access')
->assertSee('Status: Not started') ->assertSee('Status: Not started')
->click('Provider connection') ->click('Select an existing connection or create a new one.')
->assertScript($visibleSelectValue, (string) $connection->getKey()) ->assertSee('Edit selected connection')
->click('Create new connection') ->click('Create new connection')
->check('internal:label="Dedicated override"s') ->check('internal:label="Dedicated override"s')
->fill('[type="password"]', 'browser-only-secret') ->fill('[type="password"]', 'browser-only-secret')
@ -97,8 +85,8 @@
->waitForText('Status: Not started') ->waitForText('Status: Not started')
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access') ->assertSee('Verify access')
->click('Provider connection') ->click('Select an existing connection or create a new one.')
->assertScript($visibleSelectValue, (string) $connection->getKey()) ->assertSee('Edit selected connection')
->click('Create new connection') ->click('Create new connection')
->check('internal:label="Dedicated override"s') ->check('internal:label="Dedicated override"s')
->assertValue('[type="password"]', ''); ->assertValue('[type="password"]', '');

View File

@ -86,18 +86,6 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page $page
@ -113,8 +101,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Status: Needs attention') ->assertSee('Status: Needs attention')
->assertSee('Start verification') ->assertSee('Start verification')
->click('Provider connection') ->click('Select an existing connection or create a new one.')
->assertScript($visibleSelectValue, (string) $selectedConnection->getKey()); ->assertSee('Edit selected connection');
}); });
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void { it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
@ -328,32 +316,14 @@
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->wait(1) ->wait(1)
->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true) ->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true)
->click('[data-testid="verification-assist-trigger"]') ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank')
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) ->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank'); ->click('[data-testid="contextual-help-link-open-required-permissions"]')
$page->script(<<<'JS'
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: async () => Promise.resolve(),
},
});
document.querySelector('[data-testid="verification-assist-copy-application"]')?.click();
JS);
$page
->waitForText('Copied')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
->click('[data-testid="verification-assist-full-page"]')
->wait(1) ->wait(1)
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true) ->click('Select an existing connection or create a new one.')
->click('Close') ->assertSee('Edit selected connection');
->click('Provider connection')
->assertSee('Select an existing connection or create a new one.');
}); });
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void { it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('removes the acknowledged findings capability alias from shared RBAC truth', function (): void {
expect(Capabilities::isKnown('tenant_findings.acknowledge'))->toBeFalse();
expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]);
});
it('keeps the canonical findings triage capability available to operators', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue();
expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
});

View File

@ -9,6 +9,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
it('writes audit events for baseline capture start and completion with scope + gap summary', function () { it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -35,7 +36,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -58,7 +58,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -12,6 +12,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () { it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -73,7 +74,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -18,6 +18,7 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunType;
it('Baseline capture (full content) captures evidence on demand when missing', function () { it('Baseline capture (full content) captures evidence on demand when missing', function () {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true); config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -119,7 +120,7 @@ public function capture(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -64,7 +64,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -14,6 +14,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void { it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -104,7 +105,7 @@
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
$run = $operationRuns->ensureRunWithIdentity( $run = $operationRuns->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -17,6 +17,7 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState; use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
function createBaselineCaptureInventoryBasis( function createBaselineCaptureInventoryBasis(
@ -65,7 +66,7 @@ function runBaselineCaptureJob(
/** @var OperationRun $run */ /** @var OperationRun $run */
$run = $result['run']; $run = $result['run'];
expect($run->type)->toBe('baseline_capture'); expect($run->type)->toBe(OperationRunType::BaselineCapture->value);
expect($run->status)->toBe('queued'); expect($run->status)->toBe('queued');
expect($run->tenant_id)->toBe((int) $tenant->getKey()); expect($run->tenant_id)->toBe((int) $tenant->getKey());
@ -104,7 +105,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture when the latest inventory sync was blocked', function () { it('rejects capture when the latest inventory sync was blocked', function () {
@ -135,7 +136,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () { it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
@ -166,7 +167,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () { it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
@ -189,7 +190,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture for a draft profile with reason code', function () { it('rejects capture for a draft profile with reason code', function () {
@ -209,7 +210,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture for an archived profile with reason code', function () { it('rejects capture for an archived profile with reason code', function () {
@ -228,7 +229,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0);
}); });
it('rejects capture for a tenant from a different workspace', function () { it('rejects capture for a tenant from a different workspace', function () {
@ -274,7 +275,7 @@ function runBaselineCaptureJob(
expect($result2['ok'])->toBeTrue(); expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey()); expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1); expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1);
}); });
// --- Snapshot dedupe + capture job execution --- // --- Snapshot dedupe + capture job execution ---
@ -321,7 +322,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -476,7 +477,7 @@ function runBaselineCaptureJob(
$run1 = $opService->ensureRunWithIdentity( $run1 = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -499,7 +500,7 @@ function runBaselineCaptureJob(
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(), 'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => 'baseline_capture', 'type' => OperationRunType::BaselineCapture->value,
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp), 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
@ -586,7 +587,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -662,7 +663,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: 'baseline_capture', type: OperationRunType::BaselineCapture->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -528,6 +528,133 @@
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1); expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
}); });
it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $workspaceSafeExternalId,
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => $displayName],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => $displayName,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->sole();
$expectedSlaDays = (int) $finding->sla_days;
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($expectedSlaDays)->toBeGreaterThan(0)
->and($expectedDueAt)->not->toBeNull();
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe($expectedSlaDays)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
\Carbon\CarbonImmutable::setTestNow();
});
it('does not create new finding identities when a new snapshot is captured', function () { it('does not create new finding identities when a new snapshot is captured', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -15,6 +15,7 @@
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -69,14 +70,14 @@
$activeRuns = OperationRun::query() $activeRuns = OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey()) ->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare') ->where('type', OperationRunType::BaselineCompare->value)
->get(); ->get();
expect($activeRuns)->toHaveCount(2) expect($activeRuns)->toHaveCount(2)
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0); ->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
}); });
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void { it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
@ -97,7 +98,7 @@
expect(OperationRun::query() expect(OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey()) ->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', 'baseline_compare') ->where('type', OperationRunType::BaselineCompare->value)
->whereNull('tenant_id') ->whereNull('tenant_id')
->count())->toBe(0); ->count())->toBe(0);
}); });

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Concerns;
use App\Models\InventoryItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
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\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
trait BuildsPortfolioCompareFixtures
{
/**
* @return array{user: User, workspace: Workspace, sourceTenant: Tenant, targetTenant: Tenant}
*/
protected function makeCrossTenantCompareFixture(
string $workspaceRole = 'owner',
string $tenantRole = 'owner',
): array {
$sourceTenant = Tenant::factory()->create([
'name' => 'Source Tenant',
]);
[$user, $sourceTenant] = createUserWithTenant(
tenant: $sourceTenant,
role: $tenantRole,
workspaceRole: $workspaceRole,
);
$workspace = Workspace::query()->findOrFail((int) $sourceTenant->workspace_id);
$targetTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Target Tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $targetTenant->getKey() => ['role' => $tenantRole],
]);
app(CapabilityResolver::class)->clearCache();
app(WorkspaceCapabilityResolver::class)->clearCache();
return [
'user' => $user,
'workspace' => $workspace,
'sourceTenant' => $sourceTenant,
'targetTenant' => $targetTenant,
];
}
/**
* @return array{0: string, 1: array<string, mixed>}
*/
protected function setAdminWorkspaceContext(User $user, Workspace $workspace): array
{
$this->actingAs($user);
Filament::setCurrentPanel('admin');
Filament::setTenant(null, true);
Filament::bootCurrentPanel();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()];
}
/**
* @param array<string, mixed> $snapshot
* @return array{policy: Policy, version: PolicyVersion, inventory: InventoryItem}
*/
protected function createPortfolioCompareSubject(
Tenant $tenant,
string $displayName,
array $snapshot,
string $policyType = 'deviceConfiguration',
?string $externalId = null,
): array {
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => $externalId ?? str($displayName)->slug()->append('-')->append((string) str()->uuid())->toString(),
'display_name' => $displayName,
'platform' => 'windows',
]);
$version = PolicyVersion::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_type' => $policyType,
'platform' => 'windows',
'captured_at' => now(),
'snapshot' => $snapshot,
'assignments' => [],
'scope_tags' => [],
]);
$inventory = InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'policy_type' => $policyType,
'external_id' => (string) $policy->external_id,
'display_name' => $displayName,
'last_seen_at' => now(),
]);
return [
'policy' => $policy,
'version' => $version,
'inventory' => $inventory,
];
}
}

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