Compare commits

..

15 Commits

Author SHA1 Message Date
e1136ac6e9 Merge platform-dev into dev (automated) (#309)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Automatischer Commit und PR erstellt auf Anfrage.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #309
2026-04-30 14:41:01 +00:00
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
7ee4909212 feat: commercial lifecycle overlay for workspace entitlements (#292)
Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
## Summary
- add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate
- expose audited commercial state inspection and mutation on the system workspace detail surface
- gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history
- add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature

## Validation
- targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces

## Notes
- branch: `251-commercial-entitlements-billing-state`
- base: `dev`
- commit: `606e9760`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #292
2026-04-28 13:39:33 +00:00
72bfb37ba7 feat: add decision-based governance inbox (#291)
Some checks failed
Main Confidence / confidence (push) Failing after 57s
## Summary
- add a read-first governance inbox page at `/admin/governance/inbox`
- aggregate assigned findings, intake, stale operations, alert-delivery failures, and review follow-up into one canonical routing surface
- add focused coverage for inbox authorization, navigation context, page behavior, and section builder logic
- include the Spec Kit artifacts for spec 250

## Notes
- branch is synced with `dev`
- this PR supersedes #290 for the governance inbox work

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #291
2026-04-28 10:13:09 +00:00
aacd82849a feat(reviews): add CustomerReviewWorkspace with audit logging and RBAC enforcement (#289)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
Add `CustomerReviewWorkspace` page for tenant pre-filtered reviews
Add customer workspace links to `EvidenceSnapshotResource`, `ReviewPackResource`, and `TenantReviewResource`
Implement audit logging for `TenantReviewOpened` and `ReviewPackDownloaded` actions
Update ReviewPack download controller to enforce tenant-scoped RBAC
Add tests for ReviewPack download authorization and audit logging

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #289
2026-04-28 07:15:41 +00:00
ff3392892b Merge 248-private-ai-policy-foundation into dev (#288)
Some checks failed
Main Confidence / confidence (push) Failing after 56s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
Automated PR: merge branch 248-private-ai-policy-foundation into dev (created by Copilot)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #288
2026-04-27 21:18:37 +00:00
e222845a36 247: plans entitlements billing readiness (#287)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
Automated commit and PR created by Copilot per user request.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #287
2026-04-27 17:35:04 +00:00
6e3736a53f Add in-app support request with context (#285)
Some checks failed
Main Confidence / confidence (push) Failing after 1m29s
## Summary
- add the first in-app support request flow with an immutable `SupportRequest` record, canonical context builder, submission service, and generated internal reference
- expose contextual support-request actions from the tenant dashboard and operation run surfaces, including audit logging and support-safe diagnostic capture rules
- add Pest coverage plus the `specs/246-support-request-context` artifacts for the new support-request slice

## Testing
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportRequests/OperationRunSupportRequestActionTest.php tests/Feature/SupportRequests/SupportRequestAuditTest.php tests/Feature/SupportRequests/SupportRequestAuthorizationTest.php tests/Feature/SupportRequests/TenantSupportRequestActionTest.php tests/Unit/Support/SupportRequests/SupportRequestContextBuilderTest.php tests/Unit/Support/SupportRequests/SupportRequestReferenceTest.php`

## Notes
- this PR supersedes the earlier session-branch PR opened from `246-support-request-context-session-1777289015`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #285
2026-04-27 12:51:39 +00:00
395 changed files with 37084 additions and 8659 deletions

View File

@ -260,6 +260,12 @@ ## Active Technologies
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (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)
- 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)
@ -294,9 +300,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
- 253-remove-findings-backfill-runtime-surfaces: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `OperationUxPresenter`, `OperationRunService`, `OperationCatalog`, `SystemOperationRunLinks`, `OperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`, and `PlatformCapabilities`
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

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

@ -1,5 +1,10 @@
# Research T186 — settings_apply capability verification (LEGACY / DEPRECATED)
> **Status:** Superseded
> **Last reviewed:** 2026-04-30
> **Use for:** Historical investigation context only if a later Settings Catalog write-path regression needs provenance
> **Do not use for:** Active feature research or current implementation truth
> DEPRECATED: Do not add new research notes under `.specify/`.
> Active feature research should live under `specs/<NNN>-<slug>/`.
> Legacy history lives under `spechistory/`.

View File

@ -59,6 +59,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
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_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1

View File

@ -6,12 +6,14 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command
{
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}
{--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect}
@ -99,21 +101,35 @@ public function handle(): int
*/
private function normalizedTypes(): array
{
$types = array_values(array_unique(array_filter(
$requestedTypes = array_values(array_unique(array_filter(
array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'),
),
)));
if ($types === []) {
return ['baseline_compare', 'baseline_capture'];
$canonicalTypes = array_values(array_unique(array_filter(array_map(
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(
$types,
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
));
return array_values(array_unique(array_merge(
...array_map(
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

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Entitlements;
final class WorkspaceEntitlementBlockedException extends \RuntimeException
{
/**
* @param array<string, mixed> $decision
*/
public function __construct(private readonly array $decision)
{
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
}
/**
* @return array<string, mixed>
*/
public function decision(): array
{
return $this->decision;
}
}

View File

@ -9,4 +9,9 @@
class Login extends BaseLogin
{
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
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
$actions = [];
$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')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
}
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
{
$resolvedTenant = array_key_exists('tenant', $overrides)

View File

@ -97,14 +97,26 @@ public function mount(): void
protected function getHeaderActions(): array
{
return [
Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
$actions = [];
$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')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter()),
];
->url($governanceContext->backLinkUrl);
}
$actions[] = Action::make('clear_tenant_filter')
->label('Clear tenant filter')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
->action(fn (): mixed => $this->clearTenantFilter());
return $actions;
}
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
{
$tenant = $this->filteredTenant();

View File

@ -0,0 +1,520 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class GovernanceInbox extends Page
{
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Governance inbox';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Governance inbox';
protected static ?string $slug = 'governance/inbox';
protected string $view = 'filament.pages.governance.governance-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleFindingTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $reviewTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $inboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredInboxPayload = null;
private ?Workspace $workspace = null;
private ?bool $visibleAlertsFamily = null;
private ?bool $visibleFindingExceptionsFamily = null;
public ?int $tenantId = null;
public ?string $family = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
}
public function mount(): void
{
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->family = $this->resolveRequestedFamily();
$this->ensureAtLeastOneVisibleFamily();
$this->ensureRequestedFamilyIsVisible();
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$selectedTenant = $this->selectedTenant();
$availableFamilies = collect($this->availableFamilies())
->keyBy('key');
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $selectedTenant?->name,
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
'family_key' => $this->family,
'family_label' => $this->family !== null
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
: 'All attention',
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableFamilies(): array
{
return $this->inboxPayload()['available_families'] ?? [];
}
/**
* @return list<array<string, mixed>>
*/
public function sections(): array
{
return $this->inboxPayload()['sections'] ?? [];
}
/**
* @return array<string, mixed>
*/
public function calmEmptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'This tenant filter is hiding other visible attention',
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
'action_label' => 'Clear tenant filter',
'action_url' => $this->pageUrl(['tenant' => null]),
];
}
return [
'title' => 'No visible governance attention right now',
'body' => 'The current workspace scope is calm across the visible governance families.',
'action_label' => null,
'action_url' => null,
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveFamily(?string $familyKey): bool
{
return $this->family === $familyKey;
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
: $this->family;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function navigationContext(): CanonicalNavigationContext
{
return CanonicalNavigationContext::forGovernanceInbox(
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkUrl: $this->pageUrl(),
familyKey: $this->family,
);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureAtLeastOneVisibleFamily(): void
{
if (
$this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== []
|| $this->hasVisibleFindingExceptionsFamily()
|| $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily()
) {
return;
}
abort(403);
}
private function ensureRequestedFamilyIsVisible(): void
{
if ($this->family === null) {
return;
}
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
return;
}
throw new NotFoundHttpException;
}
private function hasVisibleOperationsFamily(): bool
{
return $this->authorizedTenants() !== [];
}
private function hasVisibleAlertsFamily(): bool
{
if (is_bool($this->visibleAlertsFamily)) {
return $this->visibleAlertsFamily;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleAlertsFamily = false;
}
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>
*/
private function visibleFindingTenants(): array
{
if ($this->visibleFindingTenants !== null) {
return $this->visibleFindingTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleFindingTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleFindingTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
/**
* @return array<int, Tenant>
*/
private function reviewTenants(): array
{
if ($this->reviewTenants !== null) {
return $this->reviewTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->reviewTenants = [];
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
return $this->reviewTenants = [];
}
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedFamily(): ?string
{
$family = request()->query('family');
if (! is_string($family)) {
return null;
}
return in_array($family, [
'assigned_findings',
'intake_findings',
'finding_exceptions',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
], true) ? $family : null;
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<string, mixed>
*/
private function inboxPayload(): array
{
if (is_array($this->inboxPayload)) {
return $this->inboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->inboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredInboxPayload(): array
{
if (is_array($this->unfilteredInboxPayload)) {
return $this->unfilteredInboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->unfilteredInboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
canViewFindingExceptions: $this->hasVisibleFindingExceptionsFamily(),
selectedTenant: null,
selectedFamily: null,
navigationContext: $this->navigationContext(),
);
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->family !== null) {
return false;
}
if ($this->sections() !== []) {
return false;
}
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
}
}

View File

@ -208,6 +208,16 @@ protected function getHeaderActions(): array
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')
->label('Clear filters')
->icon('heroicon-o-x-mark')
@ -479,7 +489,10 @@ public function selectedExceptionUrl(): ?string
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
@ -490,7 +503,10 @@ public function selectedFindingUrl(): ?string
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
@ -654,6 +670,15 @@ private function navigationContext(): ?CanonicalNavigationContext
return CanonicalNavigationContext::fromRequest(request());
}
private function incomingGovernanceContext(): ?CanonicalNavigationContext
{
$context = $this->navigationContext();
return $context?->sourceSurface === 'governance.inbox'
? $context
: null;
}
private function normalizeSelectedFindingExceptionId(): void
{
if (! is_int($this->selectedFindingExceptionId) || $this->selectedFindingExceptionId <= 0) {
@ -783,4 +808,16 @@ private function governanceWarningColor(FindingException $record): string
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

@ -6,6 +6,7 @@
use App\Filament\Resources\OperationRunResource;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
@ -30,6 +31,8 @@
use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
@ -40,9 +43,14 @@
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable;
@ -141,10 +149,6 @@ protected function getHeaderActions(): array
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: OperationRunLinks::index());
if (isset($this->run)) {
$actions[] = $this->openSupportDiagnosticsAction();
}
if (! isset($this->run)) {
return $actions;
}
@ -167,6 +171,14 @@ protected function getHeaderActions(): array
->color('gray');
}
$actions[] = ActionGroup::make([
$this->openSupportDiagnosticsAction(),
$this->requestSupportAction(),
])
->label('More')
->icon('heroicon-o-ellipsis-horizontal')
->color('gray');
$actions[] = $this->resumeCaptureAction();
return $actions;
@ -228,8 +240,6 @@ private function openSupportDiagnosticsAction(): Action
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->iconButton()
->tooltip('Open support diagnostics')
->color('gray')
->record($this->run)
->modal()
@ -251,39 +261,128 @@ private function openSupportDiagnosticsAction(): Action
->apply();
}
public function authorizeOperationRunSupportRequest(): void
{
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->record($this->run)
->slideOver()
->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_run_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('primary_context')
->label(__('localization.dashboard.primary_context'))
->content(fn (): string => OperationRunLinks::identifier($this->run))
->columnSpanFull(),
Placeholder::make('included_context')
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
->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')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveViewerActor()->name),
TextInput::make('contact_email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveViewerActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveViewerActor();
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
Notification::make()
->title(__('localization.dashboard.support_request_submitted'))
->body($this->supportRequestNotificationBody($supportRequest))
->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();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
/**
* @return array<string, mixed>
*/
public function operationRunSupportDiagnosticBundle(): array
{
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
}
private function auditOperationSupportDiagnosticsOpen(): void
{
$user = auth()->user();
$tenant = $this->supportDiagnosticsTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$user = $this->resolveViewerActor();
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
@ -307,6 +406,151 @@ private function supportDiagnosticsTenant(): ?Tenant
return $this->run->loadMissing('tenant')->tenant;
}
private function resolveViewerActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
return $user;
}
private function resolveRunTenantForCapability(string $capability): Tenant
{
$tenant = $this->supportDiagnosticsTenant();
$user = $this->resolveViewerActor();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function operationSupportRequestAttachmentSummary(): string
{
$tenant = $this->supportDiagnosticsTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted run context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted run context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
: '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
*/

View File

@ -0,0 +1,573 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Navigation\CanonicalNavigationContext;
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\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
private const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/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
{
$tenantIdentifier = filled($tenant->external_id)
? (string) $tenant->external_id
: (string) $tenant->getKey();
return static::getUrl(panel: 'admin').'?'.http_build_query([
'tenant' => $tenantIdentifier,
]);
}
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
public function mount(): void
{
$this->authorizePageAccess();
$this->applyRequestedTenantPrefilter();
$this->mountInteractsWithTable();
}
protected function getHeaderActions(): array
{
$actions = [];
$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')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(function (): void {
$this->clearWorkspaceFilters();
});
return $actions;
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->workspaceQuery())
->defaultSort('name')
->paginated(TablePaginationProfiles::customPage())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->columns([
TextColumn::make('name')->label(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review')
->label(__('localization.review.latest_review'))
->badge()
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
->wrap(),
TextColumn::make('finding_summary')
->label(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('published_at')
->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label(__('localization.review.tenant'))
->options(fn (): array => $this->tenantFilterOptions())
->default(fn (): ?string => $this->defaultTenantFilter())
->query(function (Builder $query, array $data): Builder {
$tenantId = $data['value'] ?? null;
return is_numeric($tenantId)
? $query->whereKey((int) $tenantId)
: $query;
})
->searchable(),
])
->actions([
Action::make('open_latest_review')
->label(__('localization.review.open_latest_review'))
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
Action::make('download_review_pack')
->label(__('localization.review.download_review_pack'))
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
->openUrlInNewTab()
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
])
->bulkActions([])
->emptyStateHeading(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? __('localization.review.clear_filters_description')
: __('localization.review.adjust_filters_description'))
->emptyStateActions([
Action::make('clear_filters_empty')
->label(__('localization.review.clear_filters'))
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (): bool => $this->hasActiveFilters())
->action(fn (): mixed => $this->clearWorkspaceFilters()),
]);
}
/**
* @return array<int, Tenant>
*/
public function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
}
private function authorizePageAccess(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
throw new NotFoundHttpException;
}
if ($this->authorizedTenants() === []) {
throw new NotFoundHttpException;
}
}
private function workspaceQuery(): Builder
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return Tenant::query()->whereRaw('1 = 0');
}
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
}
/**
* @return array<string, string>
*/
private function tenantFilterOptions(): array
{
return collect($this->authorizedTenants())
->mapWithKeys(static fn (Tenant $tenant): array => [
(string) $tenant->getKey() => $tenant->name,
])
->all();
}
private function defaultTenantFilter(): ?string
{
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
? (string) $tenantId
: null;
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function hasActiveFilters(): bool
{
return $this->currentTenantFilterId() !== null;
}
private function clearWorkspaceFilters(): void
{
app(WorkspaceContext::class)->clearLastTenantId(request());
$this->removeTableFilters();
}
private function currentTenantFilterId(): ?int
{
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
}
private function workspace(): ?Workspace
{
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
return is_numeric($workspaceId)
? Workspace::query()->whereKey((int) $workspaceId)->first()
: null;
}
private function latestPublishedReview(Tenant $tenant): ?TenantReview
{
$review = $tenant->tenantReviews->first();
return $review instanceof TenantReview ? $review : null;
}
private function latestReviewUrl(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return null;
}
return $this->appendQuery(
TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant'),
array_replace(
[self::DETAIL_CONTEXT_QUERY_KEY => 1],
$this->navigationContext()?->toQuery() ?? [],
),
);
}
private function latestReviewPack(Tenant $tenant): ?ReviewPack
{
$review = $this->latestPublishedReview($tenant);
$pack = $review?->currentExportReviewPack;
return $pack instanceof ReviewPack ? $pack : null;
}
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
{
$user = auth()->user();
$pack = $this->latestReviewPack($tenant);
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
return null;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
return null;
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return null;
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return null;
}
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => self::SOURCE_SURFACE,
]);
}
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
{
return $this->latestPublishedReview($tenant)?->published_at;
}
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
{
$review = $this->latestPublishedReview($tenant);
return $review instanceof TenantReview
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
: null;
}
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
{
$presenter = app(ArtifactTruthPresenter::class);
$review = $this->latestPublishedReview($tenant);
$truth = $this->reviewTruth($tenant);
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
}
private function latestReviewStateLabel(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryLabel ?? __('localization.review.no_published_review');
}
private function latestReviewStateColor(Tenant $tenant): string
{
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
}
private function latestReviewStateIcon(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
}
private function latestReviewStateIconColor(Tenant $tenant): ?string
{
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
}
private function reviewOutcomeDescription(Tenant $tenant): ?string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
$summary = is_array($review->summary) ? $review->summary : [];
$findingOutcomes = $summary['finding_outcomes'] ?? null;
if (! is_array($findingOutcomes)) {
return $primaryReason;
}
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingOutcomeSummary === null) {
return $primaryReason;
}
return trim($primaryReason.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$summary = is_array($review->summary) ? $review->summary : [];
$findingCount = (int) ($summary['finding_count'] ?? 0);
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
if ($findingCount === 0) {
return __('localization.review.no_findings_recorded');
}
if ($terminalOutcomes === null) {
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
}
return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$summary = is_array($review->summary) ? $review->summary : [];
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
return match (true) {
$statusMarkedCount === 0 => __('localization.review.no_accepted_risks_recorded'),
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
};
}
private function reviewPackAvailability(Tenant $tenant): string
{
$pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) {
return __('localization.review.unavailable');
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return __('localization.review.unavailable');
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return __('localization.review.unavailable');
}
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

@ -9,6 +9,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
@ -176,6 +177,24 @@ public function table(Table $table): Table
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
&& in_array($record->status, ['ready', 'published'], true))
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(function (TenantReview $record): ?string {
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
if ((bool) ($decision['is_warning'] ?? false)) {
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
return null;
})
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
])
->bulkActions([])

View File

@ -7,7 +7,12 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Support\Ai\AiPolicyMode;
use App\Support\Ai\AiUseCaseCatalog;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities;
@ -20,7 +25,9 @@
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
@ -51,6 +58,8 @@ class WorkspaceSettings extends Page
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
*/
private const SETTING_FIELDS = [
'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_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
@ -58,10 +67,23 @@ class WorkspaceSettings extends Page
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
];
/**
* @var array<string, string>
*/
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
];
/**
* Fields rendered as Filament KeyValue components (array state, not JSON string).
*
@ -111,6 +133,14 @@ class WorkspaceSettings extends Page
*/
public array $resolvedSettings = [];
/**
* @var array{
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions?: array<string, array<string, mixed>>
* }
*/
public array $entitlementSummary = [];
/**
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
*
@ -125,17 +155,22 @@ protected function getHeaderActions(): array
{
return [
Action::make('save')
->label('Save')
->label(__('localization.workspace.save'))
->action(function (): void {
$this->save();
})
->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage()
? 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
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -180,6 +215,83 @@ public function content(Schema $schema): Schema
return $schema
->statePath('data')
->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')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2)
->schema([
Select::make('entitlements_plan_profile')
->label('Plan profile')
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
->native(false)
->columnSpanFull()
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->planProfileFieldHelperText()),
TextInput::make('entitlements_managed_tenant_limit_override_value')
->label('Managed tenant activation limit override')
->placeholder('Unset (uses plan profile default)')
->suffix('tenants')
->hint('0 or greater')
->numeric()
->integer()
->minValue(0)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitHelperText())
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
Textarea::make('entitlements_managed_tenant_limit_override_reason')
->label('Managed tenant activation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
Select::make('entitlements_review_pack_generation_override_value')
->label('Review pack generation override')
->options(self::booleanOptions())
->placeholder('Unset (uses plan profile default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
Textarea::make('entitlements_review_pack_generation_override_reason')
->label('Review pack generation override reason')
->rows(3)
->maxLength(500)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
]),
Section::make('Workspace AI policy')
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
->schema([
Select::make('ai_policy_mode')
->label('AI posture')
->options(AiPolicyMode::optionLabels())
->placeholder('Unset (uses default)')
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->aiPolicyModeHelperText())
->hintAction($this->makeResetAction('ai_policy_mode')),
Placeholder::make('ai_approved_use_cases')
->label('Approved use cases')
->content(fn (): string => $this->aiApprovedUseCasesText()),
Placeholder::make('ai_allowed_provider_classes')
->label('Allowed provider classes')
->content(fn (): string => $this->aiAllowedProviderClassesText()),
Placeholder::make('ai_blocked_data_classifications')
->label('Blocked data classifications')
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
]),
Section::make('Backup settings')
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
->schema([
@ -414,7 +526,7 @@ public function save(): void
$this->loadFormState();
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()
->send();
}
@ -433,7 +545,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) {
Notification::make()
->title('Setting already uses default')
->title(__('localization.notifications.setting_already_default'))
->success()
->send();
@ -450,7 +562,57 @@ public function resetSetting(string $field): void
$this->loadFormState();
Notification::make()
->title('Workspace setting reset to default')
->title(__('localization.notifications.workspace_setting_reset'))
->success()
->send();
}
private function resetEntitlementOverridePair(string $field): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceManage($user);
if (! $this->hasEntitlementOverridePair($field)) {
Notification::make()
->title('Entitlement already uses plan profile default')
->success()
->send();
return;
}
$writer = app(SettingsWriter::class);
$valueSetting = $this->settingForField($field);
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
$reasonSetting = $this->settingForField($reasonField);
if ($this->workspaceOverrideForField($field) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $valueSetting['domain'],
key: $valueSetting['key'],
);
}
if ($this->workspaceOverrideForField($reasonField) !== null) {
$writer->resetWorkspaceSetting(
actor: $user,
workspace: $this->workspace,
domain: $reasonSetting['domain'],
key: $reasonSetting['key'],
);
}
$this->loadFormState();
Notification::make()
->title('Workspace entitlement override reset')
->success()
->send();
}
@ -490,6 +652,7 @@ private function loadFormState(): void
$this->data = $data;
$this->workspaceOverrides = $workspaceOverrides;
$this->resolvedSettings = $resolvedSettings;
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
$this->loadDomainLastModified();
}
@ -548,37 +711,240 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at'];
return sprintf(
'%s — Last modified by %s, %s.',
$baseDescription,
$meta['user_name'],
$updatedAt->diffForHumans(),
);
return __('localization.workspace.last_modified_by', [
'description' => $baseDescription,
'user' => $meta['user_name'],
'time' => $updatedAt->diffForHumans(),
]);
}
private function makeResetAction(string $field): Action
{
return Action::make('reset_'.$field)
->label('Reset')
->label(__('localization.workspace.reset'))
->color('danger')
->requiresConfirmation()
->action(function () use ($field): void {
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return 'You do not have permission to manage workspace settings.';
if ($this->isEntitlementOverrideValueField($field)) {
$this->resetEntitlementOverridePair($field);
return;
}
if (! $this->hasWorkspaceOverride($field)) {
return 'No workspace override to reset.';
$this->resetSetting($field);
})
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
->tooltip(function () use ($field): ?string {
if (! $this->currentUserCanManage()) {
return __('localization.workspace.no_manage_permission');
}
if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) {
return __('localization.workspace.no_workspace_override');
}
return __('localization.workspace.no_workspace_override');
}
return null;
});
}
private function canResetField(string $field): bool
{
if ($this->isEntitlementOverrideValueField($field)) {
return $this->hasEntitlementOverridePair($field);
}
return $this->hasWorkspaceOverride($field);
}
private function isEntitlementOverrideValueField(string $field): bool
{
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
}
private function hasEntitlementOverridePair(string $field): bool
{
if (! $this->isEntitlementOverrideValueField($field)) {
return false;
}
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
return $this->workspaceOverrideForField($field) !== null
|| $this->workspaceOverrideForField($reasonField) !== null;
}
private function planProfileFieldHelperText(): string
{
$profile = $this->resolvedPlanProfile();
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
if (! is_string($selectedProfile) || $selectedProfile === '') {
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
}
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
}
private function managedTenantLimitHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
$currentUsage = (int) ($decision['current_usage'] ?? 0);
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$capacityText = $remainingCapacity < 0
? sprintf('Over limit by %d.', abs($remainingCapacity))
: sprintf('%d remaining.', $remainingCapacity);
return sprintf(
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
$effectiveValue,
$currentUsage,
$capacityText,
$this->entitlementSourceLabel($decision),
);
}
private function managedTenantLimitReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_managed_tenant_limit_override_value',
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
}
private function reviewPackGenerationHelperText(): string
{
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
return sprintf(
'Effective state: %s. Source: %s.',
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
$this->entitlementSourceLabel($decision),
);
}
private function reviewPackGenerationReasonHelperText(): string
{
return $this->entitlementReasonHelperText(
valueField: 'entitlements_review_pack_generation_override_value',
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
}
private function aiPolicyModeHelperText(): string
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return '';
}
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
: sprintf('Effective posture: %s.', $mode->label());
return sprintf('%s %s', $prefix, $mode->summary());
}
private function aiApprovedUseCasesText(): string
{
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
}
private function aiAllowedProviderClassesText(): string
{
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
if ($labels === []) {
return 'No provider classes are allowed while AI is disabled.';
}
return implode(', ', $labels).'.';
}
private function aiBlockedDataClassificationsText(): string
{
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
}
private function effectiveAiPolicyMode(): AiPolicyMode
{
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
if (! is_array($resolved)) {
return AiPolicyMode::Disabled;
}
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
?? AiPolicyMode::Disabled;
}
private function entitlementReasonHelperText(string $valueField, string $key): string
{
$decision = $this->entitlementDecision($key);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
if ($this->workspaceOverrideForField($valueField) === null) {
return 'Required when an explicit override value is set.';
}
if ($rationale === null || $rationale === '') {
return 'Required when an explicit override value is set.';
}
return sprintf('Current rationale: %s', $rationale);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
private function resolvedPlanProfile(): array
{
$profile = $this->entitlementSummary['plan_profile'] ?? null;
if (is_array($profile)) {
return $profile;
}
return app(WorkspacePlanProfileCatalog::class)->default();
}
/**
* @return array<string, mixed>
*/
private function entitlementDecision(string $key): array
{
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
return is_array($decision) ? $decision : [];
}
/**
* @param array<string, mixed> $decision
*/
private function entitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$planProfileLabel = $decision['plan_profile_label'] ?? null;
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
return sprintf('%s plan profile', $planProfileLabel);
}
return 'plan profile default';
}
private function helperTextFor(string $field): string
{
$resolved = $this->resolvedSettings[$field] ?? null;
@ -600,6 +966,29 @@ private function helperTextFor(string $field): string
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
{
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
@ -721,6 +1110,27 @@ private function normalizedInputValues(): array
}
}
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
if (($normalizedValues[$valueField] ?? null) === null) {
$normalizedValues[$reasonField] = null;
continue;
}
if (($normalizedValues[$reasonField] ?? null) !== null) {
continue;
}
$message = match ($valueField) {
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
default => 'Override reason is required when an explicit override is set.',
};
$validationErrors['data.'.$reasonField] ??= [];
$validationErrors['data.'.$reasonField][] = $message;
}
return [$normalizedValues, $validationErrors];
}
@ -984,9 +1394,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
private function sourceLabel(string $source): string
{
return match ($source) {
'workspace_override' => 'workspace override',
'workspace_override' => __('localization.source.workspace_override'),
'tenant_override' => 'tenant override',
default => 'system default',
default => __('localization.source.system_default'),
};
}

View File

@ -11,6 +11,7 @@
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
@ -20,9 +21,17 @@
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\SupportRequests\ExternalSupportDeskHandoffService;
use App\Support\SupportRequests\SupportRequestSubmissionService;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Dashboard;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
@ -35,6 +44,11 @@ class TenantDashboard extends Dashboard
*/
public array $supportDiagnosticsAuditKeys = [];
public function getTitle(): string
{
return __('localization.dashboard.tenant_title');
}
/**
* @param array<mixed> $parameters
*/
@ -70,23 +84,128 @@ public function getColumns(): int|array
protected function getHeaderActions(): array
{
return [
$this->requestSupportAction(),
$this->openSupportDiagnosticsAction(),
];
}
public function authorizeTenantSupportRequest(): void
{
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
}
private function requestSupportAction(): Action
{
$action = Action::make('requestSupport')
->label(__('localization.dashboard.request_support'))
->icon('heroicon-o-paper-airplane')
->color('gray')
->slideOver()
->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_request_heading'))
->modalDescription(__('localization.dashboard.support_request_description'))
->modalSubmitActionLabel(__('localization.dashboard.submit_request'))
->form([
Placeholder::make('included_context')
->label(__('localization.dashboard.included_context'))
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->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')
->label(__('localization.dashboard.severity'))
->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL)
->required()
->native(false),
TextInput::make('summary')
->label(__('localization.dashboard.summary'))
->required()
->columnSpanFull(),
Textarea::make('reproduction_notes')
->label(__('localization.dashboard.reproduction_notes'))
->rows(4)
->columnSpanFull(),
TextInput::make('contact_name')
->label(__('localization.dashboard.contact_name'))
->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email')
->label(__('localization.dashboard.contact_email'))
->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email),
])
->action(function (array $data): void {
$actor = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make()
->title(__('localization.dashboard.support_request_submitted'))
->body($this->supportRequestNotificationBody($supportRequest))
->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();
});
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
->apply();
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->label(__('localization.dashboard.open_support_diagnostics'))
->icon('heroicon-o-lifebuoy')
->color('gray')
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted tenant context from existing records.')
->modalHeading(__('localization.dashboard.support_diagnostics'))
->modalDescription(__('localization.dashboard.support_diagnostics_description'))
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close')))
->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen();
})
@ -104,34 +223,16 @@ private function openSupportDiagnosticsAction(): Action
*/
public function tenantSupportDiagnosticBundle(): array
{
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
abort(403);
}
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
}
private function auditTenantSupportDiagnosticsOpen(): void
{
$user = auth()->user();
$tenant = Filament::getTenant();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$user = $this->resolveDashboardActor();
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
@ -172,4 +273,150 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
private function resolveDashboardActor(): User
{
$user = auth()->user();
if (! $user instanceof User) {
abort(404);
}
return $user;
}
private function resolveCurrentTenantForCapability(string $capability): Tenant
{
$user = $this->resolveDashboardActor();
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
abort(404);
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, $capability)) {
abort(403);
}
return $tenant;
}
private function tenantSupportRequestAttachmentSummary(): string
{
$tenant = Filament::getTenant();
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return 'Only canonical redacted tenant context will be attached.';
}
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
return 'Only canonical redacted tenant context will be attached.';
}
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
? '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.';
}
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

@ -30,6 +30,8 @@
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderOperationRegistry;
@ -662,7 +664,16 @@ public function content(Schema $schema): Schema
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()),
Text::make('Activation entitlement')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
->badge()
->color(fn (): string => $this->completionSummaryEntitlementColor()),
]),
Callout::make('Activation entitlement')
->description(fn (): string => $this->completionSummaryEntitlementDetail())
->warning()
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
@ -700,9 +711,7 @@ public function content(Schema $schema): Schema
->modalSubmitActionLabel('Yes, complete onboarding')
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
? null
: 'Owner required to complete onboarding.')
->tooltip(fn (): ?string => $this->completionActionTooltip())
->action(fn () => $this->completeOnboarding()),
]),
]),
@ -4498,6 +4507,10 @@ private function canCompleteOnboarding(): bool
return false;
}
if ($this->completionSummaryEntitlementBlocked()) {
return false;
}
$user = $this->currentUser();
if (! app(TenantOperabilityService::class)->outcomeFor(
@ -4530,6 +4543,116 @@ private function canCompleteOnboarding(): bool
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
}
/**
* @return array<string, mixed>
*/
private function completionSummaryEntitlementDecision(): array
{
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
return [];
}
return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision(
$this->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION,
);
}
private function completionSummaryEntitlementBlocked(): bool
{
return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK;
}
private function completionSummaryEntitlementSummary(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
return sprintf(
'%s - %s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage,
$effectiveValue,
$sourceLabel,
);
}
private function completionSummaryEntitlementDetail(): string
{
$decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
$remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf(
'%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
(string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'),
$currentUsage,
$currentUsage === 1 ? '' : 's',
$effectiveValue,
$sourceLabel,
);
if ($remainingCapacity >= 0) {
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
}
if ($this->completionSummaryEntitlementBlocked()) {
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
if ($blockReason !== null && $blockReason !== '') {
$message = $blockReason;
}
}
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) {
$message .= ' Rationale: '.$rationale;
}
return $message;
}
private function completionSummaryEntitlementColor(): string
{
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
}
/**
* @param array<string, mixed> $decision
*/
private function completionSummaryEntitlementSourceLabel(array $decision): string
{
if (($decision['source'] ?? null) === 'workspace_override') {
return 'workspace override';
}
$label = $decision['plan_profile_label'] ?? null;
return is_string($label) && $label !== ''
? sprintf('%s plan profile', $label)
: 'plan profile default';
}
private function completionActionTooltip(): ?string
{
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
return 'Owner required to complete onboarding.';
}
if ($this->completionSummaryEntitlementBlocked()) {
return $this->completionSummaryEntitlementDetail();
}
return null;
}
private function completionSummaryTenantLine(): string
{
$tenant = $this->currentManagedTenantRecord();
@ -4863,6 +4986,16 @@ public function completeOnboarding(): void
return;
}
if ($this->completionSummaryEntitlementBlocked()) {
Notification::make()
->title('Activation unavailable')
->body($this->completionSummaryEntitlementDetail())
->warning()
->send();
return;
}
$run = $this->verificationRun();
$verificationSucceeded = $this->verificationHasSucceeded();
$verificationCanProceed = $this->verificationCanProceed();

View File

@ -6,6 +6,7 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot;
@ -267,6 +268,20 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
)->toArray();
}
if ($record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'customer_review_workspace',
label: 'Customer workspace',
value: $record->tenant->name,
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
targetKind: 'canonical_page',
priority: 30,
actionLabel: 'Open customer workspace',
contextBadge: 'Reporting',
)->toArray();
}
return $entries;
}

View File

@ -75,8 +75,6 @@ class FindingResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
public static function shouldRegisterNavigation(): bool
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
@ -86,6 +84,26 @@ public static function shouldRegisterNavigation(): bool
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
{
$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))
: null)
->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('last_seen_at')->label('Last seen')->dateTime()->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, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;
@ -1398,7 +1413,6 @@ public static function triageAction(): Actions\Action
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
@ -1423,7 +1437,6 @@ public static function startProgressAction(): Actions\Action
->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(

View File

@ -10,14 +10,8 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Findings\FindingWorkflowService;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\Capabilities;
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\UiTooltips;
use Filament\Actions;
@ -77,15 +71,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant();
return [
'all' => Tab::make('All')
'all' => Tab::make(__('localization.findings.all'))
->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make('Needs action')
'needs_action' => Tab::make(__('localization.findings.needs_action'))
->icon('heroicon-m-exclamation-triangle')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery()))
->badge($stats['open'] > 0 ? $stats['open'] : null)
->badgeColor('warning'),
'overdue' => Tab::make('Overdue')
'overdue' => Tab::make(__('localization.findings.overdue'))
->icon('heroicon-m-clock')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())
@ -93,11 +87,11 @@ public function getTabs(): array
->where('due_at', '<', now()))
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
->badgeColor('danger'),
'risk_accepted' => Tab::make('Risk accepted')
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted'))
->icon('heroicon-m-shield-check')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', Finding::STATUS_RISK_ACCEPTED)),
'resolved' => Tab::make('Resolved')
'resolved' => Tab::make(__('localization.findings.resolved'))
->icon('heroicon-m-archive-box')
->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
@ -108,77 +102,6 @@ protected function getHeaderActions(): array
{
$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\Action::make('triage_all_matching')
->label('Triage all matching')
@ -248,7 +171,6 @@ protected function getHeaderActions(): array
if (! in_array((string) $finding->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
$skippedCount++;

View File

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

View File

@ -2,7 +2,10 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource\Pages;
use App\Models\ReviewPack;
@ -10,6 +13,7 @@
use App\Models\User;
use App\Services\ReviewPackService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -45,6 +49,8 @@
class ReviewPackResource extends Resource
{
use ResolvesPanelTenantContext;
protected static ?string $model = ReviewPack::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
@ -102,9 +108,9 @@ public static function canView(Model $record): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
@ -190,6 +196,13 @@ public static function infolist(Schema $schema): Schema
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null)
->placeholder('—'),
TextEntry::make('customer_workspace')
->label('Customer workspace')
->state(fn (): string => 'Open workspace')
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status')
->label('Review status')
->badge()
@ -350,41 +363,62 @@ public static function table(Table $table): Table
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
->emptyStateIcon('heroicon-o-document-arrow-down')
->emptyStateActions([
UiEnforcement::forAction(
Actions\Action::make('generate_first')
->label('Generate first pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
static::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
]);
}
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
{
$action = UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon('heroicon-o-plus')
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
->action(function (array $data): void {
static::executeGeneration($data);
})
->form(static::reviewPackGenerationFormSchema())
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
return $action;
}
/**
* @return array<int, Section>
*/
public static function reviewPackGenerationFormSchema(): array
{
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
];
}
public static function getEloquentQuery(): Builder
{
$tenant = Filament::getTenant();
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
return parent::getEloquentQuery()
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
->where('tenant_id', (int) $tenant->getKey());
}
public static function getPages(): array
@ -458,6 +492,14 @@ public static function executeGeneration(array $data): void
try {
$reviewPack = $service->generate($tenant, $user, $options);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->warning()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->send();
return;
} catch (ReviewPackEvidenceResolutionException $exception) {
$reasons = $exception->result->reasons;
@ -493,4 +535,69 @@ public static function executeGeneration(array $data): void
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function currentTenantContext(): ?Tenant
{
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
return $tenant instanceof Tenant ? $tenant : null;
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::currentTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
}
}

View File

@ -3,12 +3,7 @@
namespace App\Filament\Resources\ReviewPackResource\Pages;
use App\Filament\Resources\ReviewPackResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Section;
class ListReviewPacks extends ListRecords
{
@ -17,29 +12,13 @@ class ListReviewPacks extends ListRecords
protected function getHeaderActions(): array
{
return [
UiEnforcement::forAction(
Actions\Action::make('generate_pack')
->label('Generate Pack')
->icon('heroicon-o-plus')
->action(function (array $data): void {
ReviewPackResource::executeGeneration($data);
})
->form([
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default(config('tenantpilot.review_pack.include_pii_default', true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default(config('tenantpilot.review_pack.include_operations_default', true)),
]),
])
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
ReviewPackResource::generatePackAction()
->visible(fn (): bool => $this->tableHasRecords()),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
}

View File

@ -19,6 +19,51 @@ class ViewReviewPack extends ViewRecord
protected function getHeaderActions(): array
{
$regenerateAction = UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
/** @var ReviewPack $record */
$record = $this->record;
$options = array_merge($record->options ?? [], [
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
]);
ReviewPackResource::executeGeneration($options);
})
->form(function (): array {
/** @var ReviewPack $record */
$record = $this->record;
$currentOptions = $record->options ?? [];
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default((bool) ($currentOptions['include_pii'] ?? true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default((bool) ($currentOptions['include_operations'] ?? true)),
]),
];
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->preserveDisabled()
->apply();
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
return [
Actions\Action::make('download')
->label('Download')
@ -28,46 +73,7 @@ protected function getHeaderActions(): array
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('regenerate')
->label('Regenerate')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
->action(function (array $data): void {
/** @var ReviewPack $record */
$record = $this->record;
$options = array_merge($record->options ?? [], [
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
]);
ReviewPackResource::executeGeneration($options);
})
->form(function (): array {
/** @var ReviewPack $record */
$record = $this->record;
$currentOptions = $record->options ?? [];
return [
Section::make('Pack options')
->schema([
Toggle::make('include_pii')
->label('Include PII')
->helperText('Include personally identifiable information in the export.')
->default((bool) ($currentOptions['include_pii'] ?? true)),
Toggle::make('include_operations')
->label('Include operations')
->helperText('Include recent operation history in the export.')
->default((bool) ($currentOptions['include_operations'] ?? true)),
]),
];
})
)
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
->apply(),
$regenerateAction,
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Pages\CrossTenantComparePage;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
@ -15,9 +16,11 @@
use App\Models\TenantOnboardingSession;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Directory\RoleDefinitionsSyncService;
use App\Services\Graph\GraphClientInterface;
@ -44,6 +47,7 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\Rbac\UiEnforcement;
@ -68,6 +72,7 @@
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Infolists;
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'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& 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(
Actions\Action::make('edit')
->label('Edit')
@ -966,6 +992,34 @@ public static function table(Table $table): Table
])
->bulkActions([
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')
->label('Sync selected')
->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{
* 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(
array $backupPostures,
array $recoveryEvidence,

View File

@ -6,7 +6,9 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot;
use App\Models\Tenant;
use App\Models\TenantReview;
@ -15,6 +17,7 @@
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips as AuthUiTooltips;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -82,6 +85,26 @@ public static function shouldRegisterNavigation(): bool
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
{
$tenant = static::resolveTenantContextForCurrentPanel();
@ -150,7 +173,7 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema
{
return $schema->schema([
Section::make('Outcome summary')
Section::make(__('localization.review.outcome_summary'))
->schema([
ViewEntry::make('artifact_truth')
->hiddenLabel()
@ -159,7 +182,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Review')
Section::make(__('localization.review.review'))
->schema([
TextEntry::make('status')
->badge()
@ -168,23 +191,23 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::make('completeness_state')
->label('Completeness')
->label(__('localization.review.completeness'))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(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('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id')
->label('Evidence snapshot')
->label(__('localization.review.evidence_snapshot'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null),
TextEntry::make('currentExportReviewPack.id')
->label('Current export')
->label(__('localization.review.current_export'))
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
@ -198,7 +221,7 @@ public static function infolist(Schema $schema): Schema
])
->columns(2)
->columnSpanFull(),
Section::make('Executive posture')
Section::make(__('localization.review.executive_posture'))
->schema([
ViewEntry::make('review_summary')
->hiddenLabel()
@ -207,21 +230,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(),
])
->columnSpanFull(),
Section::make('Sections')
Section::make(__('localization.review.sections'))
->schema([
RepeatableEntry::make('sections')
->hiddenLabel()
->schema([
TextEntry::make('title'),
TextEntry::make('completeness_state')
->label('Completeness')
->label(__('localization.review.completeness'))
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
Section::make('Details')
Section::make(__('localization.review.details'))
->schema([
ViewEntry::make('section_payload')
->hiddenLabel()
@ -241,6 +264,25 @@ public static function infolist(Schema $schema): Schema
public static function table(Table $table): Table
{
$exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label(__('localization.review.export_executive_pack'))
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
return $table
->defaultSort('generated_at', 'desc')
->persistFiltersInSession()
@ -256,7 +298,7 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(),
Tables\Columns\TextColumn::make('outcome')
->label('Outcome')
->label(__('localization.review.outcome'))
->badge()
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
@ -267,10 +309,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export')
->label(__('localization.review.export'))
->boolean(),
Tables\Columns\TextColumn::make('next_step')
->label('Next step')
->label(__('localization.review.next_step'))
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
->wrap(),
Tables\Columns\TextColumn::make('fingerprint')
@ -284,31 +326,18 @@ public static function table(Table $table): Table
->all()),
Tables\Filters\SelectFilter::make('completeness_state')
->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([
UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
fn (TenantReview $record): TenantReview => $record,
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(),
$exportExecutivePackAction,
])
->bulkActions([])
->emptyStateHeading('No tenant reviews yet')
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet'))
->emptyStateDescription(__('localization.review.create_first_review_description'))
->emptyStateActions([
static::makeCreateReviewAction(
name: 'create_first_review',
label: 'Create first review',
label: __('localization.review.create_first_review'),
icon: 'heroicon-o-plus',
),
]);
@ -327,19 +356,23 @@ public static function makeCreateReviewAction(
string $label = 'Create review',
string $icon = 'heroicon-o-plus',
): Actions\Action {
$label = $label === 'Create review'
? __('localization.review.create_review')
: $label;
return UiEnforcement::forAction(
Actions\Action::make($name)
->label($label)
->icon($icon)
->form([
Section::make('Evidence basis')
Section::make(__('localization.review.evidence_basis'))
->schema([
Select::make('evidence_snapshot_id')
->label('Evidence snapshot')
->label(__('localization.review.evidence_snapshot'))
->required()
->options(fn (): array => static::evidenceSnapshotOptions())
->searchable()
->helperText('Choose the anchored evidence snapshot for this review.'),
->helperText(__('localization.review.evidence_basis_helper')),
]),
])
->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -357,7 +390,7 @@ public static function executeCreateReview(array $data): void
$user = auth()->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;
}
@ -379,7 +412,7 @@ public static function executeCreateReview(array $data): void
: null;
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;
}
@ -387,7 +420,7 @@ public static function executeCreateReview(array $data): void
try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} 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;
}
@ -397,11 +430,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) {
Notification::make()
->success()
->title('Review already available')
->body('A matching mutable review already exists for this evidence basis.')
->title(__('localization.review.review_already_available'))
->body(__('localization.review.review_already_available_body'))
->actions([
Actions\Action::make('view_review')
->label('View review')
->label(__('localization.review.view_review'))
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
])
->send();
@ -410,12 +443,12 @@ public static function executeCreateReview(array $data): void
}
$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) {
$toast->actions([
Actions\Action::make('view_run')
->label('Open operation')
->label(__('localization.review.open_operation'))
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]);
}
@ -423,13 +456,71 @@ public static function executeCreateReview(array $data): void
$toast->send();
}
/**
* @return array<string, mixed>
*/
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
{
$tenant ??= Filament::getTenant();
if (! $tenant instanceof Tenant) {
return [];
}
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
}
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
{
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
}
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return null;
}
$reason = $decision['block_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
{
$decision = static::reviewPackGenerationDecision($tenant);
if (! (bool) ($decision['is_warning'] ?? false)) {
return null;
}
$reason = $decision['warning_reason'] ?? null;
return is_string($reason) && $reason !== '' ? $reason : null;
}
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{
$tenant ??= static::panelTenantContext();
$user = auth()->user();
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
return AuthUiTooltips::insufficientPermission();
}
return static::reviewPackGenerationBlockReason($tenant)
?? static::reviewPackGenerationWarningReason($tenant);
}
public static function executeExport(TenantReview $review): void
{
$review->loadMissing(['tenant', 'currentExportReviewPack']);
$user = auth()->user();
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;
}
@ -446,7 +537,7 @@ public static function executeExport(TenantReview $review): void
if ($service->checkActiveRunForReview($review)) {
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();
return;
@ -457,8 +548,12 @@ public static function executeExport(TenantReview $review): void
'include_pii' => true,
'include_operations' => true,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send();
return;
} 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;
}
@ -469,11 +564,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) {
Notification::make()
->success()
->title('Executive pack already available')
->body('A matching executive pack already exists for this review.')
->title(__('localization.review.executive_pack_already_available'))
->body(__('localization.review.executive_pack_already_available_body'))
->actions([
Actions\Action::make('view_pack')
->label('View pack')
->label(__('localization.review.view_pack'))
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
])
->send();
@ -482,7 +577,7 @@ public static function executeExport(TenantReview $review): void
}
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body('The executive pack is being generated in the background.')
->body(__('localization.review.executive_pack_generating_background'))
->send();
}
@ -522,7 +617,7 @@ private static function evidenceSnapshotOptions(): array
'#%d · %s · %s',
(int) $snapshot->getKey(),
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();
@ -546,7 +641,7 @@ private static function summaryPresentation(TenantReview $record): array
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) {
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.';
}
return [
@ -558,12 +653,12 @@ private static function summaryPresentation(TenantReview $record): array
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record),
'metrics' => [
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => '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.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
],
];
}
@ -577,28 +672,37 @@ private static function summaryContextLinks(TenantReview $record): array
if (is_numeric($record->operation_run_id)) {
$links[] = [
'title' => 'Operation',
'label' => 'Open operation',
'title' => __('localization.review.operation'),
'label' => __('localization.review.open_operation'),
'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) {
$links[] = [
'title' => 'Executive pack',
'label' => 'View executive pack',
'title' => __('localization.review.executive_pack'),
'label' => __('localization.review.view_executive_pack'),
'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) {
$links[] = [
'title' => __('localization.review.customer_workspace'),
'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => __('localization.review.customer_workspace_description'),
];
}
if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [
'title' => 'Evidence snapshot',
'label' => 'View evidence snapshot',
'title' => __('localization.review.evidence_snapshot'),
'label' => __('localization.review.view_evidence_snapshot'),
'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

@ -4,12 +4,15 @@
namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus;
@ -24,6 +27,13 @@ class ViewTenantReview extends ViewRecord
{
protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
protected function resolveRecord(int|string $key): Model
{
return TenantReviewResource::resolveScopedRecordOrFail($key);
@ -69,7 +79,7 @@ protected function getHeaderActions(): array
->label('Danger')
->icon('heroicon-o-archive-box')
->color('danger')
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
]));
}
@ -85,6 +95,10 @@ private function primaryLifecycleAction(): ?Actions\Action
private function primaryLifecycleActionName(): ?string
{
if ($this->isCustomerWorkspaceView()) {
return null;
}
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack';
}
@ -122,6 +136,10 @@ private function secondaryLifecycleActions(): array
*/
private function secondaryLifecycleActionNames(): array
{
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = [];
if ($this->record->isMutable()) {
@ -178,7 +196,6 @@ private function refreshReviewAction(): Actions\Action
}),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply();
}
@ -232,7 +249,7 @@ private function publishReviewAction(): Actions\Action
private function exportExecutivePackAction(): Actions\Action
{
return UiEnforcement::forAction(
$action = UiEnforcement::forAction(
Actions\Action::make('export_executive_pack')
->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray')
@ -241,11 +258,17 @@ private function exportExecutivePackAction(): Actions\Action
TenantReviewStatus::Ready->value,
TenantReviewStatus::Published->value,
], true))
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
)
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->preserveDisabled()
->apply();
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
return $action;
}
private function createNextReviewAction(): Actions\Action
@ -319,4 +342,39 @@ private function archiveReviewAction(): Actions\Action
->preserveVisibility()
->apply();
}
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
}

View File

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

View File

@ -9,12 +9,19 @@
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
use App\Support\OperationCatalog;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Collection;
@ -85,6 +92,85 @@ public function runsUrl(): string
return SystemOperationRunLinks::index();
}
/**
* @return array<string, mixed>
*/
public function workspaceEntitlementSummary(): array
{
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
}
/**
* @return array<string, mixed>
*/
public function workspaceCommercialLifecycleSummary(): array
{
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('change_commercial_state')
->label('Change commercial state')
->icon('heroicon-o-adjustments-horizontal')
->color('warning')
->visible(fn (): bool => $this->canManageCommercialLifecycle())
->requiresConfirmation()
->modalHeading('Change commercial state')
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
->form([
Select::make('state')
->label('Commercial state')
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
->required()
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
Textarea::make('reason')
->label('Rationale')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
])
->action(function (array $data, SettingsWriter $settingsWriter): void {
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
abort(403);
}
$settingsWriter->updateWorkspaceCommercialLifecycle(
actor: $actor,
workspace: $this->workspace,
state: (string) ($data['state'] ?? ''),
reason: (string) ($data['reason'] ?? ''),
);
$this->workspace->refresh();
Notification::make()
->title('Commercial state updated')
->success()
->send();
}),
];
}
private function canManageCommercialLifecycle(): bool
{
$user = auth('platform')->user();
return $user instanceof PlatformUser
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
}
/**
* @return array{
* overall: array{label: string, color: string, icon: string|null},

View File

@ -57,11 +57,6 @@ public static function canAccess(): bool
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
}
public function mount(): void
{
abort_unless(static::canAccess(), 403);
}
public function getHeader(): ?View
{
return view('filament.system.pages.ops.partials.controls-header', [
@ -80,6 +75,9 @@ protected function getHeaderActions(): array
$this->pauseRestoreExecuteAction(),
$this->resumeRestoreExecuteAction(),
$this->viewHistoryRestoreExecuteAction(),
$this->pauseAiExecutionAction(),
$this->resumeAiExecutionAction(),
$this->viewHistoryAiExecutionAction(),
];
}
@ -199,6 +197,21 @@ public function viewHistoryRestoreExecuteAction(): Action
return $this->historyActionFor('restore.execute');
}
public function pauseAiExecutionAction(): Action
{
return $this->pauseActionFor('ai.execution');
}
public function resumeAiExecutionAction(): Action
{
return $this->resumeActionFor('ai.execution');
}
public function viewHistoryAiExecutionAction(): Action
{
return $this->historyActionFor('ai.execution');
}
private function pauseActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
@ -213,7 +226,7 @@ private function pauseActionFor(string $controlKey): Action
->form($this->pauseFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data);
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
@ -273,7 +286,7 @@ private function resumeActionFor(string $controlKey): Action
->form($this->resumeFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
->notExpired()
@ -331,11 +344,8 @@ private function pauseFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->live()
->required(),
@ -395,11 +405,8 @@ private function resumeFormSchema(string $controlKey): array
return [
Radio::make('scope_type')
->label('Scope')
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->options($this->scopeOptions($controlKey))
->default($this->defaultScopeFor($controlKey))
->live()
->required(),
@ -456,9 +463,9 @@ private function controlsActor(): PlatformUser
/**
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
*/
private function normalizePauseInput(array $data): array
private function normalizePauseInput(string $controlKey, array $data): array
{
[$scopeType, $workspace] = $this->resolveScopeInput($data);
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
$reasonText = trim((string) ($data['reason_text'] ?? ''));
if ($reasonText === '') {
@ -485,19 +492,20 @@ private function normalizePauseInput(array $data): array
/**
* @return array{0: string, 1: ?Workspace}
*/
private function normalizeResumeInput(array $data): array
private function normalizeResumeInput(string $controlKey, array $data): array
{
return $this->resolveScopeInput($data);
return $this->resolveScopeInput($controlKey, $data);
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function resolveScopeInput(array $data): array
private function resolveScopeInput(string $controlKey, array $data): array
{
$scopeType = (string) ($data['scope_type'] ?? 'global');
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
if (! in_array($scopeType, ['global', 'workspace'], true)) {
if (! in_array($scopeType, $supportedScopes, true)) {
throw ValidationException::withMessages([
'scope_type' => 'Invalid scope selected.',
]);
@ -526,6 +534,26 @@ private function resolveScopeInput(array $data): array
return [$scopeType, $workspace];
}
/**
* @return array<string, string>
*/
private function scopeOptions(string $controlKey): array
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return Arr::only([
'global' => 'Global',
'workspace' => 'One workspace',
], $supportedScopes);
}
private function defaultScopeFor(string $controlKey): string
{
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
return $supportedScopes[0] ?? 'global';
}
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
{
$query = OperationalControlActivation::query()

View File

@ -4,26 +4,9 @@
namespace App\Filament\System\Pages\Ops;
use App\Models\OperationRun;
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\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 Illuminate\Validation\ValidationException;
class Runbooks extends Page
{
@ -37,53 +20,6 @@ class Runbooks extends Page
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
{
$user = auth('platform')->user();
@ -95,231 +31,4 @@ public static function canAccess(): bool
return $user->hasCapability(PlatformCapabilities::OPS_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

@ -4,6 +4,8 @@
namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\OperationRun;
use App\Models\ReviewPack;
use App\Models\Tenant;
@ -18,6 +20,7 @@
use App\Support\ReviewPackStatus;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class TenantReviewPackCard extends Widget
@ -66,6 +69,26 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
/** @var ReviewPackService $service */
$service = app(ReviewPackService::class);
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
if ((bool) ($decision['is_blocked'] ?? false)) {
Notification::make()
->title('Review pack generation unavailable')
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
->warning()
->send();
return;
}
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
Notification::make()
->title('Review pack generation allowed with warning')
->body($decision['warning_reason'])
->warning()
->send();
}
$activeRun = $service->checkActiveRun($tenant)
? OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
@ -90,10 +113,20 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return;
}
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
try {
$reviewPack = $service->generate($tenant, $user, [
'include_pii' => $includePii,
'include_operations' => $includeOperations,
]);
} catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()
->title('Review pack generation unavailable')
->body($exception->getMessage())
->warning()
->send();
return;
}
$runUrl = $reviewPack->operationRun
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
@ -130,6 +163,17 @@ protected function getViewData(): array
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$service = app(ReviewPackService::class);
$generationEntitlement = $canManage
? $service->reviewPackGenerationDecisionForTenant($tenant)
: null;
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason']
: null;
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
? $generationEntitlement['warning_reason']
: null;
$latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun'])
@ -146,6 +190,10 @@ protected function getViewData(): array
'pollingInterval' => null,
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null,
'failedReason' => null,
'reviewUrl' => null,
@ -194,6 +242,10 @@ protected function getViewData(): array
'pollingInterval' => self::resolvePollingInterval($latestPack),
'canView' => $canView,
'canManage' => $canManage,
'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
@ -224,6 +276,10 @@ private function emptyState(): array
'pollingInterval' => null,
'canView' => false,
'canManage' => false,
'generationBlocked' => false,
'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null,
'downloadUrl' => null,
'failedReason' => null,
'failedReasonDetail' => null,

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

@ -4,7 +4,12 @@
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPackStatus;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -15,6 +20,21 @@ class ReviewPackDownloadController extends Controller
{
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
{
$user = $request->user();
$tenant = $reviewPack->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
if (! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
abort(403);
}
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException;
}
@ -29,7 +49,26 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
throw new NotFoundHttpException;
}
$tenant = $reviewPack->tenant;
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::ReviewPackDownloaded,
context: [
'metadata' => [
'review_pack_id' => (int) $reviewPack->getKey(),
'tenant_review_id' => $reviewPack->tenant_review_id !== null
? (int) $reviewPack->tenant_review_id
: null,
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
],
],
actor: $user,
resourceType: 'review_pack',
resourceId: (string) $reviewPack->getKey(),
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
tenant: $tenant,
operationRunId: $reviewPack->operation_run_id,
);
$filename = sprintf(
'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown',

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 {
$this->observeFinding(
finding: $finding,
tenant: $tenant,
observedAt: $observedAt,
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) {
$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))) {
$finding->last_seen_at = $observedAt;
}
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} elseif ($timesSeen < 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_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress';
@ -169,10 +167,7 @@ public static function terminalStatuses(): array
*/
public static function openStatusesForQuery(): array
{
return [
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
return self::openStatuses();
}
/**
@ -295,10 +290,6 @@ public static function isReopenReason(?string $reason): bool
public static function canonicalizeStatus(?string $status): ?string
{
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status;
}
@ -324,23 +315,6 @@ public function isRiskAccepted(): bool
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
{
$this->forceFill([

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportRequest extends Model
{
use DerivesWorkspaceIdFromTenant;
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
use HasFactory;
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
public const string SEVERITY_LOW = 'low';
public const string SEVERITY_NORMAL = 'normal';
public const string SEVERITY_HIGH = 'high';
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 = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'context_envelope' => 'array',
];
}
/**
* @return array<string, string>
*/
public static function severityOptions(): array
{
return [
self::SEVERITY_LOW => 'Low',
self::SEVERITY_NORMAL => 'Normal',
self::SEVERITY_HIGH => 'High',
self::SEVERITY_BLOCKING => 'Blocking',
];
}
/**
* @return list<string>
*/
public static function severityValues(): array
{
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>
*/
public static function primaryContextTypes(): array
{
return [
self::PRIMARY_CONTEXT_TENANT,
self::PRIMARY_CONTEXT_OPERATION_RUN,
];
}
/**
* @return list<string>
*/
public static function attachmentModes(): array
{
return [
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<OperationRun, $this>
*/
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
}

View File

@ -39,6 +39,7 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
'password',
'entra_tenant_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
{
return $this->canMutateWithAnyCapability($user, $finding, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE);
}
public function assign(User $user, Finding $finding): Response|bool

View File

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

View File

@ -42,6 +42,14 @@ public function panel(Panel $panel): Panel
PanelsRenderHook::BODY_START,
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')
->pages([
Dashboard::class,
@ -59,6 +67,7 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->middleware(['apply-resolved-locale:system'], isPersistent: true)
->authMiddleware([
Authenticate::class,
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,

View File

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

View File

@ -4,9 +4,10 @@
namespace App\Services\Audit;
use App\Models\Tenant;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
@ -14,6 +15,7 @@
use App\Support\Audit\AuditActorType;
use App\Support\Audit\AuditTargetSnapshot;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
class WorkspaceAuditLogger
{
@ -136,4 +138,159 @@ public function logSupportDiagnosticsOpened(
tenant: $tenant,
);
}
/**
* @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(
SupportRequest $supportRequest,
User|PlatformUser|null $actor = null,
): \App\Models\AuditLog {
$supportRequest->loadMissing(['tenant.workspace']);
$tenant = $supportRequest->tenant;
if (! $tenant instanceof Tenant) {
throw new InvalidArgumentException('Support requests must belong to a tenant.');
}
return $this->log(
workspace: $tenant->workspace,
action: AuditActionId::SupportRequestCreated,
context: [
'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(),
'attachment_mode' => $supportRequest->attachment_mode,
'redaction_mode' => (string) data_get($supportRequest->context_envelope, 'redaction_mode', 'default_redacted'),
],
actor: $actor,
status: 'success',
resourceType: 'support_request',
resourceId: (string) $supportRequest->getKey(),
targetLabel: $supportRequest->internal_reference,
summary: 'Support request created for '.$supportRequest->internal_reference,
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
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

@ -20,6 +20,7 @@ class RoleCapabilityMap
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
@ -27,7 +28,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -65,6 +65,7 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
@ -72,7 +73,6 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -106,10 +106,10 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::SUPPORT_REQUESTS_CREATE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,

View File

@ -0,0 +1,410 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
final class WorkspaceCommercialLifecycleResolver
{
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
public const STATE_TRIAL = 'trial';
public const STATE_GRACE = 'grace';
public const STATE_ACTIVE_PAID = 'active_paid';
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
public const ACTION_EVIDENCE_READ = 'evidence_read';
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
public const OUTCOME_ALLOW = 'allow';
public const OUTCOME_WARN = 'warn';
public const OUTCOME_BLOCK = 'block';
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
) {}
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_GRACE,
self::STATE_ACTIVE_PAID,
self::STATE_SUSPENDED_READ_ONLY,
];
}
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
self::STATE_TRIAL => 'Trial',
self::STATE_GRACE => 'Grace',
self::STATE_ACTIVE_PAID => 'Active paid',
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
];
}
/**
* @return array<string, string>
*/
public static function stateDescriptions(): array
{
return [
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
];
}
/**
* @return array<string, mixed>
*/
public function summary(Workspace $workspace): array
{
$lifecycle = $this->resolve($workspace);
return $lifecycle + [
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
'action_decisions' => [
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
],
];
}
/**
* @return array{
* workspace_id: int,
* state: string,
* state_label: string,
* source: string,
* source_label: string,
* rationale: string|null,
* description: string,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$state = in_array($rawState, self::stateIds(), true)
? $rawState
: self::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? self::SOURCE_WORKSPACE_SETTING
: self::SOURCE_DEFAULT_ACTIVE_PAID;
$rationale = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
return [
'workspace_id' => (int) $workspace->getKey(),
'state' => $state,
'state_label' => $labels[$state],
'source' => $source,
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
? 'workspace setting'
: 'default active paid',
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
'description' => $descriptions[$state],
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array<string, mixed>|null $lifecycle
* @return array<string, mixed>
*/
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
{
$lifecycle ??= $this->resolve($workspace);
return match ($actionKey) {
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ,
self::ACTION_EVIDENCE_READ,
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
};
}
/**
* @return array<string, mixed>
*/
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_WARN,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function readOnlyDecision(string $actionKey, array $lifecycle): array
{
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW_READ_ONLY,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
substrateDecision: null,
);
}
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
* @return array<string, mixed>
*/
private function decision(
array $lifecycle,
string $actionKey,
string $outcome,
?string $reasonFamily,
string $message,
?array $substrateDecision,
): array {
return [
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
'action_key' => $actionKey,
'outcome' => $outcome,
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
'is_warning' => $outcome === self::OUTCOME_WARN,
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
'message' => $message,
'reason_family' => $reasonFamily,
'state' => (string) $lifecycle['state'],
'state_label' => (string) $lifecycle['state_label'],
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'rationale' => $lifecycle['rationale'] ?? null,
'entitlement_decision' => $substrateDecision,
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_type', 'workspace_setting')
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('recorded_at')
->latest('id')
->first();
if ($audit instanceof AuditLog) {
return [
'last_changed_at' => $audit->recorded_at,
'last_changed_by' => $audit->actorDisplayLabel(),
];
}
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
])
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
}

View File

@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use Carbon\CarbonInterface;
final class WorkspaceEntitlementResolver
{
public const SETTING_DOMAIN = 'entitlements';
public const SETTING_PLAN_PROFILE = 'plan_profile';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspacePlanProfileCatalog $planProfileCatalog,
) {}
/**
* @return array{
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
* decisions: array<string, array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }>
* }
*/
public function summary(Workspace $workspace): array
{
$planProfile = $this->resolvePlanProfile($workspace);
return [
'plan_profile' => $planProfile,
'decisions' => [
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
],
];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolvePlanProfile(Workspace $workspace): array
{
$planProfileId = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_PLAN_PROFILE,
);
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int|bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int|null,
* remaining_capacity: int|null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
{
$planProfile ??= $this->resolvePlanProfile($workspace);
$lastChanged = $this->lastChangedMetadata($workspace);
return match ($key) {
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
};
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: int,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: int,
* remaining_capacity: int,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
);
$effectiveValue = is_int($overrideValue['value'])
? $overrideValue['value']
: (int) $planProfile['managed_tenant_limit_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$currentUsage = Tenant::activeQuery()
->where('workspace_id', (int) $workspace->getKey())
->count();
$remainingCapacity = $effectiveValue - $currentUsage;
$isBlocked = $currentUsage >= $effectiveValue;
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => $currentUsage,
'remaining_capacity' => $remainingCapacity,
'is_blocked' => $isBlocked,
'block_reason' => $isBlocked
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
: null,
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
* @return array{
* workspace_id: int,
* plan_profile_id: string,
* plan_profile_label: string,
* plan_profile_description: string,
* key: string,
* effective_value: bool,
* source: 'plan_profile_default'|'workspace_override',
* rationale: string|null,
* current_usage: null,
* remaining_capacity: null,
* is_blocked: bool,
* block_reason: string|null,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
{
$overrideValue = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
);
$overrideReason = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
);
$effectiveValue = is_bool($overrideValue['value'])
? $overrideValue['value']
: (bool) $planProfile['review_pack_generation_default'];
$source = $overrideValue['source'] === 'workspace_override'
? 'workspace_override'
: 'plan_profile_default';
$rationale = $source === 'workspace_override'
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
: (string) $planProfile['description'];
return [
'workspace_id' => (int) $workspace->getKey(),
'plan_profile_id' => (string) $planProfile['id'],
'plan_profile_label' => (string) $planProfile['label'],
'plan_profile_description' => (string) $planProfile['description'],
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
'effective_value' => $effectiveValue,
'source' => $source,
'rationale' => $rationale,
'current_usage' => null,
'remaining_capacity' => null,
'is_blocked' => ! $effectiveValue,
'block_reason' => $effectiveValue
? null
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_PLAN_PROFILE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
])
->whereNotNull('updated_by_user_id')
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
{
$prefix = $source === 'workspace_override'
? 'This workspace override currently allows'
: sprintf('The %s plan profile currently allows', $planProfile['label']);
$message = sprintf(
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
$prefix,
$effectiveValue,
$effectiveValue === 1 ? '' : 's',
$currentUsage,
$currentUsage === 1 ? '' : 's',
);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
/**
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
*/
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
{
$message = $source === 'workspace_override'
? 'Review pack generation is disabled by workspace override.'
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
if ($source === 'workspace_override' && $rationale !== null) {
$message .= ' Reason: '.$rationale;
}
return $message;
}
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
final class WorkspacePlanProfileCatalog
{
/**
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
private const PROFILES = [
'starter' => [
'id' => 'starter',
'label' => 'Starter',
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
'managed_tenant_limit_default' => 1,
'review_pack_generation_default' => false,
'is_default' => false,
],
'standard' => [
'id' => 'standard',
'label' => 'Standard',
'description' => 'Balanced defaults for most managed workspaces.',
'managed_tenant_limit_default' => 25,
'review_pack_generation_default' => true,
'is_default' => true,
],
'scale' => [
'id' => 'scale',
'label' => 'Scale',
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
'managed_tenant_limit_default' => 100,
'review_pack_generation_default' => true,
'is_default' => false,
],
];
/**
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
*/
public function all(): array
{
return array_values(self::PROFILES);
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function default(): array
{
return self::PROFILES[self::defaultProfileId()];
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
*/
public function find(?string $id): ?array
{
if ($id === null) {
return null;
}
return self::PROFILES[$id] ?? null;
}
/**
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
*/
public function resolve(?string $id): array
{
return $this->find($id) ?? $this->default();
}
/**
* @return array<string, string>
*/
public function optionLabels(): array
{
return array_map(
static fn (array $profile): string => $profile['label'],
self::PROFILES,
);
}
/**
* @return list<string>
*/
public static function profileIds(): array
{
return array_keys(self::PROFILES);
}
public static function defaultProfileId(): string
{
foreach (self::PROFILES as $id => $profile) {
if ($profile['is_default']) {
return $id;
}
}
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
}
}

View File

@ -163,7 +163,7 @@ private function upsertFinding(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -253,7 +253,7 @@ private function handleGaAggregate(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
$existing->forceFill([
'severity' => Finding::SEVERITY_HIGH,
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
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) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
$slaPolicy = $this->resolveSlaPolicy();
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
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
$currentStatus = (string) $finding->status;
if (! in_array($currentStatus, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) {
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
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
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.');
}
@ -369,10 +362,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{
$this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]);
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
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();
if ($finding instanceof Finding) {
$this->observeFinding($finding, $observedAt);
$this->observeFinding($finding, $tenant, $observedAt, $severity);
$finding->forceFill([
'severity' => $severity,
@ -216,7 +216,7 @@ private function handleErrorPermission(
->first();
if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt);
$this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([
'severity' => $severity,
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
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) {
$finding->first_seen_at = $observedAt;
}
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1;
return;
} elseif ($timesSeen < 1) {
$finding->times_seen = 1;
}
if ($timesSeen < 1) {
$finding->times_seen = 1;
if ($finding->sla_days === null) {
$finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
}

View File

@ -4,6 +4,7 @@
namespace App\Services;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot;
@ -13,6 +14,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId;
@ -28,6 +30,7 @@ public function __construct(
private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger,
private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {}
@ -49,6 +52,8 @@ public function __construct(
*/
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
{
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$snapshot = $this->resolveSnapshot($tenant);
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
@ -138,6 +143,8 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
}
$this->assertReviewPackGenerationAllowed($tenant);
$options = $this->normalizeOptions($options);
$fingerprint = $this->computeFingerprintForReview($review, $options);
$existing = $this->findExistingPackForReview($review, $fingerprint);
@ -227,18 +234,43 @@ public function computeFingerprint(Tenant $tenant, array $options): string
/**
* Generate a signed download URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/
public function generateDownloadUrl(ReviewPack $pack): string
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
{
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute(
'admin.review-packs.download',
['reviewPack' => $pack->getKey()],
array_merge(['reviewPack' => $pack->getKey()], $parameters),
now()->addMinutes($ttlMinutes),
);
}
/**
* @return array<string, mixed>
*/
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
);
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
? $decision['entitlement_decision']
: [];
return $decision + [
'effective_value' => $entitlementDecision['effective_value'] ?? null,
'source' => $decision['source'] ?? null,
'current_usage' => $entitlementDecision['current_usage'] ?? null,
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
];
}
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
{
$this->productTelemetryRecorder->record(
@ -314,6 +346,17 @@ private function normalizeOptions(array $options): array
];
}
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
{
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
if (! (bool) ($decision['is_blocked'] ?? false)) {
return;
}
throw new WorkspaceEntitlementBlockedException($decision);
}
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
{
$data = [

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

@ -4,6 +4,7 @@
namespace App\Services\Settings;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\TenantSetting;
use App\Models\User;
@ -11,11 +12,14 @@
use App\Models\WorkspaceSetting;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{
$this->authorizeManage($actor, $workspace);
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => (int) $actor->getKey(),
]);
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
$this->resolver->clearCache();
@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace',
'domain' => $domain,
'key' => $key,
'before_value' => $beforeValue,
'before_value' => $result['before_value'],
'after_value' => $afterValue,
],
],
@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key,
);
return $setting;
return $result['setting'];
}
public function updateWorkspaceCommercialLifecycle(
PlatformUser $actor,
Workspace $workspace,
string $state,
string $reason,
): void {
$state = strtolower(trim($state));
$reason = trim($reason);
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
}
if ($reason === '') {
throw ValidationException::withMessages([
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
]);
}
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
$stateResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
value: $state,
updatedByUserId: null,
);
$reasonResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
value: $reason,
updatedByUserId: null,
);
$this->resolver->clearCache();
$afterState = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$afterReason = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSettingUpdated->value,
context: [
'metadata' => [
'scope' => 'workspace',
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
'before_state' => $stateResult['before_value'],
'after_state' => $afterState,
'before_reason' => $reasonResult['before_value'],
'after_reason' => $afterReason,
],
],
actor: $actor,
resourceType: 'workspace_setting',
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
targetLabel: 'Commercial lifecycle state',
);
});
}
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
@ -174,6 +230,39 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
]);
}
/**
* @return array{setting: WorkspaceSetting, before_value: mixed}
*/
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
{
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => $updatedByUserId,
]);
return [
'setting' => $setting,
'before_value' => $beforeValue,
];
}
private function validatedValue(SettingDefinition $definition, mixed $value): mixed
{
$validator = Validator::make(

View File

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

View File

@ -12,6 +12,7 @@
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
final class TenantReviewRegisterService
{
@ -43,6 +44,55 @@ public function query(User $user, Workspace $workspace): Builder
->latest('id');
}
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
$rankedReviews = TenantReview::query()
->select([
'tenant_reviews.id',
'tenant_reviews.tenant_id',
'tenant_reviews.published_at',
'tenant_reviews.generated_at',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->published();
$latestPublishedIds = DB::query()
->fromSub($rankedReviews, 'ranked_tenant_reviews')
->where('rn', 1)
->select('id');
return TenantReview::query()
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_reviews.id', $latestPublishedIds)
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id');
}
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
return Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
->with([
'tenantReviews' => fn ($query) => $query
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->published()
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id')
->limit(1),
])
->orderBy('name');
}
public function canAccessWorkspace(User $user, Workspace $workspace): bool
{
return WorkspaceMembership::query()

View File

@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
{
$summary = $this->summary($findingsItem);
$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')) {
'critical' => 4,
'high' => 3,

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDataClassification: string
{
case ProductKnowledge = 'product_knowledge';
case OperationalMetadata = 'operational_metadata';
case RedactedSupportSummary = 'redacted_support_summary';
case PersonalData = 'personal_data';
case CustomerConfidential = 'customer_confidential';
case RawProviderPayload = 'raw_provider_payload';
public function label(): string
{
return match ($this) {
self::ProductKnowledge => 'Product knowledge',
self::OperationalMetadata => 'Operational metadata',
self::RedactedSupportSummary => 'Redacted support summary',
self::PersonalData => 'Personal data',
self::CustomerConfidential => 'Customer confidential',
self::RawProviderPayload => 'Raw provider payload',
};
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiDecisionAuditMetadataFactory
{
/**
* @return array<string, mixed>
*/
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
{
return array_filter([
'use_case_key' => $decision->useCaseKey,
'decision_outcome' => $decision->outcome,
'decision_reason' => $decision->reasonCode->value,
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
'requested_provider_class' => $decision->requestedProviderClass,
'data_classifications' => $decision->dataClassifications,
'source_family' => $decision->sourceFamily,
'workspace_id' => $request->workspace?->getKey(),
'tenant_id' => $request->tenant?->getKey(),
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
], static fn (mixed $value): bool => $value !== null);
}
private function normalizedFingerprint(?string $contextFingerprint): ?string
{
if (! is_string($contextFingerprint)) {
return null;
}
$normalized = trim($contextFingerprint);
return $normalized === '' ? null : $normalized;
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiDecisionReasonCode: string
{
case Allowed = 'allowed';
case MissingWorkspaceContext = 'missing_workspace_context';
case TenantOutsideWorkspace = 'tenant_outside_workspace';
case OperationalControlPaused = 'operational_control_paused';
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
case UnregisteredUseCase = 'unregistered_use_case';
case ProviderClassBlocked = 'provider_class_blocked';
case DataClassificationBlocked = 'data_classification_blocked';
case SourceFamilyMismatch = 'source_family_mismatch';
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Support\Audit\AuditActionId;
final readonly class AiExecutionDecision
{
/**
* @param list<string> $dataClassifications
* @param array<string, mixed> $auditMetadata
*/
public function __construct(
public string $outcome,
public AiDecisionReasonCode $reasonCode,
public string $workspaceAiPolicyMode,
public ?string $matchedOperationalControlScope,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public AuditActionId $auditAction,
public array $auditMetadata,
) {}
public function isAllowed(): bool
{
return $this->outcome === 'allowed';
}
public function isBlocked(): bool
{
return $this->outcome === 'blocked';
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
final readonly class AiExecutionRequest
{
/**
* @param list<string> $dataClassifications
*/
public function __construct(
public ?Workspace $workspace,
public ?Tenant $tenant,
public User|PlatformUser|null $actor,
public string $useCaseKey,
public string $requestedProviderClass,
public array $dataClassifications,
public string $sourceFamily,
public ?string $callerSurface = null,
public ?string $contextFingerprint = null,
) {}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiPolicyMode: string
{
case Disabled = 'disabled';
case PrivateOnly = 'private_only';
public function label(): string
{
return match ($this) {
self::Disabled => 'Disabled',
self::PrivateOnly => 'Private only',
};
}
public function summary(): string
{
return match ($this) {
self::Disabled => 'No AI execution is allowed for this workspace.',
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
};
}
/**
* @return array<string, string>
*/
public static function optionLabels(): array
{
return array_reduce(
self::cases(),
static function (array $labels, self $mode): array {
$labels[$mode->value] = $mode->label();
return $labels;
},
[],
);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
enum AiProviderClass: string
{
case LocalPrivate = 'local_private';
case ExternalPublic = 'external_public';
public function label(): string
{
return match ($this) {
self::LocalPrivate => 'Local private',
self::ExternalPublic => 'External public',
};
}
}

View File

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
final class AiUseCaseCatalog
{
/**
* @var array<string, array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
private const USE_CASES = [
'product_knowledge.answer_draft' => [
'key' => 'product_knowledge.answer_draft',
'label' => 'Product knowledge answer draft',
'future_consumer' => 'ContextualHelpResolver',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'source_family' => 'product_knowledge',
'tenant_context_permitted' => false,
],
'support_diagnostics.summary_draft' => [
'key' => 'support_diagnostics.summary_draft',
'label' => 'Support diagnostics summary draft',
'future_consumer' => 'SupportDiagnosticBundleBuilder',
'visibility' => 'internal_only_draft',
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
'source_family' => 'support_diagnostics',
'tenant_context_permitted' => true,
],
];
/**
* @return list<array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }>
*/
public function all(): array
{
return array_values(self::USE_CASES);
}
/**
* @return array{
* key: string,
* label: string,
* future_consumer: string,
* visibility: string,
* allowed_provider_classes: list<string>,
* allowed_data_classifications: list<string>,
* source_family: string,
* tenant_context_permitted: bool
* }|null
*/
public function find(string $key): ?array
{
return self::USE_CASES[$key] ?? null;
}
/**
* @return list<string>
*/
public function labels(): array
{
return array_map(
static fn (array $definition): string => $definition['label'],
$this->all(),
);
}
/**
* @return list<string>
*/
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
{
if ($mode === AiPolicyMode::Disabled) {
return [];
}
$labels = [];
foreach ($this->all() as $definition) {
foreach ($definition['allowed_provider_classes'] as $providerClass) {
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
}
}
return array_values($labels);
}
/**
* @return list<string>
*/
public function blockedDataClassificationLabels(): array
{
return array_map(
static fn (AiDataClassification $classification): string => $classification->label(),
[
AiDataClassification::PersonalData,
AiDataClassification::CustomerConfidential,
AiDataClassification::RawProviderPayload,
],
);
}
}

View File

@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Support\Ai;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use App\Support\OperationalControls\OperationalControlEvaluator;
final class GovernedAiExecutionBoundary
{
public function __construct(
private readonly AiUseCaseCatalog $useCaseCatalog,
private readonly SettingsResolver $settingsResolver,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
{
$decision = $this->decisionFor($request);
$metadata = $this->auditMetadataFactory->make($request, $decision);
$decision = new AiExecutionDecision(
outcome: $decision->outcome,
reasonCode: $decision->reasonCode,
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
useCaseKey: $decision->useCaseKey,
requestedProviderClass: $decision->requestedProviderClass,
dataClassifications: $decision->dataClassifications,
sourceFamily: $decision->sourceFamily,
auditAction: $decision->auditAction,
auditMetadata: $metadata,
);
if ($request->workspace !== null) {
$definition = $this->useCaseCatalog->find($request->useCaseKey);
$this->workspaceAuditLogger->log(
workspace: $request->workspace,
action: $decision->auditAction,
context: ['metadata' => $decision->auditMetadata],
actor: $request->actor,
status: $decision->isAllowed() ? 'success' : 'blocked',
resourceType: 'ai_use_case',
resourceId: $request->useCaseKey,
targetLabel: $definition['label'] ?? $request->useCaseKey,
summary: 'AI execution decision evaluated',
tenant: $request->tenant,
);
}
return $decision;
}
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
{
if ($request->workspace === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
);
}
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
if ($controlDecision->isPaused()) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
matchedOperationalControlScope: $controlDecision->matchedScopeType,
);
}
$policyMode = $this->resolvedPolicyMode($request);
if ($policyMode === AiPolicyMode::Disabled->value) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
workspaceAiPolicyMode: $policyMode,
);
}
$definition = $this->useCaseCatalog->find($request->useCaseKey);
if ($definition === null) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
workspaceAiPolicyMode: $policyMode,
);
}
if ($definition['source_family'] !== $request->sourceFamily) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
workspaceAiPolicyMode: $policyMode,
);
}
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
foreach ($request->dataClassifications as $classification) {
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
return $this->blockedDecision(
request: $request,
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
workspaceAiPolicyMode: $policyMode,
);
}
}
return new AiExecutionDecision(
outcome: 'allowed',
reasonCode: AiDecisionReasonCode::Allowed,
workspaceAiPolicyMode: $policyMode,
matchedOperationalControlScope: null,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
private function resolvedPolicyMode(AiExecutionRequest $request): string
{
if ($request->workspace === null) {
return AiPolicyMode::Disabled->value;
}
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
return is_string($resolved) && $resolved !== ''
? $resolved
: AiPolicyMode::Disabled->value;
}
private function blockedDecision(
AiExecutionRequest $request,
AiDecisionReasonCode $reasonCode,
string $workspaceAiPolicyMode,
?string $matchedOperationalControlScope = null,
): AiExecutionDecision {
return new AiExecutionDecision(
outcome: 'blocked',
reasonCode: $reasonCode,
workspaceAiPolicyMode: $workspaceAiPolicyMode,
matchedOperationalControlScope: $matchedOperationalControlScope,
useCaseKey: $request->useCaseKey,
requestedProviderClass: $request->requestedProviderClass,
dataClassifications: $request->dataClassifications,
sourceFamily: $request->sourceFamily,
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
auditMetadata: [],
);
}
}

View File

@ -69,6 +69,7 @@ enum AuditActionId: string
case BaselineCompareStarted = 'baseline_compare.started';
case BaselineCompareCompleted = 'baseline_compare.completed';
case BaselineCompareFailed = 'baseline_compare.failed';
case CrossTenantPromotionPreflightGenerated = 'cross_tenant_promotion_preflight.generated';
case BaselineAssignmentCreated = 'baseline_assignment.created';
case BaselineAssignmentUpdated = 'baseline_assignment.updated';
case BaselineAssignmentDeleted = 'baseline_assignment.deleted';
@ -94,12 +95,19 @@ enum AuditActionId: string
case TenantReviewRefreshed = 'tenant_review.refreshed';
case TenantReviewPublished = 'tenant_review.published';
case TenantReviewArchived = 'tenant_review.archived';
case TenantReviewOpened = 'tenant_review.opened';
case TenantReviewExported = 'tenant_review.exported';
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
case ReviewPackDownloaded = 'review_pack.downloaded';
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
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 OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
case OperationalControlResumed = 'operational_control.resumed';
@ -211,6 +219,7 @@ private static function labels(): array
self::BaselineCompareStarted->value => 'Baseline compare started',
self::BaselineCompareCompleted->value => 'Baseline compare completed',
self::BaselineCompareFailed->value => 'Baseline compare failed',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::BaselineAssignmentCreated->value => 'Baseline assignment created',
self::BaselineAssignmentUpdated->value => 'Baseline assignment updated',
self::BaselineAssignmentDeleted->value => 'Baseline assignment deleted',
@ -236,11 +245,18 @@ private static function labels(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
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::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',
@ -298,6 +314,7 @@ private static function summaries(): array
self::BaselineProfileUpdated->value => 'Baseline profile updated',
self::BaselineProfileArchived->value => 'Baseline profile archived',
self::BaselineProfileScopeBackfilled->value => 'Baseline profile scope backfilled',
self::CrossTenantPromotionPreflightGenerated->value => 'Cross-tenant promotion preflight generated',
self::AlertDestinationCreated->value => 'Alert destination created',
self::AlertDestinationUpdated->value => 'Alert destination updated',
self::AlertDestinationDeleted->value => 'Alert destination deleted',
@ -324,9 +341,16 @@ private static function summaries(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
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::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',

View File

@ -72,6 +72,9 @@ class Capabilities
// Support diagnostics
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
// Support requests
public const SUPPORT_REQUESTS_CREATE = 'support_requests.create';
// Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
@ -88,8 +91,6 @@ class Capabilities
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_MANAGE = 'finding_exception.manage';

View File

@ -18,6 +18,8 @@ class PlatformCapabilities
public const DIRECTORY_VIEW = 'platform.directory.view';
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
public const OPERATIONS_VIEW = 'platform.operations.view';
public const OPERATIONS_MANAGE = 'platform.operations.manage';
@ -28,8 +30,6 @@ class PlatformCapabilities
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';
/**

View File

@ -57,6 +57,7 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,

View File

@ -48,6 +48,7 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status';

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class CommercialLifecycleStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -796,7 +796,6 @@ private static function findingAttentionCounts(Tenant $tenant): array
$activeNonNewFindingsCount = Finding::query()
->where('tenant_id', $tenantId)
->whereIn('status', [
Finding::STATUS_ACKNOWLEDGED,
Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS,
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;
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
$tenant = Tenant::query()->find($tenantId);
$tenant = Tenant::query()->withTrashed()->find($tenantId);
}
if (! $tenant instanceof Tenant) {

View File

@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array
/**
* @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_TRIAGED,
Finding::STATUS_IN_PROGRESS,
@ -114,21 +114,6 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
Finding::STATUS_CLOSED,
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();
}
private static function legacyFindingAcknowledgedLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)';
}
private static function platformLabel(string $platform): string
{
return match (Str::of($platform)

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,6 @@ final class TrustedStatePolicy
public const TENANT_REQUIRED_PERMISSIONS = 'tenant_required_permissions';
public const SYSTEM_RUNBOOKS = 'system_runbooks';
/**
* @return array{
* name: string,
@ -329,92 +327,6 @@ public function firstSlice(): array
'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;
use App\Filament\Pages\BaselineCompareMatrix;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\BaselineProfile;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
final readonly class CanonicalNavigationContext
@ -18,6 +20,7 @@ public function __construct(
public string $sourceSurface,
public string $canonicalRouteName,
public ?int $tenantId = null,
public ?string $familyKey = null,
public ?string $backLinkLabel = null,
public ?string $backLinkUrl = null,
public array $filterPayload = [],
@ -56,12 +59,42 @@ public static function fromRequest(Request $request): ?self
sourceSurface: $sourceSurface,
canonicalRouteName: $canonicalRouteName,
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,
backLinkUrl: is_string($payload['back_url'] ?? null) ? (string) $payload['back_url'] : null,
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>
*/
@ -117,6 +150,7 @@ private function navPayload(): array
'source_surface' => $this->sourceSurface,
'canonical_route_name' => $this->canonicalRouteName,
'tenant_id' => $this->tenantId,
'family_key' => $this->familyKey,
'back_label' => $this->backLinkLabel,
'back_url' => $this->backLinkUrl,
], 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.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),
'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 [
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.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.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.export', 'policy.export', '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('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('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('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.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.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.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.purge', 'backup.schedule.purge', 'canonical', true),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
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('restore_run.delete', 'restore_run.delete', '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_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('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('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.compose', 'tenant.review.compose', '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('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
];
}
}

View File

@ -17,6 +17,13 @@ final class OperationalControlCatalog
'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'],
],
'ai.execution' => [
'key' => 'ai.execution',
'label' => 'AI execution',
'supported_scopes' => ['global'],
'operation_types' => ['ai.execution'],
'affected_surfaces' => ['governed_ai.execution'],
],
];
/**

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

@ -6,6 +6,7 @@
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Ai\AiDataClassification;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
@ -147,6 +148,43 @@ public function knowledgeSource(): array
return $this->catalog->knowledgeSource();
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* operational_metadata: array{version: int, topic_count: int},
* topics: list<array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* glossary_terms: list<string>,
* docs_links: list<array{label: string, kind: string, url: ?string, resolver: ?string}>
* }>
* }
*/
public function aiProductKnowledgeAnswerDraftSource(): array
{
$source = $this->knowledgeSource();
return [
'use_case_key' => 'product_knowledge.answer_draft',
'source_family' => 'product_knowledge',
'data_classifications' => [
AiDataClassification::ProductKnowledge->value,
AiDataClassification::OperationalMetadata->value,
],
'operational_metadata' => [
'version' => (int) $source['version'],
'topic_count' => (int) $source['topic_count'],
],
'topics' => $source['topics'],
];
}
/**
* @param array<string, mixed>|null $verificationReport
*/

View File

@ -5,6 +5,10 @@
namespace App\Support\Settings;
use App\Models\Finding;
use App\Services\Localization\LocaleResolver;
use App\Support\Ai\AiPolicyMode;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry
{
@ -17,6 +21,34 @@ public function __construct()
{
$this->definitions = [];
$this->register(new SettingDefinition(
domain: 'ai',
key: 'policy_mode',
type: 'string',
systemDefault: AiPolicyMode::Disabled->value,
rules: ['required', 'string', 'in:disabled,private_only'],
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(
domain: 'backup',
key: 'retention_keep_last_default',
@ -218,6 +250,129 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
rules: ['required', 'integer', 'min:0', 'max:10080'],
normalizer: static fn (mixed $value): int => (int) $value,
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'plan_profile',
type: 'string',
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_value',
type: 'int',
systemDefault: null,
rules: ['nullable', 'integer', 'min:0'],
normalizer: static function (mixed $value): ?int {
if ($value === null || $value === '') {
return null;
}
return (int) $value;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'managed_tenant_limit_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_value',
type: 'bool',
systemDefault: null,
rules: ['nullable', 'boolean'],
normalizer: static function (mixed $value): ?bool {
if ($value === null || $value === '') {
return null;
}
return filter_var($value, FILTER_VALIDATE_BOOL);
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: 'review_pack_generation_override_reason',
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = strtolower(trim((string) $value));
return $normalized === '' ? null : $normalized;
},
));
$this->register(new SettingDefinition(
domain: 'entitlements',
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
type: 'string',
systemDefault: null,
rules: ['nullable', 'string', 'max:500'],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
$normalized = trim((string) $value);
return $normalized === '' ? null : $normalized;
},
));
}
/**

View File

@ -19,6 +19,7 @@
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Ai\AiDataClassification;
use App\Support\Navigation\RelatedNavigationResolver;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
@ -133,6 +134,39 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
);
}
/**
* @return array{
* use_case_key: string,
* source_family: string,
* data_classifications: list<string>,
* summary: array{
* headline: string,
* dominant_issue: string,
* freshness_state: string,
* completeness_note: ?string,
* redaction_note: string,
* generated_from: string
* },
* redaction: array{mode: string, markers: list<string>},
* notes: list<string>
* }
*/
public function aiSupportDiagnosticsSummaryDraftSource(Tenant $tenant, ?User $actor = null): array
{
$bundle = $this->forTenant($tenant, $actor);
return [
'use_case_key' => 'support_diagnostics.summary_draft',
'source_family' => 'support_diagnostics',
'data_classifications' => [
AiDataClassification::RedactedSupportSummary->value,
],
'summary' => $bundle['summary'],
'redaction' => $bundle['redaction'],
'notes' => $bundle['notes'],
];
}
/**
* @param list<array<string, mixed>> $sections
* @return array<string, mixed>

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

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
final class SupportRequestContextBuilder
{
public const ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
public const ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
public function __construct(
private readonly SupportDiagnosticBundleBuilder $supportDiagnosticBundleBuilder,
) {}
/**
* @return array<string, mixed>
*/
public function forTenant(Tenant $tenant, User $actor, bool $attachDiagnosticSnapshot): array
{
return $this->buildEnvelope(
bundle: $this->supportDiagnosticBundleBuilder->forTenant($tenant, $actor),
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
);
}
/**
* @return array<string, mixed>
*/
public function forOperationRun(OperationRun $run, User $actor, bool $attachDiagnosticSnapshot): array
{
return $this->buildEnvelope(
bundle: $this->supportDiagnosticBundleBuilder->forOperationRun($run, $actor),
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
);
}
/**
* @param array<string, mixed> $bundle
* @return array<string, mixed>
*/
private function buildEnvelope(array $bundle, bool $attachDiagnosticSnapshot): array
{
$attachmentMode = $attachDiagnosticSnapshot
? self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED
: self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY;
return [
'schema_version' => 1,
'generated_from' => 'support_diagnostics_bundle',
'attachment_mode' => $attachmentMode,
'redaction_mode' => (string) data_get($bundle, 'redaction.mode', 'default_redacted'),
'primary_context' => [
'type' => (string) data_get($bundle, 'context.type'),
'workspace_id' => data_get($bundle, 'context.workspace_id'),
'tenant_id' => data_get($bundle, 'context.tenant_id'),
'operation_run_id' => data_get($bundle, 'context.operation_run_id'),
'workspace_label' => data_get($bundle, 'context.workspace_label'),
'tenant_label' => data_get($bundle, 'context.tenant_label'),
],
'canonical_context' => [
'headline' => (string) data_get($bundle, 'summary.headline', data_get($bundle, 'headline')),
'dominant_issue' => (string) data_get($bundle, 'summary.dominant_issue', data_get($bundle, 'dominant_issue')),
'freshness_state' => (string) data_get($bundle, 'freshness_state'),
'completeness_note' => data_get($bundle, 'summary.completeness_note'),
'redaction_note' => data_get($bundle, 'summary.redaction_note'),
'context' => data_get($bundle, 'context', []),
'tenant' => data_get($bundle, 'tenant'),
'operation_run' => data_get($bundle, 'operation_run'),
'sections' => $this->canonicalSections($bundle),
'notes' => is_array($bundle['notes'] ?? null)
? array_values($bundle['notes'])
: [],
],
'diagnostic_snapshot' => $attachDiagnosticSnapshot
? [
'contextual_help' => data_get($bundle, 'contextual_help'),
'sections' => is_array($bundle['sections'] ?? null)
? array_values($bundle['sections'])
: [],
'redaction' => is_array($bundle['redaction'] ?? null)
? $bundle['redaction']
: [],
'notes' => is_array($bundle['notes'] ?? null)
? array_values($bundle['notes'])
: [],
]
: null,
'omissions' => $attachDiagnosticSnapshot
? []
: [[
'type' => 'diagnostic_snapshot',
'reason' => 'omitted_without_support_diagnostics_view',
'message' => 'Redacted diagnostic evidence was omitted because the creator could not view support diagnostics.',
]],
];
}
/**
* @param array<string, mixed> $bundle
* @return list<array<string, mixed>>
*/
private function canonicalSections(array $bundle): array
{
if (! is_array($bundle['sections'] ?? null)) {
return [];
}
return array_values(array_map(
static fn (array $section): array => [
'key' => (string) ($section['key'] ?? ''),
'label' => (string) ($section['label'] ?? ''),
'availability' => (string) ($section['availability'] ?? 'missing'),
'summary' => (string) ($section['summary'] ?? ''),
'freshness_note' => $section['freshness_note'] ?? null,
'references' => is_array($section['references'] ?? null)
? array_values($section['references'])
: [],
],
$bundle['sections'],
));
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use Illuminate\Support\Str;
final class SupportRequestReferenceGenerator
{
public function generate(): string
{
return 'SR-'.strtoupper((string) Str::ulid());
}
}

View File

@ -0,0 +1,399 @@
<?php
declare(strict_types=1);
namespace App\Support\SupportRequests;
use App\Models\OperationRun;
use App\Models\SupportRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class SupportRequestSubmissionService
{
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
private readonly ExternalSupportDeskHandoffService $externalSupportDeskHandoffService,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {}
/**
* @param array<string, mixed> $data
*/
public function submitForTenant(Tenant $tenant, User $actor, array $data): SupportRequest
{
$this->authorizeCreation($tenant, $actor);
return $this->submit(
tenant: $tenant,
actor: $actor,
data: $data,
primaryContextType: SupportRequest::PRIMARY_CONTEXT_TENANT,
operationRun: null,
);
}
/**
* @param array<string, mixed> $data
*/
public function submitForOperationRun(OperationRun $run, User $actor, array $data): SupportRequest
{
$run->loadMissing('tenant.workspace');
$tenant = $run->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$this->authorizeCreation($tenant, $actor);
return $this->submit(
tenant: $tenant,
actor: $actor,
data: $data,
primaryContextType: SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
operationRun: $run,
);
}
private function authorizeCreation(Tenant $tenant, User $actor): void
{
if (! $this->capabilityResolver->isMember($actor, $tenant)) {
abort(404);
}
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_REQUESTS_CREATE)) {
abort(403);
}
}
/**
* @param array<string, mixed> $data
*/
private function submit(
Tenant $tenant,
User $actor,
array $data,
string $primaryContextType,
?OperationRun $operationRun,
): SupportRequest {
$validated = $this->validate($data);
$attachDiagnosticSnapshot = $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
$contextEnvelope = $operationRun instanceof OperationRun
? $this->supportRequestContextBuilder->forOperationRun($operationRun, $actor, $attachDiagnosticSnapshot)
: $this->supportRequestContextBuilder->forTenant($tenant, $actor, $attachDiagnosticSnapshot);
$contactName = $validated['contact_name'] ?? $this->normalizeNullableString($actor->name) ?? $this->normalizeNullableString($actor->email);
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
$connection = SupportRequest::query()->getModel()->getConnection();
$supportRequest = $connection->transaction(function () use (
$actor,
$contactEmail,
$contactName,
$contextEnvelope,
$operationRun,
$primaryContextType,
$tenant,
$validated,
): SupportRequest {
$supportRequest = SupportRequest::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
'initiated_by_user_id' => (int) $actor->getKey(),
'internal_reference' => $this->supportRequestReferenceGenerator->generate(),
'primary_context_type' => $primaryContextType,
'attachment_mode' => (string) data_get($contextEnvelope, 'attachment_mode', SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY),
'severity' => $validated['severity'],
'summary' => $validated['summary'],
'reproduction_notes' => $validated['reproduction_notes'],
'contact_name' => $contactName,
'contact_email' => $contactEmail,
'context_envelope' => $contextEnvelope,
]);
$supportRequest->loadMissing(['tenant.workspace']);
$this->workspaceAuditLogger->logSupportRequestCreated($supportRequest, $actor);
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(),
];
}
/**
* @param array<string, mixed> $data
* @return array{
* severity: string,
* summary: string,
* reproduction_notes: ?string,
* contact_name: ?string,
* contact_email: ?string,
* external_handoff_mode: string,
* external_ticket_reference: ?string,
* external_ticket_url: ?string,
* }
*/
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(
[
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
'summary' => $data['summary'] ?? null,
'reproduction_notes' => $data['reproduction_notes'] ?? null,
'contact_name' => $data['contact_name'] ?? 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())],
'summary' => ['required', 'string'],
'reproduction_notes' => ['nullable', 'string'],
'contact_name' => ['nullable', 'string'],
'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();
$validated['summary'] = trim((string) $validated['summary']);
if ($validated['summary'] === '') {
throw ValidationException::withMessages([
'summary' => 'The summary field is required.',
]);
}
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? 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;
}
private function normalizeNullableString(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$normalized = trim($value);
return $normalized === '' ? null : $normalized;
}
}

View File

@ -39,6 +39,7 @@
use App\Filament\System\Pages\Dashboard as SystemDashboard;
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
use App\Filament\System\Pages\Ops\Controls;
use App\Filament\System\Pages\Ops\Runbooks;
use App\Filament\System\Pages\Ops\ViewRun;
use App\Filament\System\Pages\RepairWorkspaceOwners;
@ -639,22 +640,43 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'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' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsRunbooks/FindingsLifecycleBackfillStartTest.php',
'proves' => 'The runbooks shell enforces preflight-first execution, typed confirmation, and capability-gated run behavior.',
'reference' => 'tests/Feature/System/OpsRunbooks/RemoveFindingsLifecycleBackfillRunbookSurfaceTest.php',
'proves' => 'The runbooks shell stays accessible to authorized platform operators while exposing no findings lifecycle backfill launch action.',
],
[
'kind' => 'authorization_test',
'reference' => 'tests/Feature/System/Spec113/AuthorizationSemanticsTest.php',
'proves' => 'The system plane still returns 403 when runbook-view capabilities are missing.',
],
],
'followUpAction' => 'add_guard_only',
'mustRemainBaselineExempt' => false,
'mustNotRemainBaselineExempt' => true,
],
Controls::class => [
'surfaceKey' => 'system_ops_controls',
'surfaceName' => 'System Ops Controls',
'pageClass' => Controls::class,
'panelPlane' => 'system',
'surfaceKind' => 'system_utility',
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'separately_governed',
'reasonCategory' => 'workflow_specific_governance',
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
'evidence' => [
[
'kind' => 'guard_test',
'reference' => 'tests/Feature/Guards/LivewireTrustedStateGuardTest.php',
'proves' => 'Runbooks keeps its trusted-state policy under explicit guard coverage.',
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
],
],
'followUpAction' => 'add_guard_only',
@ -722,12 +744,17 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.',
'evidence' => [
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
],
[
'kind' => 'authorization_test',

View File

@ -8,6 +8,8 @@ final class WorkspaceResolver
{
public function resolve(string $value): ?Workspace
{
$value = $this->normalizeRouteValue($value);
$workspace = Workspace::query()
->where('slug', $value)
->first();
@ -22,4 +24,37 @@ public function resolve(string $value): ?Workspace
return Workspace::query()->whereKey((int) $value)->first();
}
private function normalizeRouteValue(string $value): string
{
$value = trim($value);
if (! str_starts_with($value, '{')) {
return $value;
}
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return $value;
}
$slug = $decoded['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
$id = $decoded['id'] ?? null;
if (is_int($id)) {
return (string) $id;
}
if (is_string($id) && ctype_digit($id)) {
return $id;
}
return $value;
}
}

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