Compare commits

...

5 Commits

Author SHA1 Message Date
bf43e55848 feat(onboarding): decision-first verify-step & contextual-help callout fix (#282)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
This PR implements the onboarding verify-step changes and ProductKnowledge contextual-help fixes:

- Hide the top-level "Permission diagnostics" when a stored verification report exists.
- Move permission details to "Supporting evidence" / "View required permissions" and into stored report technical details.
- Rename "Current checkpoint" to "Step" in onboarding readiness.
- Rename the inner verification card title to "Stored verification details" to avoid duplicate headings.
- Keep "Grant admin consent" as primary CTA when admin consent is the dominant blocker by deriving the CTA from the verification primary reason.
- Replace the custom Safe Next Action with Filament `Callout` for correct dark-mode styling.
- Add/adjust focused feature tests proving the above behaviors.

Verification:
- Tests: 36 passed (173 assertions) locally.
- Pint: pass.

Created from local session branch `244-product-knowledge-contextual-help-session-1777248340`. Please review and merge into `dev` when ready.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #282
2026-04-27 00:09:46 +00:00
6053d87b99 feat: implement product usage adoption telemetry (#281)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary
- implement spec 243 product usage adoption telemetry end-to-end
- add bounded product usage event capture, aggregation, retention pruning, and system dashboard KPIs
- add unit and feature coverage for telemetry capture, authorization, retention, privacy, and dashboard window behavior

## Validation
- ran focused Pest test suites for telemetry and system dashboard behavior
- ran Laravel Pint formatting
- verified the system dashboard telemetry widget in the integrated browser

## Notes
- branch: `243-product-usage-adoption-telemetry`
- target: `dev`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #281
2026-04-26 20:52:38 +00:00
d96abc65fb Remove Findings lifecycle backfill operational surface (controls slice) (#280)
Some checks failed
Main Confidence / confidence (push) Failing after 1m23s
Removes the Findings lifecycle backfill from the Operational Controls UI and OperationalControlCatalog.

This patch is a safe, controls-only change; runbooks, jobs and other runtime artifacts are NOT removed yet. Follow-up work will delete the runbook service/scope, jobs, commands, and update tests.

Files changed:
- apps/platform/app/Filament/System/Pages/Ops/Controls.php
- apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php
- apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php
- apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php
- apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #280
2026-04-26 15:43:47 +00:00
17d3ca8313 feat(support-diagnostics): guardrail refactor and UI polish (agent) (#278)
Some checks failed
Main Confidence / confidence (push) Failing after 45s
Implements support diagnostics bundle, moves audit writes to action mountUsing to avoid side-effects during render, replaces custom slide-over with Filament-native schema, updates tests and adds spec docs.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #278
2026-04-25 23:32:30 +00:00
ab6eccaf40 feat: add onboarding readiness workflow (#277)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary
- add derived onboarding readiness to the managed tenant onboarding workflow and multi-draft picker
- keep provider-specific permission diagnostics secondary while preserving canonical `Open operation` and existing onboarding action semantics
- add spec-kit artifacts for `240-tenant-onboarding-readiness` and align roadmap/spec-candidate planning notes
- unify the required-permissions empty state copy to English

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- browser smoke exercised the onboarding picker, route-bound mismatch readiness state, canonical `Open operation` path, and local fixture cleanup

## Notes
- branch includes the generated spec artifacts under `specs/240-tenant-onboarding-readiness/`
- temporary browser smoke tenants/drafts/runs were cleaned from the local environment after validation

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #277
2026-04-25 21:17:31 +00:00
136 changed files with 17074 additions and 914 deletions

View File

@ -256,6 +256,10 @@ ## Active Technologies
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope) - Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
- 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 (239-canonical-operation-type-source-of-truth) - 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 (239-canonical-operation-type-source-of-truth)
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth) - PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
- 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 (240-tenant-onboarding-readiness)
- 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.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -290,9 +294,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -0,0 +1,447 @@
---
name: spec-kit-implementation-loop
description: Implement an existing TenantPilot/TenantAtlas Spec Kit feature, run tests, browser smoke checks where applicable, post-implementation analysis, fix all confirmed in-scope findings when safe and bounded, and repeat until no in-scope findings remain or a stop condition is reached.
---
# Skill: Spec Kit Implementation Loop
## Purpose
Use this skill to implement an already prepared TenantPilot/TenantAtlas Spec Kit feature and verify it with a bounded implementation loop.
This skill assumes `spec.md`, `plan.md`, and `tasks.md` already exist and have passed preparation readiness or have been explicitly accepted by the user.
The intended workflow is:
```text
active or explicitly named spec
→ inspect repo truth, constitution, spec, plan, tasks, and relevant code/tests
→ evaluate implementation gates
→ implement strictly task-by-task
→ run relevant tests/checks
→ run browser smoke test when UI/user-facing flows are affected
→ run strict post-implementation analysis
→ fix confirmed in-scope findings
→ repeat test + browser smoke + analysis + fix loop until clean or bounded stop condition is reached
→ final implementation report
```
## When to Use
Use this skill when the user asks to:
- implement an active or explicitly named Spec Kit feature
- run Spec Kit implement
- analyze after implementation
- fix implementation findings
- repeat implementation verification until no confirmed in-scope findings remain
- run tests and browser smoke checks after implementation
Typical user prompts:
```text
Implementiere die aktive Spec und analysiere danach, ob alles passt.
```
```text
Implementiere specs/243-product-usage-adoption-telemetry streng nach tasks.md.
```
```text
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
```
```text
Implementiere die vorbereitete Spec. Danach Tests, Browser Smoke Test falls UI betroffen ist, Analyse und Fix-Loop bis keine In-Scope Findings mehr offen sind.
```
## Hard Rules
- Work strictly repo-based.
- Implement only the active or explicitly named Spec Kit feature.
- Do not choose a new candidate.
- Do not create a new spec.
- Do not expand scope beyond `spec.md`, `plan.md`, and `tasks.md`.
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
- Follow the repository constitution and existing Spec Kit conventions.
- Preserve TenantPilot/TenantAtlas terminology.
- Prefer small, reviewable patches over broad rewrites.
- Treat repository truth as authoritative over assumptions.
- If repository truth conflicts with implementation scope, stop and report the conflict unless there is an obvious minimal correction inside active spec scope.
- Fix only confirmed findings from tests, static checks, browser smoke checks, or post-implementation analysis.
- Fix all confirmed in-scope findings, regardless of severity, when they are safe and bounded.
- Do not leave Medium/Low findings open silently. If they are not fixed, document exactly why.
- Never hide failing tests, weaken assertions, delete meaningful coverage, or mark tasks complete without implementation evidence.
- Do not run destructive commands.
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
- Do not perform database-destructive actions unless the repository test workflow explicitly requires isolated test database resets.
- Do not continue analysis/fix loops indefinitely.
- Do not move from implementation to final status unless the Test Gate, Browser Smoke Test Gate where applicable, and Post-Implementation Analysis Gate have been evaluated.
- Do not claim merge-readiness unless the Merge Readiness Gate passes.
## Required Inputs
The user should provide at least one of:
- explicit spec directory such as `specs/<number>-<slug>/`
- instruction to use the current active Spec Kit feature
- instruction to implement the prepared/current spec
If the active spec cannot be determined safely, inspect the repository Spec Kit context first. If it is still ambiguous, stop and ask for the specific spec directory.
## Required Repository Checks
Always check:
1. active Spec Kit context / current branch
2. git status
3. `.specify/memory/constitution.md`
4. the active spec directory
5. `spec.md`
6. `plan.md`
7. `tasks.md`
8. relevant templates or conventions under `.specify/templates/`
9. nearby existing specs with related terminology or scope
10. application code surfaces referenced by the active spec
11. existing tests related to the changed behavior
## Git and Branch Safety
Before making implementation changes:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended changes for this operation, continue cautiously.
5. Do not force checkout, reset, stash, rebase, merge, or delete branches.
6. Do not overwrite unrelated work.
## Quality Gates
### Gate 1: Spec Readiness Gate
Required before implementation starts.
Pass criteria:
- `spec.md`, `plan.md`, and `tasks.md` exist.
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
- The tasks are small, ordered, verifiable, and include test/validation tasks.
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
- No open question blocks safe implementation.
- The scope is small enough for a bounded implementation loop.
Fail behavior:
- Stop before implementation.
- Report readiness gaps.
- Do not compensate for an unclear spec by inventing implementation scope.
### Gate 2: Implementation Scope Gate
Required before changing application code.
Pass criteria:
- The active spec directory is known.
- The implementation target is traceable to specific tasks in `tasks.md`.
- The affected files/surfaces are consistent with `plan.md` or clearly justified by repository truth.
- No required change would introduce unrelated product behavior.
- No required change conflicts with constitution, existing architecture, RBAC/isolation boundaries, or source-of-truth semantics.
Fail behavior:
- Stop before code changes and report the conflict or ambiguity.
- Suggest a minimal spec/plan/tasks correction if the issue is in the artifacts rather than the codebase.
### Gate 3: Test Gate
Required after implementation and after each fix iteration.
Pass criteria:
- Targeted tests for changed behavior pass.
- Relevant existing tests pass or failures are proven unrelated and documented.
- Static analysis, linting, formatting, or type checks used by the repository pass when applicable.
- Security/governance-relevant changes have backend, policy, or domain coverage; UI-only verification is not enough.
- Regression coverage exists for each fixed Blocker or High finding where practical.
Fail behavior:
- Fix in-scope failures before post-implementation analysis.
- If failures are unrelated or pre-existing, document evidence and continue only if they do not invalidate the active spec.
- Do not weaken tests to pass the gate.
### Gate 4: Browser Smoke Test Gate
Required before claiming implementation is ready for manual review/merge when the change affects Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
Not required for backend-only, domain-only, enum-only, contract-only, or test-only changes unless those changes alter a user-facing flow.
Pass criteria:
- The relevant page or flow loads in a real browser or the repository's browser-testing harness.
- The primary action introduced or changed by the spec can be executed successfully.
- Expected UI states, labels, badges, actions, empty states, tables, forms, modals, and navigation are visible where relevant.
- Workspace/tenant context is preserved across the tested flow where relevant.
- RBAC/capability-dependent visibility behaves as expected where practical to verify.
- Livewire interactions complete without visible runtime errors.
- No relevant browser console errors occur.
- No failed network requests occur for the tested flow, except known unrelated development noise that is explicitly documented.
- OperationRun, audit, evidence, result, or support-diagnostic drilldowns work where relevant.
- The smoke-tested path is documented in the final response.
Fail behavior:
- Fix in-scope browser, UX, Livewire, navigation, or runtime failures before claiming merge-readiness.
- If a browser issue is unrelated existing debt, document evidence and residual risk.
- Do not treat a passing browser smoke test as a substitute for backend, policy, domain, security, feature, or integration tests.
- Do not expand the smoke test into a full E2E suite unless the user explicitly asks for that.
### Gate 5: Post-Implementation Analysis Gate
Required after implementation and after each fix iteration.
Pass criteria:
- The implementation has been checked against `spec.md`, `plan.md`, `tasks.md`, and constitution.
- All completed tasks have implementation evidence.
- No confirmed in-scope findings remain.
- Medium/Low findings are fixed when they are inside active spec scope, clearly bounded, and safe.
- Medium/Low findings that remain open are explicitly documented with one of these reasons:
- out of scope
- requires separate spec
- risky refactor
- existing unrelated debt
- not reproducible
- blocked by unclear product/architecture decision
- No scope expansion was introduced during fixes.
Fail behavior:
- Fix confirmed in-scope findings, regardless of severity, when the fix is safe and bounded.
- Stop instead of fixing when remediation would expand scope, contradict repo architecture, introduce risky refactors, or repeat the same failed fix twice.
### Gate 6: Merge Readiness Gate
Required before claiming the implementation is ready for manual review/merge.
Pass criteria:
- Spec Readiness Gate passed.
- Implementation Scope Gate passed.
- Test Gate passed.
- Browser Smoke Test Gate passed when applicable, or was explicitly marked not applicable with a reason.
- Post-Implementation Analysis Gate passed.
- `tasks.md` reflects actual completion status.
- No confirmed in-scope findings remain.
- All remaining findings are documented as out-of-scope, follow-up candidates, unrelated existing debt, or explicit residual risks.
- Final response includes changed files, tests/checks run, browser smoke result, iterations performed, residual risks, and follow-up candidates.
Fail behavior:
- Do not claim merge-readiness.
- Report the failed gate, remaining risks, and the smallest recommended next action.
## Implementation Loop
Execute the loop in bounded phases:
1. Evaluate the Spec Readiness Gate.
2. Evaluate the Implementation Scope Gate before changing application code.
3. Implement the active Spec Kit feature scope task-by-task.
4. Run targeted tests and relevant static/dynamic checks.
5. Evaluate the Test Gate.
6. Run a Browser Smoke Test when the change affects UI/user-facing flows.
7. Evaluate the Browser Smoke Test Gate as passed, failed, or not applicable with a reason.
8. Run strict post-implementation analysis against spec, plan, tasks, constitution, changed code, changed tests, browser smoke results where applicable, and relevant existing patterns.
9. Evaluate the Post-Implementation Analysis Gate.
10. Identify confirmed findings by severity: Blocker, High, Medium, Low.
11. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
12. Do not fix findings that require scope expansion, risky unrelated refactors, or architectural/product decisions outside the active spec; document them as follow-up/residual risks with reasons.
13. Re-run relevant tests and browser smoke checks where applicable after fixes.
14. Repeat test + browser smoke + analysis + fix loop until no confirmed in-scope findings remain or a stop condition is reached.
15. Evaluate the Merge Readiness Gate.
16. Report final implementation status, changed files, tests, browser smoke result, residual risks, failed/passed gates, and manual review prompt.
## Stop Conditions
Stop the implementation loop when any of the following is true:
- No confirmed in-scope findings remain.
- The same finding appears twice after attempted fixes.
- A required fix conflicts with the spec, plan, constitution, or repository architecture.
- A required fix would expand scope beyond the active spec.
- A required fix would require a risky unrelated refactor.
- A required fix depends on an unresolved product or architecture decision.
- Tests reveal an unrelated pre-existing failure that cannot be safely fixed inside the active spec.
- Browser smoke testing reveals an unrelated pre-existing UI/runtime failure that cannot be safely fixed inside the active spec.
- Three analysis/fix iterations have already been completed.
- The repository state is ambiguous enough that continuing would risk damaging architecture or data semantics.
When stopping before full cleanliness, report exactly why the loop stopped and what remains.
## Post-Implementation Analysis Prompt
Use this prompt internally after implementation and after each fix iteration:
```markdown
Du bist ein Senior Staff Software Engineer, Software Architect und Enterprise SaaS Reviewer.
Analysiere die Implementierung der aktiven Spec streng repo-basiert.
Ziel:
Prüfe, ob die Umsetzung vollständig, konsistent, getestet und constitution-konform ist.
Prüfe gegen:
- spec.md
- plan.md
- tasks.md
- .specify/memory/constitution.md
- geänderte Anwendungscodes
- geänderte Tests
- Browser-Smoke-Test-Ergebnis, falls UI/user-facing Flows betroffen sind
- bestehende Repository-Patterns
Wichtig:
- Keine Spekulation ohne Repo-Beleg.
- Keine Scope-Erweiterung.
- Keine neuen Produktideen als Pflicht-Fixes.
- Findings nach Blocker, High, Medium, Low gruppieren.
- Für jedes Finding konkrete Datei-/Code-Belege nennen.
- Für jedes Finding eine minimale Remediation nennen.
- Separat ausweisen, welche Findings innerhalb der aktiven Spec behoben werden müssen.
- Medium/Low Findings innerhalb der aktiven Spec ebenfalls zur Behebung markieren, wenn sie sicher und bounded sind.
- Bei UI-/Filament-/Livewire-Änderungen prüfen, ob ein Browser Smoke Test durchgeführt wurde und ob der getestete Operator-Flow wirklich funktioniert.
- Findings, die nicht behoben werden sollen, nur als Follow-up/Residual Risk ausweisen, wenn sie out of scope, risky refactor, unrelated existing debt, not reproducible oder durch eine offene Produkt-/Architekturentscheidung blockiert sind.
- Wenn keine bestätigten In-Scope Findings verbleiben, klare Implementierungsfreigabe geben.
```
## Task Completion Rules
- Keep `tasks.md` aligned with actual implementation status.
- Check off tasks only after the implementation and test evidence exists.
- If a task is obsolete because repository truth proves a different path, update the task note with the reason instead of silently deleting it.
- If a task cannot be completed inside scope, leave it unchecked and report why.
## Testing Rules
- Add or update tests for all changed business behavior.
- Include RBAC and workspace/tenant isolation tests where relevant.
- Include OperationRun, audit, evidence, or result-truth tests where relevant.
- Prefer regression tests for every fixed Blocker or High finding.
- Add regression tests for Medium/Low findings when the behavior is important and testable without excessive churn.
- Do not weaken tests to pass the suite.
- Do not treat a green UI path as sufficient without backend or policy coverage when the behavior is security- or governance-relevant.
## Browser Smoke Test Rules
Apply these rules when the active spec changes Filament UI, Livewire interactions, navigation, forms, tables, actions, modals, dashboards, operation drilldowns, tenant/workspace context, or any user-facing flow.
The browser smoke test should be narrow and focused. It is not a full E2E suite unless explicitly requested.
Minimum smoke path:
1. Open the relevant page or entry point.
2. Confirm the expected workspace/tenant context where relevant.
3. Confirm the changed or newly introduced UI element is visible.
4. Execute the primary action or interaction changed by the spec.
5. Confirm the expected result state, notification, redirect, table update, modal state, operation link, or drilldown.
6. Check for relevant console errors.
7. Check for failed network requests related to the tested flow.
8. Document the tested path in the final response.
For TenantPilot/TenantAtlas, pay special attention to:
- Filament actions and header actions
- Livewire polling, modals, validation, and actions
- workspace/tenant context preservation
- RBAC/capability-dependent action visibility
- OperationRun links and drilldown continuity
- audit/evidence/result/support-diagnostic drilldowns where relevant
- empty states, badges, labels, and decision guidance where relevant
Browser smoke testing is required for UI/user-facing changes and optional for backend-only changes.
Do not treat browser smoke success as proof that backend security, policies, domain logic, auditability, or workspace/tenant isolation are correct. Those still require automated tests or repo-based verification.
## Failure Handling
If an implementation step, test phase, browser smoke phase, or post-implementation analysis fails:
1. Stop at the relevant gate or stop condition.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt unrelated implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before implementation changes.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
Respond with:
1. Active spec directory
2. Summary of implemented changes
3. Tests/checks run and their results
4. Browser smoke test result, tested path, or not-applicable reason
5. Quality gates passed/failed and number of analysis/fix iterations performed
6. Remaining in-scope findings, if any
7. Residual risks and follow-up candidates, if relevant
8. Files changed
9. Explicit statement whether the Merge Readiness Gate passed and whether the implementation is ready for manual review/merge
Keep the final response concise, but include enough detail for the user to continue immediately.
## Manual Review Prompt
Provide a ready-to-copy prompt like this, adapted to the active spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Führe eine finale manuelle Review der implementierten Spec `<spec-number>-<slug>` streng repo-basiert durch.
Ziel:
Prüfe, ob die Implementierung nach dem Agenten-Loop wirklich merge-ready ist.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe gegen spec.md, plan.md, tasks.md und constitution.md.
- Prüfe die geänderten Dateien, Tests, Browser-Smoke-Test-Ergebnis, RBAC, Workspace-/Tenant-Isolation, Auditability, UX und OperationRun-Semantik, soweit relevant.
- Benenne nur konkrete Findings mit Repo-Beleg.
- Gib am Ende eine klare Entscheidung: Merge-ready, merge-ready with notes, oder not merge-ready.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-implementation-loop.
Implementiere die aktive Spec.
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
```
Expected behavior:
1. Inspect active Spec Kit context, constitution, spec, plan, tasks, relevant code, and relevant tests.
2. Evaluate the Spec Readiness Gate and Implementation Scope Gate.
3. Implement only the active spec scope.
4. Run targeted tests and relevant checks.
5. Evaluate the Test Gate.
6. Run and evaluate Browser Smoke Test when UI/user-facing flows are affected.
7. Run post-implementation analysis.
8. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
9. Repeat test + browser smoke + analysis + fix loop up to the stop conditions.
10. Evaluate the Merge Readiness Gate.
11. Report final status, changed files, tests, browser smoke result, residual risks, gates, and manual review prompt.
```

View File

@ -1,398 +0,0 @@
---
name: spec-kit-next-best-one-shot
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then run the GitHub Spec Kit preparation flow in one pass: specify, plan, tasks, and analyze. Use when the user wants the agent to choose the next best spec, execute the real Spec Kit workflow including branch/spec-directory mechanics, analyze the generated artifacts, and fix preparation issues before implementation. This skill must not implement application code.
---
# Skill: Spec Kit Next-Best One-Shot Preparation
## Purpose
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then execute the real GitHub Spec Kit preparation flow in one pass:
1. select the next best spec candidate from roadmap and spec candidates
2. run the repository's Spec Kit `specify` flow for that selected candidate
3. run the repository's Spec Kit `plan` flow for the generated spec
4. run the repository's Spec Kit `tasks` flow for the generated plan
5. run the repository's Spec Kit `analyze` flow against the generated artifacts
6. fix issues in Spec Kit preparation artifacts only (`spec.md`, `plan.md`, `tasks.md`, and related Spec Kit metadata if required)
7. stop before implementation
8. provide a concise readiness summary for the user
This skill must use the repository's actual Spec Kit scripts, commands, templates, branch naming rules, and generated paths. It must not manually bypass Spec Kit by creating arbitrary spec folders or files. The only allowed fixes after `analyze` are preparation-artifact fixes, not application-code implementation.
The intended workflow is:
```text
roadmap.md + spec-candidates.md
→ select next best spec
→ run Spec Kit specify
→ run Spec Kit plan
→ run Spec Kit tasks
→ run Spec Kit analyze
→ fix preparation-artifact issues
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks things like:
```text
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates und führe specify, plan, tasks und analyze aus.
```
```text
Wähle die nächste geeignete Spec und mach den Spec-Kit-Flow inklusive analyze in einem Rutsch.
```
```text
Schau in roadmap.md und spec-candidates.md und starte daraus specify, plan, tasks und analyze.
```
```text
Such die beste nächste Spec aus und bereite sie per GitHub Spec Kit vollständig vor.
```
```text
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema, aber nicht implementieren.
```
## Hard Rules
- Work strictly repo-based.
- Use the repository's actual GitHub Spec Kit workflow.
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
- Do not manually create `spec.md`, `plan.md`, or `tasks.md` when the Spec Kit workflow can generate them.
- Do not bypass Spec Kit branch mechanics.
- Run `analyze` after `tasks` when the repository supports it.
- Fix only issues found in Spec Kit preparation artifacts and planning metadata.
- Do not treat analyze findings as permission to implement product code.
- If analyze reports implementation work as missing, record it in `tasks.md` instead of implementing it.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not invent roadmap priorities not supported by repository documents.
- Do not pick a spec only because it is listed first.
- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate.
- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
- Prefer small, reviewable, implementation-ready specs over large ambiguous themes.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
- If no candidate is suitable, do not run Spec Kit commands and explain why.
## Required Repository Checks Before Selection
Before selecting the next spec, inspect:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `.specify/scripts/`
4. existing Spec Kit command usage or repository instructions, if present
5. `specs/`
6. `docs/product/spec-candidates.md`
7. roadmap documents under `docs/product/`, especially `roadmap.md` if present
8. nearby existing specs related to top candidate areas
9. current branch and git status
10. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
Do not edit application code.
## Git and Branch Safety
Before running any Spec Kit command or script:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
7. Do not overwrite existing specs.
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
## Candidate Selection Criteria
Evaluate candidate specs using these criteria.
### 1. Roadmap Fit
Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer.
Examples:
- governance foundations before advanced compliance views
- evidence/snapshot foundations before auditor packs
- control catalog foundations before CIS/NIS2 mappings
- decision/workflow surfaces before autonomous governance
- provider/platform boundary cleanup before multi-provider expansion
### 2. Foundation Value
Prefer candidates that strengthen reusable platform foundations:
- RBAC and workspace/tenant isolation
- auditability
- evidence and snapshot truth
- operation observability
- provider boundary neutrality
- canonical vocabulary
- baseline/control/finding semantics
- enterprise detail-page or decision-surface patterns
### 3. Dependency Unblocking
Prefer specs that unblock multiple later candidates.
A good next spec should usually make future specs smaller, safer, or more consistent.
### 4. Scope Size
Prefer a candidate that can be implemented as a narrow, testable slice.
Avoid selecting:
- broad platform rewrites
- vague product themes
- multi-feature bundles
- speculative future-provider frameworks
- large UX redesigns without a clear first slice
### 5. Repo Readiness
Prefer candidates where the repository already has enough structure to implement the next slice safely.
Check whether related models, services, UI pages, tests, or concepts already exist.
### 6. Risk Reduction
Prefer candidates that reduce current architectural or product risk:
- legacy dual-world semantics
- unclear truth ownership
- inconsistent operator UX
- missing audit/evidence boundaries
- repeated manual workflow friction
- false-positive calmness in governance surfaces
### 7. User/Product Value
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
## Required Selection Output Before Spec Kit Execution
Before running the Spec Kit flow, identify:
- selected candidate title
- source location in roadmap/spec-candidates
- why it was selected
- why close alternatives were deferred
- roadmap relationship
- smallest viable implementation slice
- proposed concise feature description to feed into `specify`
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
## Spec Kit Execution Flow
After selecting the candidate, execute the real repository Spec Kit preparation sequence, including analysis and preparation-artifact fixes.
### Step 1: Determine the repository's Spec Kit command pattern
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
Common locations to inspect:
```text
.specify/scripts/
.specify/templates/
.specify/memory/constitution.md
.github/prompts/
.github/skills/
README.md
specs/
```
Use the repo-specific mechanism if present.
### Step 2: Run `specify`
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
The `specify` input should include:
- selected candidate title
- problem statement
- operator/user value
- roadmap relationship
- out-of-scope boundaries
- key acceptance criteria
- important enterprise constraints
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
### Step 3: Run `plan`
Run the repository's `plan` flow for the generated spec.
The `plan` input should keep the scope tight and should require repo-based alignment with:
- constitution
- existing architecture
- workspace/tenant isolation
- RBAC
- OperationRun/observability where relevant
- evidence/snapshot/truth semantics where relevant
- Filament/Livewire conventions where relevant
- test strategy
### Step 4: Run `tasks`
Run the repository's `tasks` flow for the generated plan.
The generated tasks must be:
- ordered
- small
- testable
- grouped by phase
- limited to the selected scope
- suitable for later manual analysis before implementation
### Step 5: Run `analyze`
Run the repository's `analyze` flow against the generated Spec Kit artifacts.
Analyze must check:
- consistency between `spec.md`, `plan.md`, and `tasks.md`
- constitution alignment
- roadmap alignment
- whether the selected candidate was narrowed safely
- whether tasks are complete enough for implementation
- whether tasks accidentally require scope not described in the spec
- whether plan details conflict with repository architecture or terminology
- whether implementation risks are documented instead of silently ignored
Do not use analyze as a trigger to implement application code.
### Step 6: Fix preparation-artifact issues only
If analyze finds issues, fix only Spec Kit preparation artifacts such as:
- `spec.md`
- `plan.md`
- `tasks.md`
- generated Spec Kit metadata files, if the repository uses them
Allowed fixes include:
- clarify requirements
- tighten scope
- move out-of-scope work into follow-up candidates
- correct terminology
- add missing tasks
- remove tasks not backed by the spec
- align plan language with repository architecture
- add missing acceptance criteria or validation tasks
Forbidden fixes include:
- modifying application code
- creating migrations
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, or commands
- running implementation or test-fix loops
- changing runtime behavior
### Step 7: Stop
After `analyze` has passed or preparation-artifact issues have been fixed, stop.
Do not implement.
Do not modify application code.
Do not run implementation tests unless the repository's Spec Kit preparation command requires a non-destructive validation.
## Failure Handling
If a Spec Kit command or analyze phase fails:
1. Stop immediately.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before running Spec Kit commands.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
After the Spec Kit preparation flow completes, respond with:
1. Selected candidate
2. Why this candidate was selected
3. Why close alternatives were deferred
4. Current branch after Spec Kit execution
5. Generated spec path
6. Files created or updated by Spec Kit
7. Analyze result summary
8. Preparation-artifact fixes applied after analyze
9. Assumptions made
10. Open questions, if any
11. Recommended next implementation prompt
12. Explicit statement that no application implementation was performed
Keep the response concise, but include enough detail for the user to continue immediately.
## Required Next Implementation Prompt
Always provide a ready-to-copy implementation prompt like this, adapted to the generated spec branch/path, but only after analyze has passed or preparation-artifact issues have been fixed:
```markdown
Du bist ein Senior Staff Software Engineer für TenantPilot/TenantAtlas.
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
Wichtig:
- Arbeite task-sequenziell.
- Ändere nur Dateien, die für die jeweilige Task notwendig sind.
- Halte dich an `spec.md`, `plan.md`, `tasks.md` und die Constitution.
- Keine Scope-Erweiterung.
- Keine Opportunistic Refactors.
- Führe passende Tests nach sinnvollen Task-Gruppen aus.
- Wenn eine Task unklar oder falsch ist, stoppe und dokumentiere den Konflikt statt frei zu improvisieren.
- Am Ende liefere geänderte Dateien, Teststatus, offene Risiken und nicht erledigte Tasks.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-next-best-one-shot.
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
Keine Application-Implementierung.
```
Expected behavior:
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
2. Check branch and working tree safety.
3. Compare candidate suitability.
4. Select the next best candidate.
5. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
6. Run the repository's real Spec Kit `plan` flow.
7. Run the repository's real Spec Kit `tasks` flow.
8. Run the repository's real Spec Kit `analyze` flow.
9. Fix analyze issues only in Spec Kit preparation artifacts.
10. Stop before application implementation.
11. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, and next implementation prompt.
```

View File

@ -0,0 +1,562 @@
---
name: spec-kit-next-best-prep
description: Select the next suitable TenantPilot/TenantAtlas spec candidate from roadmap/spec-candidates, run the repository's Spec Kit preparation flow, create or update spec.md/plan.md/tasks.md, run preparation analysis, fix preparation-artifact issues only, and stop before application implementation.
---
# Skill: Spec Kit Next-Best Preparation
## Purpose
Use this skill to prepare the next implementation-ready Spec Kit package for TenantPilot/TenantAtlas without implementing application code.
This skill supports preparation only:
1. Select or scope the next suitable feature from roadmap/spec-candidates.
2. Run the repository's real Spec Kit preparation workflow where available.
3. Create or update `spec.md`, `plan.md`, and `tasks.md`.
4. Run preparation `analyze` when supported.
5. Fix preparation-artifact issues only.
6. Evaluate preparation quality gates.
7. Stop before application implementation.
The intended workflow is:
```text
roadmap / spec-candidates / feature idea
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
→ select the next suitable candidate or scope the provided idea
→ run Spec Kit specify/plan/tasks/analyze where available
→ create or update spec.md + plan.md + tasks.md
→ fix preparation-artifact issues only
→ evaluate Candidate Selection Gate and Spec Readiness Gate
→ final preparation report
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks to:
- select the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
- turn a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
- prepare Spec Kit artifacts in one pass
- run specify/plan/tasks/analyze without implementation
- fix preparation analysis issues in Spec Kit artifacts only
- prepare a feature package for a later implementation skill
Typical user prompts:
```text
Nimm den nächsten sinnvollen Spec Candidate aus Roadmap/spec-candidates und mach spec, plan und tasks.
```
```text
Mach daraus spec, plan und tasks in einem Rutsch, aber noch nicht implementieren.
```
```text
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und führe specify, plan, tasks und analyze aus.
```
```text
Behebe alle analyze-Issues in den Spec-Kit-Artefakten. Keine Application-Implementierung.
```
## Hard Rules
- Work strictly repo-based.
- This is a preparation-only skill.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, routes, views, tests, or runtime behavior.
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
- Do not manually invent spec numbers, branch names, or spec paths if Spec Kit provides a script or command for that.
- Do not bypass Spec Kit branch mechanics.
- Create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
- Do not expand scope beyond the selected feature, `spec.md`, `plan.md`, and `tasks.md`.
- Do not silently add roadmap features, adjacent UX rewrites, speculative architecture, or unrelated refactors.
- Follow the repository constitution and existing Spec Kit conventions.
- Preserve TenantPilot/TenantAtlas terminology.
- Prefer small, reviewable, implementation-ready specs over broad rewrites.
- Treat repository truth as authoritative over assumptions.
- If repository truth conflicts with the user-provided draft or candidate wording, keep repository truth and document the deviation.
- Fix only confirmed preparation-artifact findings from Spec Kit preparation analysis.
- Do not leave preparation findings open silently. If they are not fixed, document exactly why.
- Do not run destructive commands.
- Do not force checkout, reset, stash, rebase, merge, or delete branches.
- Do not overwrite existing specs.
- Do not move from preparation to an implementation step inside this skill.
## Required Inputs
The user should provide at least one of:
- feature title and short goal
- full spec candidate
- roadmap item
- rough problem statement
- UX or architecture improvement idea
- instruction to choose the next best candidate from roadmap/spec-candidates
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions.
If no suitable candidate can be selected safely, stop and report why.
## Required Repository Checks
Always check:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `.specify/scripts/`
4. existing Spec Kit command usage or repository instructions, if present
5. current branch and git status
6. `specs/`
7. `docs/product/spec-candidates.md`
8. relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present
9. nearby existing specs with related terminology or scope
10. application code only as needed to avoid wrong naming, wrong architecture, duplicate concepts, impossible tasks, duplicated specs, or already-completed candidates
Do not edit application code.
## Git and Branch Safety
Before running any Spec Kit command:
1. Check the current branch.
2. Check whether the working tree is clean.
3. If there are unrelated uncommitted changes, stop and report them. Do not continue.
4. If the working tree only contains user-intended planning edits for this operation, continue cautiously.
5. Let Spec Kit create or switch to the correct feature branch when that is how the repository workflow works.
6. Do not force checkout, reset, stash, rebase, merge, or delete branches.
7. Do not overwrite existing specs.
If the repo requires an explicit branch creation script for `specify`, use that script rather than manually creating the branch.
## Quality Gates
### Gate 1: Candidate Selection Gate
Required before creating a new spec from roadmap/spec-candidates.
Pass criteria:
- The selected candidate exists in roadmap/spec-candidate material or is directly provided by the user.
- The selected candidate is not already covered by an existing active or completed spec.
- The selected candidate aligns with current roadmap priorities or explicitly documented product direction.
- The candidate can be scoped as a small, reviewable, implementation-ready slice.
- Major adjacent concerns are listed as follow-up candidates instead of being hidden inside the primary scope.
Fail behavior:
- If no candidate satisfies the gate, stop and report the top candidates plus the reason none is ready.
- Do not invent a new roadmap direction to force progress.
### Gate 2: Spec Readiness Gate
Required before reporting that the package is ready for implementation.
Pass criteria:
- `spec.md`, `plan.md`, and `tasks.md` exist.
- The spec has clear problem statement, user value, functional requirements, out-of-scope boundaries, acceptance criteria, assumptions, and risks.
- The plan identifies likely affected repo surfaces and does not contradict repository architecture.
- The tasks are small, ordered, verifiable, and include test/validation tasks.
- RBAC, workspace/tenant isolation, auditability, OperationRun semantics, evidence/result-truth, and UX requirements are addressed where relevant.
- No open question blocks safe implementation.
- The scope is small enough for a bounded implementation loop in a later implementation skill.
- Required checklist artifacts exist when the constitution requires them.
Fail behavior:
- Fix preparation-artifact issues when they are safe and bounded.
- If readiness cannot be achieved without implementation or unresolved product decisions, stop and report the gap.
- Do not compensate for an unclear spec by inventing implementation scope.
## Candidate Selection Rules
When the user asks for the next best spec from roadmap/spec-candidates:
- Read `docs/product/spec-candidates.md`.
- Read relevant roadmap documents under `docs/product/`, especially `roadmap.md` if present.
- Check existing specs to avoid duplicates.
- Prefer candidates that align with current roadmap priorities, platform foundations, enterprise UX, RBAC/isolation, auditability, observability, and governance workflow maturity.
- Prefer candidates that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
- Prefer small, implementation-ready slices over broad platform rewrites.
- If multiple candidates are plausible, choose one primary candidate and document why it was selected.
- Add non-selected relevant candidates as follow-up spec candidates, not hidden scope.
- Do not invent a candidate if existing roadmap/spec-candidate material provides a suitable one.
- Do not pick a spec only because it is listed first.
- Evaluate the Candidate Selection Gate before creating the spec directory.
Evaluate candidates using these criteria:
1. **Roadmap Fit**: Does it support the current roadmap sequence or unlock the next roadmap layer?
2. **Foundation Value**: Does it strengthen reusable platform foundations such as RBAC, isolation, auditability, evidence, OperationRun observability, provider boundaries, vocabulary, baseline/control/finding semantics, or enterprise UX patterns?
3. **Dependency Unblocking**: Does it make future specs smaller, safer, or more consistent?
4. **Scope Size**: Can it be implemented as a narrow, testable slice?
5. **Repo Readiness**: Does the repo already have enough structure to implement the next slice safely?
6. **Risk Reduction**: Does it reduce current architectural or product risk?
7. **User/Product Value**: Does it produce visible operator value or make the platform more sellable without heavy scope?
## Required Selection Output Before Spec Kit Execution
Before running the Spec Kit flow, identify:
- selected candidate title
- source location in roadmap/spec-candidates
- why it was selected
- why close alternatives were deferred
- roadmap relationship
- smallest viable implementation slice
- proposed concise feature description to feed into `specify`
The feature description must be product- and behavior-oriented. It should not be a low-level implementation plan.
## Spec Kit Preparation Flow
### Step 1: Determine the repository's Spec Kit command pattern
Inspect repository instructions and scripts to identify how this repo expects Spec Kit to be run.
Common locations to inspect:
```text
.specify/scripts/
.specify/templates/
.specify/memory/constitution.md
.github/prompts/
.github/skills/
README.md
specs/
```
Use the repo-specific mechanism if present.
### Step 2: Run `specify`
Run the repository's `specify` flow using the selected candidate and the smallest viable slice.
The `specify` input should include:
- selected candidate title
- problem statement
- operator/user value
- roadmap relationship
- out-of-scope boundaries
- key acceptance criteria
- important enterprise constraints
Let Spec Kit create the correct branch and spec location if that is the repo's configured behavior.
### Step 3: Run `plan`
Run the repository's `plan` flow for the generated spec.
The `plan` input should keep the scope tight and should require repo-based alignment with:
- constitution
- existing architecture
- workspace/tenant isolation
- RBAC
- OperationRun/observability where relevant
- evidence/snapshot/truth semantics where relevant
- Filament/Livewire conventions where relevant
- test strategy
### Step 4: Run `tasks`
Run the repository's `tasks` flow for the generated plan.
The generated tasks must be:
- ordered
- small
- testable
- grouped by phase
- limited to the selected scope
- suitable for later implementation or manual analysis before implementation
### Step 5: Run preparation `analyze`
Run the repository's `analyze` flow against the generated Spec Kit artifacts when the repository supports it.
Analyze must check:
- consistency between `spec.md`, `plan.md`, and `tasks.md`
- constitution alignment
- roadmap alignment
- whether the selected candidate was narrowed safely
- whether tasks are complete enough for implementation
- whether tasks accidentally require scope not described in the spec
- whether plan details conflict with repository architecture or terminology
- whether implementation risks are documented instead of silently ignored
Do not use analyze as a trigger to implement application code.
### Step 6: Fix preparation-artifact issues only
If preparation analyze finds issues, fix only Spec Kit preparation artifacts such as:
- `spec.md`
- `plan.md`
- `tasks.md`
- `checklists/requirements.md` or other generated Spec Kit metadata files, if the repository uses them
Allowed fixes include:
- clarify requirements
- tighten scope
- move out-of-scope work into follow-up candidates
- correct terminology
- add missing tasks
- remove tasks not backed by the spec
- align plan language with repository architecture
- add missing acceptance criteria or validation tasks
- add missing checklist artifacts required by the constitution
Forbidden fixes include:
- modifying application code
- creating migrations
- editing models, services, jobs, policies, Filament resources, Livewire components, tests, commands, routes, or views
- running implementation or test-fix loops
- changing runtime behavior
### Step 7: Evaluate the Spec Readiness Gate
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
Stop after this gate and do not implement.
## Spec Directory Rules
When creating a new spec directory, use the repository's Spec Kit-generated directory or path.
If the repository does not provide a command for spec setup, use the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions.
## `spec.md` Requirements
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
Include:
- Feature title
- Problem statement
- Business/product value
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but it must not make code changes by itself.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
The plan should clearly distinguish where relevant:
- execution truth
- artifact truth
- backup/snapshot truth
- recovery/evidence truth
- operator next action
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Preparation Scope Control
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
Do not force all follow-up candidates into the primary spec.
## Failure Handling
If a Spec Kit command or preparation analyze phase fails:
1. Stop at the relevant gate.
2. Report the failing command or phase.
3. Summarize the error.
4. Do not attempt implementation as a workaround.
5. Suggest the smallest safe next action.
If the branch or working tree state is unsafe:
1. Stop before running Spec Kit commands.
2. Report the current branch and relevant uncommitted files.
3. Ask the user to commit, stash, or move to a clean worktree.
## Final Response Requirements
Respond with:
1. Selected candidate and why it was chosen
2. Why close alternatives were deferred
3. Current branch after Spec Kit execution, if changed
4. Generated spec path
5. Files created or updated by Spec Kit
6. Preparation analyze result summary
7. Preparation-artifact fixes applied after analyze
8. Assumptions made
9. Open questions, if any
10. Candidate Selection Gate result
11. Spec Readiness Gate result
12. Recommended next implementation prompt
13. Explicit statement that no application implementation was performed
Keep the final response concise, but include enough detail for the user to continue immediately.
## Manual Review and Next-Step Prompts
Provide a ready-to-copy manual artifact review prompt like this, adapted to the generated spec branch/path:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-branch-or-spec-path>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
Also provide a ready-to-copy implementation prompt for the separate implementation skill after analyze has passed or preparation-artifact issues have been fixed:
```markdown
/spec-kit-implementation-loop
Implementiere die vorbereitete Spec `<spec-branch-or-spec-path>` streng anhand von `tasks.md`.
Danach Tests ausführen, Browser Smoke Test falls UI/user-facing betroffen ist, Post-Implementation Analyse durchführen und alle bestätigten In-Scope Findings unabhängig von Severity beheben, wenn safe und bounded.
Wiederhole test + browser smoke + analysis + fix bis keine In-Scope Findings mehr offen sind oder eine Stop Condition greift.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-next-best-prep.
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec.
Führe danach GitHub Spec Kit specify, plan, tasks und analyze in einem Rutsch aus.
Behebe alle analyze-Issues in den Spec-Kit-Artefakten.
Keine Application-Implementierung.
```
Expected behavior:
1. Inspect constitution, Spec Kit scripts/templates, specs, roadmap, and spec candidates.
2. Check branch and working tree safety.
3. Compare candidate suitability.
4. Select the next best candidate.
5. Evaluate the Candidate Selection Gate.
6. Run the repository's real Spec Kit `specify` flow, letting it handle branch/spec setup.
7. Run the repository's real Spec Kit `plan` flow.
8. Run the repository's real Spec Kit `tasks` flow.
9. Run the repository's real Spec Kit preparation `analyze` flow.
10. Fix analyze issues only in Spec Kit preparation artifacts.
11. Evaluate the Spec Readiness Gate.
12. Stop before application implementation.
13. Return selection rationale, branch/path summary, artifact summary, analyze summary, fixes applied, gates, and next implementation prompt.
```

View File

@ -1,294 +0,0 @@
---
name: spec-kit-one-shot-prep
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
---
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
Define the functionality provided by this skill, including detailed instructions and examples
---
name: spec-kit-one-shot-prep
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
---
# Skill: Spec Kit One-Shot Preparation
## Purpose
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
1. `spec.md`
2. `plan.md`
3. `tasks.md`
This skill prepares implementation work, but it must not perform implementation.
The intended workflow is:
```text
feature idea / roadmap item / spec candidate
→ one-shot spec + plan + tasks preparation
→ manual repo-based analysis/review
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
- a feature idea
- a spec candidate
- a roadmap item
- a product or UX requirement
- a governance/platform improvement
- an architecture cleanup candidate
- a refactoring preparation request
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
Typical user prompts:
```text
Mach daraus spec, plan und tasks in einem Rutsch.
```
```text
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
```
```text
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
```
```text
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
```
## Hard Rules
- Work strictly repo-based.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not expand scope beyond the provided feature idea.
- Do not invent architecture that conflicts with repository truth.
- Do not create broad platform rewrites when a smaller implementable spec is possible.
- Prefer small, reviewable, implementation-ready specs.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
## Required Inputs
The user should provide at least one of:
- feature title and short goal
- full spec candidate
- roadmap item
- rough problem statement
- UX or architecture improvement idea
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
## Required Repository Checks
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
Always check:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `specs/`
4. `docs/product/spec-candidates.md`
5. relevant roadmap documents under `docs/product/`
6. nearby existing specs with related terminology or scope
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
## Spec Directory Rules
Create a new spec directory using the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update only these preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
## `spec.md` Requirements
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
Include:
- Feature title
- Problem statement
- Business/product value
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but still must not implement.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
The plan should clearly distinguish:
- execution truth
- artifact truth
- backup/snapshot truth
- recovery/evidence truth
- operator next action
Use those distinctions only where relevant to the feature.
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Scope Control
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
Do not force all follow-up candidates into the primary spec.
## Final Response Requirements
After creating or updating the artifacts, respond with:
1. Created or updated spec directory
2. Files created or updated
3. Important repo-based adjustments made
4. Assumptions made
5. Open questions, if any
6. Recommended next manual analysis prompt
7. Explicit statement that no implementation was performed
Keep the final response concise, but include enough detail for the user to continue immediately.
## Required Next Manual Analysis Prompt
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
## Example Invocation
User:
```text
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
```
Expected behavior:
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
2. Determine the next valid spec number.
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
4. Keep scope tight.
5. Do not implement.
6. Return the summary and next manual analysis prompt.

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\ProductUsageEvent;
use Illuminate\Console\Command;
class PruneProductUsageEventsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'tenantpilot:product-usage:prune {--days= : Number of days to retain product usage events}';
/**
* @var string
*/
protected $description = 'Delete product usage events older than the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?: config('tenantpilot.product_usage_event_retention_days', 90));
if ($days < 1) {
$this->error('Retention days must be at least 1.');
return self::FAILURE;
}
$cutoff = now()->subDays($days);
$deleted = ProductUsageEvent::query()
->where('occurred_at', '<', $cutoff)
->delete();
$this->info("Deleted {$deleted} product usage event(s) older than {$days} days.");
return self::SUCCESS;
}
}

View File

@ -7,6 +7,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\OperationalControls\OperationalControlBlockedException;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -51,6 +52,14 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
reason: null, reason: null,
source: 'cli', 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) { } catch (ValidationException $e) {
$errors = $e->errors(); $errors = $e->errors();

View File

@ -7,6 +7,7 @@
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService; use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope; use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Services\Runbooks\RunbookReason; use App\Services\Runbooks\RunbookReason;
use App\Support\OperationalControls\OperationalControlBlockedException;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -31,6 +32,10 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
$this->info('Deploy runbooks started (if needed).'); $this->info('Deploy runbooks started (if needed).');
return self::SUCCESS;
} catch (OperationalControlBlockedException $e) {
$this->info('Deploy runbooks paused: '.$e->getMessage());
return self::SUCCESS; return self::SUCCESS;
} catch (ValidationException $e) { } catch (ValidationException $e) {
$errors = $e->errors(); $errors = $e->errors();

View File

@ -8,6 +8,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService; use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
@ -22,9 +23,13 @@
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling; use App\Support\OpsUx\RunDetailPolling;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\RedactionIntegrity; use App\Support\RedactionIntegrity;
use App\Support\RestoreSafety\RestoreSafetyCopy; use App\Support\RestoreSafety\RestoreSafetyCopy;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion; use App\Support\Tenants\TenantOperabilityQuestion;
@ -39,6 +44,7 @@
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedSchema; use Filament\Schemas\Components\EmbeddedSchema;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -81,6 +87,11 @@ public function getTitle(): string|Htmlable
*/ */
public ?array $navigationContextPayload = null; public ?array $navigationContextPayload = null;
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
/** /**
* @return array<Action|ActionGroup> * @return array<Action|ActionGroup>
*/ */
@ -130,6 +141,10 @@ protected function getHeaderActions(): array
? OperationRunLinks::tenantlessView($this->run, $navigationContext) ? OperationRunLinks::tenantlessView($this->run, $navigationContext)
: OperationRunLinks::index()); : OperationRunLinks::index());
if (isset($this->run)) {
$actions[] = $this->openSupportDiagnosticsAction();
}
if (! isset($this->run)) { if (! isset($this->run)) {
return $actions; return $actions;
} }
@ -208,6 +223,129 @@ public function monitoringDetailSummary(): array
]; ];
} }
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()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted operation context from existing records.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void {
$this->auditOperationSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->operationRunSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->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);
}
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);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->operationRunSupportDiagnosticBundle(),
user: $user,
);
}
private function supportDiagnosticsTenant(): ?Tenant
{
if (! isset($this->run)) {
return null;
}
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant) {
return $tenant;
}
return $this->run->loadMissing('tenant')->tenant;
}
/**
* @param array<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
if (! isset($this->run)) {
return;
}
$auditKey = 'operation:'.$this->run->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'operation_run',
bundle: $bundle,
actor: $user,
operationRun: $this->run,
);
app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'operation_run',
subjectId: (int) $this->run->getKey(),
metadata: [
'source_surface' => 'operation_run_viewer',
'operation_type' => (string) $this->run->type,
],
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
public function mount(OperationRun $run): void public function mount(OperationRun $run): void
{ {
$user = auth()->user(); $user = auth()->user();

View File

@ -11,13 +11,30 @@
use App\Filament\Widgets\Dashboard\RecentDriftFindings; use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Filament\Widgets\Dashboard\RecentOperations; use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Filament\Widgets\Dashboard\RecoveryReadiness; use App\Filament\Widgets\Dashboard\RecoveryReadiness;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Rbac\UiEnforcement;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Widgets\Widget; use Filament\Widgets\Widget;
use Filament\Widgets\WidgetConfiguration; use Filament\Widgets\WidgetConfiguration;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class TenantDashboard extends Dashboard class TenantDashboard extends Dashboard
{ {
/**
* @var list<string>
*/
public array $supportDiagnosticsAuditKeys = [];
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -46,4 +63,113 @@ public function getColumns(): int|array
{ {
return 2; return 2;
} }
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->openSupportDiagnosticsAction(),
];
}
private function openSupportDiagnosticsAction(): Action
{
$action = Action::make('openSupportDiagnostics')
->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy')
->color('gray')
->modal()
->slideOver()
->stickyModalHeader()
->modalHeading('Support diagnostics')
->modalDescription('Redacted tenant context from existing records.')
->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen();
})
->modalContent(fn (): View => view('filament.modals.support-diagnostic-bundle', [
'bundle' => $this->tenantSupportDiagnosticBundle(),
]));
return UiEnforcement::forAction($action)
->requireCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
->apply();
}
/**
* @return array<string, mixed>
*/
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);
}
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);
}
$this->recordSupportDiagnosticsOpened(
tenant: $tenant,
bundle: $this->tenantSupportDiagnosticBundle(),
user: $user,
);
}
/**
* @param array<string, mixed> $bundle
*/
private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, User $user): void
{
$auditKey = 'tenant:'.$tenant->getKey();
if (in_array($auditKey, $this->supportDiagnosticsAuditKeys, true)) {
return;
}
app(WorkspaceAuditLogger::class)->logSupportDiagnosticsOpened(
tenant: $tenant,
contextType: 'tenant',
bundle: $bundle,
actor: $user,
);
app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant',
subjectId: (int) $tenant->getKey(),
metadata: [
'source_surface' => 'tenant_dashboard',
],
);
$this->supportDiagnosticsAuditKeys[] = $auditKey;
}
} }

View File

@ -6,17 +6,18 @@
use App\Filament\Resources\FindingResource; use App\Filament\Resources\FindingResource;
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner; use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
use App\Filament\Widgets\Tenant\FindingStatsOverview; use App\Filament\Widgets\Tenant\FindingStatsOverview;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Services\OperationRunService; use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Filament\CanonicalAdminTenantFilterState; use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips; use App\Support\Rbac\UiTooltips;
use Filament\Actions; use Filament\Actions;
@ -107,83 +108,76 @@ protected function getHeaderActions(): array
{ {
$actions = []; $actions = [];
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) { $actions[] = UiEnforcement::forAction(
$actions[] = UiEnforcement::forAction( Actions\Action::make('backfill_lifecycle')
Actions\Action::make('backfill_lifecycle') ->label('Backfill findings lifecycle')
->label('Backfill findings lifecycle') ->icon('heroicon-o-wrench-screwdriver')
->icon('heroicon-o-wrench-screwdriver') ->color('gray')
->color('gray') ->requiresConfirmation()
->requiresConfirmation() ->modalHeading('Backfill findings lifecycle')
->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.')
->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 {
->action(function (OperationRunService $operationRuns): void { $user = auth()->user();
$user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
abort(404); abort(404);
} }
$opRun = $operationRuns->ensureRunWithIdentity( try {
tenant: $tenant, $opRun = $runbookService->start(
type: 'findings.lifecycle.backfill', scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
identityInputs: [
'tenant_id' => (int) $tenant->getKey(),
'trigger' => 'backfill',
],
context: [
'workspace_id' => (int) $tenant->workspace_id,
'initiator_user_id' => (int) $user->getKey(),
],
initiator: $user, initiator: $user,
reason: null,
source: 'tenant_ui',
); );
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
$runUrl = OperationRunLinks::view($opRun, $tenant); throw new \Filament\Support\Exceptions\Halt;
}
if ($opRun->wasRecentlyCreated === false) { $runUrl = OperationRunLinks::view($opRun, $tenant);
OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
return;
}
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
BackfillFindingLifecycleJob::dispatch(
tenantId: (int) $tenant->getKey(),
workspaceId: (int) $tenant->workspace_id,
initiatorUserId: (int) $user->getKey(),
);
});
if ($opRun->wasRecentlyCreated === false) {
OpsUxBrowserEvents::dispatchRunEnqueued($this); OpsUxBrowserEvents::dispatchRunEnqueued($this);
OperationUxPresenter::queuedToast((string) $opRun->type) OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->body('The backfill will run in the background. You can continue working while it completes.')
->actions([ ->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label('Open operation') ->label('Open operation')
->url($runUrl), ->url($runUrl),
]) ])
->send(); ->send();
})
) return;
->preserveVisibility() }
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) OpsUxBrowserEvents::dispatchRunEnqueued($this);
->apply();
} OperationUxPresenter::queuedToast((string) $opRun->type)
->body('The backfill will run in the background. You can continue working while it completes.')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url($runUrl),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply();
$actions[] = UiEnforcement::forAction( $actions[] = UiEnforcement::forAction(
Actions\Action::make('triage_all_matching') Actions\Action::make('triage_all_matching')

View File

@ -18,7 +18,9 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Rules\SkipOrUuidRule; use App\Rules\SkipOrUuidRule;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
@ -31,14 +33,18 @@
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver; use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Audit\AuditActionId;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets; use App\Support\Filament\FilterPresets;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter; use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency; use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus; use App\Support\RestoreRunStatus;
@ -1921,16 +1927,26 @@ public static function createRestoreRun(array $data): RestoreRun
->executionSafetySnapshot($tenant, $user, $data) ->executionSafetySnapshot($tenant, $user, $data)
->toArray(); ->toArray();
[$result, $restoreRun] = static::startQueuedRestoreExecution( try {
tenant: $tenant, [$result, $restoreRun] = static::startQueuedRestoreExecution(
backupSet: $backupSet, tenant: $tenant,
selectedItemIds: $selectedItemIds, backupSet: $backupSet,
preview: $preview, selectedItemIds: $selectedItemIds,
metadata: $metadata, preview: $preview,
groupMapping: $groupMapping, metadata: $metadata,
actorEmail: $actorEmail, groupMapping: $groupMapping,
actorName: $actorName, actorEmail: $actorEmail,
); actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
app(ProviderOperationStartResultPresenter::class) app(ProviderOperationStartResultPresenter::class)
->notification( ->notification(
@ -1978,6 +1994,13 @@ private static function startQueuedRestoreExecution(
$initiator = auth()->user(); $initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null; $initiator = $initiator instanceof User ? $initiator : null;
static::guardRestoreExecutionOperationalControl(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
initiator: $initiator,
);
$queuedRestoreRun = null; $queuedRestoreRun = null;
$dispatcher = function (OperationRun $run) use ( $dispatcher = function (OperationRun $run) use (
@ -2097,6 +2120,58 @@ private static function startQueuedRestoreExecution(
return [$result, $queuedRestoreRun?->refresh()]; return [$result, $queuedRestoreRun?->refresh()];
} }
/**
* @param array<int>|null $selectedItemIds
*/
private static function guardRestoreExecutionOperationalControl(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
?User $initiator,
): void {
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Restore execution requires a workspace context.');
}
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
if (! $decision->isPaused()) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: [
'metadata' => array_filter([
'control_key' => $decision->controlKey,
'scope_type' => $decision->matchedScopeType,
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => $decision->reasonText,
'expires_at' => $decision->expiresAt?->toIso8601String(),
'actor_id' => $initiator?->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
'requested_scope' => 'restore.execute',
], static fn (mixed $value): bool => $value !== null && $value !== ''),
],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label('restore.execute'),
summary: 'Restore execution blocked by operational control',
tenant: $tenant,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label('restore.execute'),
);
}
/** /**
* @param array<int>|null $selectedItemIds * @param array<int>|null $selectedItemIds
*/ */
@ -2529,16 +2604,26 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
$metadata['rerun_of_restore_run_id'] = $record->id; $metadata['rerun_of_restore_run_id'] = $record->id;
[$result, $newRun] = static::startQueuedRestoreExecution( try {
tenant: $tenant, [$result, $newRun] = static::startQueuedRestoreExecution(
backupSet: $backupSet, tenant: $tenant,
selectedItemIds: $selectedItemIds, backupSet: $backupSet,
preview: $preview, selectedItemIds: $selectedItemIds,
metadata: $metadata, preview: $preview,
groupMapping: $groupMapping, metadata: $metadata,
actorEmail: $actorEmail, groupMapping: $groupMapping,
actorName: $actorName, actorEmail: $actorEmail,
); actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
return;
}
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) { if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire); OpsUxBrowserEvents::dispatchRunEnqueued($livewire);

View File

@ -6,6 +6,7 @@
use App\Filament\System\Widgets\ControlTowerHealthIndicator; use App\Filament\System\Widgets\ControlTowerHealthIndicator;
use App\Filament\System\Widgets\ControlTowerKpis; use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures; use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders; use App\Filament\System\Widgets\ControlTowerTopOffenders;
use App\Models\PlatformUser; use App\Models\PlatformUser;
@ -61,9 +62,18 @@ public function getWidgets(): array
{ {
return [ return [
ControlTowerHealthIndicator::class, ControlTowerHealthIndicator::class,
ControlTowerKpis::class, new WidgetConfiguration(ControlTowerKpis::class, [
ControlTowerTopOffenders::class, 'window' => $this->window,
ControlTowerRecentFailures::class, ]),
new WidgetConfiguration(ProductTelemetryKpis::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerTopOffenders::class, [
'window' => $this->window,
]),
new WidgetConfiguration(ControlTowerRecentFailures::class, [
'window' => $this->window,
]),
]; ];
} }

View File

@ -0,0 +1,660 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Pages\Ops;
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Audit\AuditRecorder;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationalControls\OperationalControlCatalog;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
class Controls extends Page
{
protected static ?string $navigationLabel = 'Controls';
protected static ?string $title = 'Operational Controls';
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pause-circle';
protected static string|\UnitEnum|null $navigationGroup = 'Ops';
protected static ?string $slug = 'ops/controls';
protected string $view = 'filament.system.pages.ops.controls';
public static function canAccess(): bool
{
$user = auth('platform')->user();
if (! $user instanceof PlatformUser) {
return false;
}
return $user->hasCapability(PlatformCapabilities::ACCESS_SYSTEM_PANEL)
&& $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', [
'breadcrumbs' => filament()->hasBreadcrumbs() ? $this->getBreadcrumbs() : [],
'heading' => $this->getHeading(),
'subheading' => $this->getSubheading(),
]);
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
$this->pauseRestoreExecuteAction(),
$this->resumeRestoreExecuteAction(),
$this->viewHistoryRestoreExecuteAction(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function controlCards(): array
{
$catalog = app(OperationalControlCatalog::class);
return array_map(
fn (string $controlKey): array => $this->controlSummary($controlKey),
$catalog->keys(),
);
}
/**
* @return array<string, mixed>
*/
public function controlSummary(string $controlKey): array
{
$definition = app(OperationalControlCatalog::class)->definition($controlKey);
$activations = $this->activeActivationsForControl($controlKey);
$effectiveState = $activations->isEmpty() ? 'enabled' : 'paused';
$stateLabel = match (true) {
$activations->contains(fn (OperationalControlActivation $activation): bool => $activation->scope_type === 'global') => 'Paused globally',
$activations->isNotEmpty() => sprintf('Workspace pauses active (%d)', $activations->where('scope_type', 'workspace')->count()),
default => 'Enabled',
};
return [
'control_key' => $controlKey,
'action_slug' => $this->actionSlug($controlKey),
'label' => (string) $definition['label'],
'effective_state' => $effectiveState,
'state_label' => $stateLabel,
'supported_scopes' => $definition['supported_scopes'],
'affected_surfaces' => $definition['affected_surfaces'],
'active_activations' => $activations
->map(fn (OperationalControlActivation $activation): array => $this->activationSummary($activation))
->values()
->all(),
'history_count' => $this->recentAuditEventsForControl($controlKey)->count(),
];
}
/**
* @return array{control_key: string, scope_type: string, workspace_id: ?int, workspace_count: int, tenant_count: int, summary: string}
*/
public function scopeImpactPreview(string $controlKey, string $scopeType, ?int $workspaceId): array
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
if ($scopeType === 'workspace') {
$workspace = is_int($workspaceId)
? Workspace::query()->whereKey($workspaceId)->first()
: null;
if (! $workspace instanceof Workspace) {
return [
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => null,
'workspace_count' => 0,
'tenant_count' => 0,
'summary' => 'Select a workspace to preview the scope impact.',
];
}
$tenantCount = Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->where('external_id', '!=', 'platform')
->count();
return [
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => (int) $workspace->getKey(),
'workspace_count' => 1,
'tenant_count' => $tenantCount,
'summary' => sprintf('%s will affect workspace %s and %d %s.', $label, $workspace->name, $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
];
}
$tenantCount = Tenant::query()
->where('external_id', '!=', 'platform')
->count();
$workspaceCount = Tenant::query()
->where('external_id', '!=', 'platform')
->distinct('workspace_id')
->count('workspace_id');
return [
'control_key' => $controlKey,
'scope_type' => 'global',
'workspace_id' => null,
'workspace_count' => $workspaceCount,
'tenant_count' => $tenantCount,
'summary' => sprintf('%s will affect %d %s across %d %s.', $label, $workspaceCount, $workspaceCount === 1 ? 'workspace' : 'workspaces', $tenantCount, $tenantCount === 1 ? 'tenant' : 'tenants'),
];
}
public function pauseRestoreExecuteAction(): Action
{
return $this->pauseActionFor('restore.execute');
}
public function resumeRestoreExecuteAction(): Action
{
return $this->resumeActionFor('restore.execute');
}
public function viewHistoryRestoreExecuteAction(): Action
{
return $this->historyActionFor('restore.execute');
}
private function pauseActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('pause_'.$this->actionSlug($controlKey))
->label('Pause '.$label)
->icon('heroicon-o-pause')
->color('danger')
->requiresConfirmation()
->modalHeading('Pause '.$label)
->modalDescription('Review the scope impact, reason, and optional expiry before confirming this control change.')
->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);
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
(clone $scopeQuery)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->delete();
$activation = (clone $scopeQuery)->notExpired()->first();
$auditAction = $activation instanceof OperationalControlActivation
? AuditActionId::OperationalControlUpdated
: AuditActionId::OperationalControlPaused;
if ($activation instanceof OperationalControlActivation) {
$activation->fill([
'reason_text' => $reasonText,
'expires_at' => $expiresAt,
'updated_by_platform_user_id' => (int) $actor->getKey(),
])->save();
} else {
$activation = OperationalControlActivation::query()->create([
'control_key' => $controlKey,
'scope_type' => $scopeType,
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
'reason_text' => $reasonText,
'expires_at' => $expiresAt,
'created_by_platform_user_id' => (int) $actor->getKey(),
]);
}
$this->recordControlMutation(
auditAction: $auditAction,
activation: $activation,
actor: $actor,
auditRecorder: $auditRecorder,
workspaceAuditLogger: $workspaceAuditLogger,
);
Notification::make()
->title(sprintf('%s %s', $label, $auditAction === AuditActionId::OperationalControlPaused ? 'paused' : 'updated'))
->success()
->send();
});
}
private function resumeActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('resume_'.$this->actionSlug($controlKey))
->label('Resume '.$label)
->icon('heroicon-o-play')
->color('gray')
->requiresConfirmation()
->modalHeading('Resume '.$label)
->modalDescription('Remove the selected pause so new starts can proceed again.')
->form($this->resumeFormSchema($controlKey))
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
$actor = $this->controlsActor();
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
->notExpired()
->first();
if (! $activation instanceof OperationalControlActivation) {
Notification::make()
->title(sprintf('%s already enabled', $label))
->warning()
->send();
return;
}
$activationSnapshot = $activation->replicate();
$activationSnapshot->forceFill($activation->getAttributes());
$activation->delete();
$this->recordControlMutation(
auditAction: AuditActionId::OperationalControlResumed,
activation: $activationSnapshot,
actor: $actor,
auditRecorder: $auditRecorder,
workspaceAuditLogger: $workspaceAuditLogger,
);
Notification::make()
->title($label.' resumed')
->success()
->send();
});
}
private function historyActionFor(string $controlKey): Action
{
$label = app(OperationalControlCatalog::class)->label($controlKey);
return Action::make('view_history_'.$this->actionSlug($controlKey))
->label('View '.$label.' history')
->link()
->modalHeading($label.' history')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
->modalContent(fn () => view('filament.system.pages.ops.partials.operational-control-history', [
'events' => $this->recentAuditEventsForControl($controlKey),
'label' => $label,
]));
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function pauseFormSchema(string $controlKey): array
{
return [
Radio::make('scope_type')
->label('Scope')
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
Select::make('workspace_id')
->label('Workspace')
->searchable()
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
->live()
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Textarea::make('reason_text')
->label('Reason')
->required()
->minLength(5)
->maxLength(500)
->rows(4),
DateTimePicker::make('expires_at')
->label('Expires at')
->seconds(false)
->nullable(),
Placeholder::make('scope_preview')
->label('Scope impact preview')
->content(function (callable $get) use ($controlKey): string {
$preview = $this->scopeImpactPreview(
$controlKey,
(string) ($get('scope_type') ?? 'global'),
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
);
return (string) $preview['summary'];
}),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function resumeFormSchema(string $controlKey): array
{
return [
Radio::make('scope_type')
->label('Scope')
->options([
'global' => 'Global',
'workspace' => 'One workspace',
])
->default('global')
->live()
->required(),
Select::make('workspace_id')
->label('Workspace')
->searchable()
->visible(fn (callable $get): bool => $get('scope_type') === 'workspace')
->required(fn (callable $get): bool => $get('scope_type') === 'workspace')
->getSearchResultsUsing(function (string $search): array {
return Workspace::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(25)
->pluck('name', 'id')
->all();
})
->getOptionLabelUsing(function ($value): ?string {
if (! is_numeric($value)) {
return null;
}
return Workspace::query()->whereKey((int) $value)->value('name');
}),
Placeholder::make('scope_preview')
->label('Resume impact preview')
->content(function (callable $get) use ($controlKey): string {
$preview = $this->scopeImpactPreview(
$controlKey,
(string) ($get('scope_type') ?? 'global'),
is_numeric($get('workspace_id')) ? (int) $get('workspace_id') : null,
);
return (string) $preview['summary'];
}),
];
}
private function controlsActor(): PlatformUser
{
$actor = auth('platform')->user();
if (! $actor instanceof PlatformUser) {
abort(403);
}
if (! $actor->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE)) {
abort(403);
}
return $actor;
}
/**
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
*/
private function normalizePauseInput(array $data): array
{
[$scopeType, $workspace] = $this->resolveScopeInput($data);
$reasonText = trim((string) ($data['reason_text'] ?? ''));
if ($reasonText === '') {
throw ValidationException::withMessages([
'reason_text' => 'A reason is required.',
]);
}
$expiresAt = null;
if (filled($data['expires_at'] ?? null)) {
$expiresAt = Carbon::parse((string) $data['expires_at']);
if ($expiresAt->lessThanOrEqualTo(now())) {
throw ValidationException::withMessages([
'expires_at' => 'Expiry must be in the future.',
]);
}
}
return [$scopeType, $workspace, $reasonText, $expiresAt];
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function normalizeResumeInput(array $data): array
{
return $this->resolveScopeInput($data);
}
/**
* @return array{0: string, 1: ?Workspace}
*/
private function resolveScopeInput(array $data): array
{
$scopeType = (string) ($data['scope_type'] ?? 'global');
if (! in_array($scopeType, ['global', 'workspace'], true)) {
throw ValidationException::withMessages([
'scope_type' => 'Invalid scope selected.',
]);
}
if ($scopeType === 'global') {
return [$scopeType, null];
}
$workspaceId = $data['workspace_id'] ?? null;
if (! is_numeric($workspaceId)) {
throw ValidationException::withMessages([
'workspace_id' => 'A workspace is required for workspace scope.',
]);
}
$workspace = Workspace::query()->whereKey((int) $workspaceId)->first();
if (! $workspace instanceof Workspace) {
throw ValidationException::withMessages([
'workspace_id' => 'The selected workspace could not be found.',
]);
}
return [$scopeType, $workspace];
}
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
{
$query = OperationalControlActivation::query()
->forControl($controlKey)
->where('scope_type', $scopeType);
if ($scopeType === 'workspace') {
$query->where('workspace_id', (int) $workspace?->getKey());
} else {
$query->whereNull('workspace_id');
}
return $query;
}
private function recordControlMutation(
AuditActionId $auditAction,
OperationalControlActivation $activation,
PlatformUser $actor,
AuditRecorder $auditRecorder,
WorkspaceAuditLogger $workspaceAuditLogger,
): void {
$label = app(OperationalControlCatalog::class)->label((string) $activation->control_key);
$summary = sprintf('%s %s', $label, match ($auditAction) {
AuditActionId::OperationalControlPaused => 'paused',
AuditActionId::OperationalControlUpdated => 'updated',
AuditActionId::OperationalControlResumed => 'resumed',
default => 'changed',
});
$metadata = array_filter([
'control_key' => (string) $activation->control_key,
'scope_type' => (string) $activation->scope_type,
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
'reason_text' => $activation->reason_text,
'expires_at' => $activation->expires_at?->toIso8601String(),
'actor_id' => (int) $actor->getKey(),
], static fn (mixed $value): bool => $value !== null && $value !== '');
if ((string) $activation->scope_type === 'global') {
$auditRecorder->record(
action: $auditAction,
context: ['metadata' => $metadata],
actor: AuditActorSnapshot::platform($actor),
target: new AuditTargetSnapshot(
type: 'operational_control',
id: (string) $activation->getKey(),
label: $label,
),
outcome: 'success',
summary: $summary,
);
return;
}
$workspace = Workspace::query()->whereKey((int) $activation->workspace_id)->firstOrFail();
$workspaceAuditLogger->log(
workspace: $workspace,
action: $auditAction,
context: ['metadata' => $metadata],
actor: $actor,
status: 'success',
resourceType: 'operational_control',
resourceId: (string) $activation->getKey(),
targetLabel: $label,
summary: $summary,
);
}
/**
* @return Collection<int, OperationalControlActivation>
*/
private function activeActivationsForControl(string $controlKey): Collection
{
return OperationalControlActivation::query()
->forControl($controlKey)
->notExpired()
->with(['workspace', 'createdBy', 'updatedBy'])
->orderByRaw("CASE WHEN scope_type = 'global' THEN 0 ELSE 1 END")
->orderBy('workspace_id')
->orderBy('id')
->get();
}
/**
* @return array<string, mixed>
*/
private function activationSummary(OperationalControlActivation $activation): array
{
$owner = $activation->updatedBy ?? $activation->createdBy;
$workspaceName = $activation->workspace?->name;
return [
'id' => (int) $activation->getKey(),
'scope_type' => (string) $activation->scope_type,
'scope_label' => (string) $activation->scope_type === 'global'
? 'Global'
: sprintf('Workspace: %s', $workspaceName ?? '#'.(int) $activation->workspace_id),
'workspace_id' => is_numeric($activation->workspace_id) ? (int) $activation->workspace_id : null,
'workspace_name' => $workspaceName,
'reason_text' => (string) $activation->reason_text,
'expires_at' => $activation->expires_at?->toIso8601String(),
'expires_label' => $activation->expires_at?->diffForHumans() ?? 'No expiry',
'owner_name' => $owner?->name ?: $owner?->email ?: 'Unknown operator',
];
}
/**
* @return Collection<int, AuditLog>
*/
private function recentAuditEventsForControl(string $controlKey): Collection
{
return AuditLog::query()
->where('metadata->control_key', $controlKey)
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlUpdated->value,
AuditActionId::OperationalControlResumed->value,
AuditActionId::OperationalControlExecutionBlocked->value,
])
->latestFirst()
->limit(10)
->get();
}
private function actionSlug(string $controlKey): string
{
return str_replace('.', '_', $controlKey);
}
}

View File

@ -14,6 +14,7 @@
use App\Services\System\AllowedTenantUniverse; use App\Services\System\AllowedTenantUniverse;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\Radio; use Filament\Forms\Components\Radio;
@ -168,12 +169,22 @@ protected function getHeaderActions(): array
'reason_text' => $data['reason_text'] ?? null, 'reason_text' => $data['reason_text'] ?? null,
]); ]);
$run = $runbookService->start( try {
scope: $scope, $run = $runbookService->start(
initiator: $user, scope: $scope,
reason: $reason, initiator: $user,
source: 'system_ui', 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); $viewUrl = SystemOperationRunLinks::view($run);

View File

@ -19,12 +19,14 @@ class ControlTowerKpis extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/** /**
* @return array<Stat> * @return array<Stat>
*/ */
protected function getStats(): array protected function getStats(): array
{ {
$window = SystemConsoleWindow::fromNullable((string) request()->query('window')); $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$start = $window->startAt(); $start = $window->startAt();
$baseQuery = OperationRun::query()->where('created_at', '>=', $start); $baseQuery = OperationRun::query()->where('created_at', '>=', $start);

View File

@ -21,12 +21,14 @@ class ControlTowerRecentFailures extends Widget
protected string $view = 'filament.system.widgets.control-tower-recent-failures'; protected string $view = 'filament.system.widgets.control-tower-recent-failures';
public ?string $window = null;
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
protected function getViewData(): array protected function getViewData(): array
{ {
$window = SystemConsoleWindow::fromNullable((string) request()->query('window')); $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$start = $window->startAt(); $start = $window->startAt();
/** @var Collection<int, OperationRun> $runs */ /** @var Collection<int, OperationRun> $runs */

View File

@ -23,12 +23,14 @@ class ControlTowerTopOffenders extends Widget
protected string $view = 'filament.system.widgets.control-tower-top-offenders'; protected string $view = 'filament.system.widgets.control-tower-top-offenders';
public ?string $window = null;
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
protected function getViewData(): array protected function getViewData(): array
{ {
$window = SystemConsoleWindow::fromNullable((string) request()->query('window')); $window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$start = $window->startAt(); $start = $window->startAt();
/** @var Collection<int, OperationRun> $grouped */ /** @var Collection<int, OperationRun> $grouped */

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\System\Widgets;
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ProductTelemetryKpis extends StatsOverviewWidget
{
protected static bool $isLazy = false;
protected ?string $heading = 'Product telemetry';
protected int|string|array $columnSpan = 'full';
public ?string $window = null;
/**
* @return array<Stat>
*/
protected function getStats(): array
{
$window = SystemConsoleWindow::fromNullable($this->window ?? (string) request()->query('window'));
$windowLabel = SystemConsoleWindow::options()[$window->value] ?? 'Last 24 hours';
$summary = app(ProductTelemetrySummaryQuery::class)->summarize($window->startAt(), now());
$stats = [
Stat::make('Active workspaces', $summary['active_workspaces'])
->description($summary['total_events'] > 0
? sprintf('%d events in %s', $summary['total_events'], $windowLabel)
: sprintf('No telemetry recorded in %s.', $windowLabel))
->color($summary['active_workspaces'] > 0 ? 'primary' : 'gray'),
];
foreach ($summary['families'] as $family) {
$stats[] = Stat::make($family['label'], $family['count'])
->description($windowLabel)
->color($family['count'] > 0 ? 'primary' : 'gray');
}
return $stats;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\OperationalControlActivationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OperationalControlActivation extends Model
{
/** @use HasFactory<OperationalControlActivationFactory> */
use HasFactory;
protected $guarded = [];
protected $casts = [
'expires_at' => 'datetime',
];
protected static function newFactory(): OperationalControlActivationFactory
{
return OperationalControlActivationFactory::new();
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(PlatformUser::class, 'created_by_platform_user_id');
}
public function updatedBy(): BelongsTo
{
return $this->belongsTo(PlatformUser::class, 'updated_by_platform_user_id');
}
public function scopeForControl(Builder $query, string $controlKey): Builder
{
return $query->where('control_key', trim($controlKey));
}
public function scopeForGlobalScope(Builder $query): Builder
{
return $query->where('scope_type', 'global');
}
public function scopeForWorkspaceScope(Builder $query, int|Workspace $workspace): Builder
{
$workspaceId = $workspace instanceof Workspace
? (int) $workspace->getKey()
: (int) $workspace;
return $query
->where('scope_type', 'workspace')
->where('workspace_id', $workspaceId);
}
public function scopeNotExpired(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
}

View File

@ -0,0 +1,55 @@
<?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 ProductUsageEvent extends Model
{
use DerivesWorkspaceIdFromTenant;
/** @use HasFactory<\Database\Factories\ProductUsageEventFactory> */
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'metadata' => 'array',
'occurred_at' => 'datetime',
];
}
/**
* @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)->withTrashed();
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -5,6 +5,8 @@
namespace App\Services\Audit; namespace App\Services\Audit;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -23,7 +25,7 @@ public function log(
Workspace $workspace, Workspace $workspace,
string|AuditActionId $action, string|AuditActionId $action,
array $context = [], array $context = [],
?User $actor = null, User|PlatformUser|null $actor = null,
string $status = 'success', string $status = 'success',
?string $resourceType = null, ?string $resourceType = null,
?string $resourceId = null, ?string $resourceId = null,
@ -36,14 +38,16 @@ public function log(
?int $operationRunId = null, ?int $operationRunId = null,
?Tenant $tenant = null, ?Tenant $tenant = null,
): \App\Models\AuditLog { ): \App\Models\AuditLog {
$resolvedActor = $actor instanceof User $resolvedActor = match (true) {
? AuditActorSnapshot::human($actor) $actor instanceof User => AuditActorSnapshot::human($actor),
: AuditActorSnapshot::fromLegacy( $actor instanceof PlatformUser => AuditActorSnapshot::platform($actor),
default => AuditActorSnapshot::fromLegacy(
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context), type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
id: $actorId, id: $actorId,
email: $actorEmail, email: $actorEmail,
label: $actorName, label: $actorName,
); ),
};
return $this->auditRecorder->record( return $this->auditRecorder->record(
action: $action, action: $action,
@ -70,7 +74,7 @@ public function logTenantLifecycleAction(
Tenant $tenant, Tenant $tenant,
string|AuditActionId $action, string|AuditActionId $action,
array $context = [], array $context = [],
?User $actor = null, User|PlatformUser|null $actor = null,
string $status = 'success', string $status = 'success',
?string $summary = null, ?string $summary = null,
): \App\Models\AuditLog { ): \App\Models\AuditLog {
@ -87,4 +91,49 @@ public function logTenantLifecycleAction(
tenant: $tenant, tenant: $tenant,
); );
} }
/**
* @param array<string, mixed> $bundle
*/
public function logSupportDiagnosticsOpened(
Tenant $tenant,
string $contextType,
array $bundle,
User|PlatformUser|null $actor = null,
?OperationRun $operationRun = null,
): \App\Models\AuditLog {
$sectionCount = is_array($bundle['sections'] ?? null) ? count($bundle['sections']) : 0;
$referenceCount = collect($bundle['sections'] ?? [])
->sum(static fn (mixed $section): int => is_array($section) && is_array($section['references'] ?? null)
? count($section['references'])
: 0);
return $this->log(
workspace: $tenant->workspace,
action: AuditActionId::SupportDiagnosticsOpened,
context: [
'context_type' => $contextType,
'redaction_mode' => 'default_redacted',
'section_count' => $sectionCount,
'reference_count' => $referenceCount,
'primary_context_id' => $operationRun instanceof OperationRun
? (string) $operationRun->getKey()
: (string) $tenant->getKey(),
],
actor: $actor,
status: 'success',
resourceType: 'support_diagnostic_bundle',
resourceId: $operationRun instanceof OperationRun
? 'operation_run:'.$operationRun->getKey()
: 'tenant:'.$tenant->getKey(),
targetLabel: $operationRun instanceof OperationRun
? 'Support diagnostics for operation #'.$operationRun->getKey()
: 'Support diagnostics for '.$tenant->name,
summary: $operationRun instanceof OperationRun
? 'Support diagnostics opened for operation #'.$operationRun->getKey()
: 'Support diagnostics opened for '.$tenant->name,
operationRunId: $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
tenant: $tenant,
);
}
} }

View File

@ -19,6 +19,7 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE, Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE, Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
@ -63,6 +64,7 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE, Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
@ -103,6 +105,7 @@ class RoleCapabilityMap
TenantRole::Operator->value => [ TenantRole::Operator->value => [
Capabilities::TENANT_VIEW, Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,

View File

@ -9,6 +9,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use RuntimeException; use RuntimeException;
@ -18,6 +20,7 @@ public function __construct(
private readonly GraphClientInterface $graphClient, private readonly GraphClientInterface $graphClient,
private readonly HighPrivilegeRoleCatalog $catalog, private readonly HighPrivilegeRoleCatalog $catalog,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver, private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
/** /**
@ -57,6 +60,8 @@ public function generate(Tenant $tenant, ?OperationRun $operationRun = null): En
'previous_fingerprint' => $latestReport?->fingerprint, 'previous_fingerprint' => $latestReport?->fingerprint,
]); ]);
$this->recordStoredReportTelemetry($report, $operationRun);
return new EntraAdminRolesReportResult( return new EntraAdminRolesReportResult(
created: true, created: true,
storedReportId: (int) $report->getKey(), storedReportId: (int) $report->getKey(),
@ -192,4 +197,24 @@ private function resolvePrincipalType(array $principal): string
default => 'unknown', default => 'unknown',
}; };
} }
private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void
{
if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) {
return;
}
$this->productTelemetryRecorder->record(
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
workspaceId: (int) $report->workspace_id,
tenantId: (int) $report->tenant_id,
userId: (int) $operationRun->user_id,
subjectType: 'stored_report',
subjectId: (int) $report->getKey(),
metadata: [
'report_type' => $report->report_type,
],
occurredAt: $report->created_at ?? now(),
);
}
} }

View File

@ -179,5 +179,7 @@ private function persistDraft(TenantOnboardingSession $draft, bool $incrementVer
$this->lifecycleService->applySnapshot($draft, false); $this->lifecycleService->applySnapshot($draft, false);
$draft->save(); $draft->save();
$this->lifecycleService->recordCompletedCheckpointTelemetryIfNeeded($draft);
} }
} }

View File

@ -15,12 +15,15 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Verification\VerificationReportOverall; use App\Support\Verification\VerificationReportOverall;
class OnboardingLifecycleService class OnboardingLifecycleService
{ {
public function __construct( public function __construct(
private readonly TenantOperabilityService $tenantOperabilityService, private readonly TenantOperabilityService $tenantOperabilityService,
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $incrementVersion = false): TenantOnboardingSession
@ -35,6 +38,7 @@ public function syncPersistedLifecycle(TenantOnboardingSession $draft, bool $inc
if ($changed) { if ($changed) {
$freshDraft->save(); $freshDraft->save();
$this->recordCompletedCheckpointTelemetryIfNeeded($freshDraft);
} }
return $freshDraft->refresh(); return $freshDraft->refresh();
@ -94,6 +98,46 @@ public function applySnapshot(TenantOnboardingSession $draft, bool $incrementVer
return $changed; return $changed;
} }
public function recordCompletedCheckpointTelemetryIfNeeded(TenantOnboardingSession $draft): void
{
if (! $draft->wasChanged('last_completed_checkpoint')) {
return;
}
$checkpoint = $draft->last_completed_checkpoint instanceof OnboardingCheckpoint
? $draft->last_completed_checkpoint
: OnboardingCheckpoint::tryFrom((string) $draft->last_completed_checkpoint);
if (! $checkpoint instanceof OnboardingCheckpoint) {
return;
}
$workspaceId = (int) ($draft->workspace_id ?? 0);
$tenantId = (int) ($draft->tenant_id ?? 0);
$userId = (int) ($draft->updated_by_user_id ?? 0);
if ($workspaceId <= 0 || $tenantId <= 0 || $userId <= 0) {
return;
}
$occurredAt = $draft->updated_at ?? now();
$this->productTelemetryRecorder->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: $workspaceId,
tenantId: $tenantId,
userId: $userId,
subjectType: 'tenant_onboarding_session',
subjectId: (int) $draft->getKey(),
metadata: [
'checkpoint_key' => $checkpoint->value,
'lifecycle_state' => $draft->lifecycleState()->value,
'completed_at' => $occurredAt,
],
occurredAt: $occurredAt,
);
}
/** /**
* @return array{ * @return array{
* lifecycle_state: OnboardingLifecycleState, * lifecycle_state: OnboardingLifecycleState,

View File

@ -23,6 +23,8 @@
use App\Support\OpsUx\BulkRunContext; use App\Support\OpsUx\BulkRunContext;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason; use App\Support\RbacReason;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
@ -44,6 +46,7 @@ public function __construct(
private readonly AuditRecorder $auditRecorder, private readonly AuditRecorder $auditRecorder,
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver, private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
private readonly ReasonTranslator $reasonTranslator, private readonly ReasonTranslator $reasonTranslator,
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool public function isStaleQueuedRun(OperationRun $run, int $thresholdMinutes = 5): bool
@ -139,7 +142,7 @@ public function ensureRun(
// Create new run (race-safe via partial unique index) // Create new run (race-safe via partial unique index)
try { try {
return OperationRun::create([ $run = OperationRun::create([
'workspace_id' => $workspaceId, 'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $initiator?->id, 'user_id' => $initiator?->id,
@ -150,6 +153,10 @@ public function ensureRun(
'run_identity_hash' => $hash, 'run_identity_hash' => $hash,
'context' => $inputs, 'context' => $inputs,
]); ]);
$this->recordOperationStartedTelemetry($run, $initiator);
return $run;
} catch (QueryException $e) { } catch (QueryException $e) {
// Unique violation (active-run dedupe): // Unique violation (active-run dedupe):
// - PostgreSQL: 23505 // - PostgreSQL: 23505
@ -205,7 +212,7 @@ public function ensureRunWithIdentity(
// Create new run (race-safe via partial unique index) // Create new run (race-safe via partial unique index)
try { try {
return OperationRun::create([ $run = OperationRun::create([
'workspace_id' => $workspaceId, 'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $initiator?->id, 'user_id' => $initiator?->id,
@ -216,6 +223,10 @@ public function ensureRunWithIdentity(
'run_identity_hash' => $hash, 'run_identity_hash' => $hash,
'context' => $context, 'context' => $context,
]); ]);
$this->recordOperationStartedTelemetry($run, $initiator);
return $run;
} catch (QueryException $e) { } catch (QueryException $e) {
// Unique violation (active-run dedupe): // Unique violation (active-run dedupe):
// - PostgreSQL: 23505 // - PostgreSQL: 23505
@ -336,7 +347,7 @@ public function ensureRunWithIdentityStrict(
} }
try { try {
return OperationRun::create([ $run = OperationRun::create([
'workspace_id' => $workspaceId, 'workspace_id' => $workspaceId,
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'user_id' => $initiator?->id, 'user_id' => $initiator?->id,
@ -347,6 +358,10 @@ public function ensureRunWithIdentityStrict(
'run_identity_hash' => $hash, 'run_identity_hash' => $hash,
'context' => $context, 'context' => $context,
]); ]);
$this->recordOperationStartedTelemetry($run, $initiator);
return $run;
} catch (QueryException $e) { } catch (QueryException $e) {
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) { if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
throw $e; throw $e;
@ -1032,6 +1047,30 @@ private function normalizeExecutionContext(string $type, array $context, ?User $
return $context; return $context;
} }
private function recordOperationStartedTelemetry(OperationRun $run, ?User $initiator): void
{
if (! $initiator instanceof User) {
return;
}
if (! is_numeric($run->workspace_id) || ! is_numeric($run->tenant_id)) {
return;
}
$this->productTelemetryRecorder->record(
eventName: ProductUsageEventCatalog::OPERATIONS_STARTED,
workspaceId: (int) $run->workspace_id,
tenantId: (int) $run->tenant_id,
userId: (int) $initiator->getKey(),
subjectType: 'operation_run',
subjectId: (int) $run->getKey(),
metadata: [
'operation_type' => (string) $run->type,
],
occurredAt: $run->created_at ?? now(),
);
}
/** /**
* Normalize inputs for stable identity hashing. * Normalize inputs for stable identity hashing.
* *

View File

@ -11,6 +11,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Findings\FindingSlaPolicy; use App\Services\Findings\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
/** /**
@ -22,6 +24,7 @@ final class PermissionPostureFindingGenerator implements FindingGeneratorContrac
public function __construct( public function __construct(
private readonly PostureScoreCalculator $scoreCalculator, private readonly PostureScoreCalculator $scoreCalculator,
private readonly FindingSlaPolicy $slaPolicy, private readonly FindingSlaPolicy $slaPolicy,
private readonly ProductTelemetryRecorder $productTelemetryRecorder,
private readonly ?FindingWorkflowService $findingWorkflowService = null, private readonly ?FindingWorkflowService $findingWorkflowService = null,
) {} ) {}
@ -94,6 +97,7 @@ public function generate(Tenant $tenant, array $permissionComparison, ?Operation
$postureScore = $this->scoreCalculator->calculate($permissionComparison); $postureScore = $this->scoreCalculator->calculate($permissionComparison);
$report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore); $report = $this->createStoredReport($tenant, $permissionComparison, $permissions, $postureScore);
$this->recordStoredReportTelemetry($report, $operationRun);
return new PostureResult( return new PostureResult(
findingsCreated: $created, findingsCreated: $created,
@ -404,6 +408,26 @@ private function createStoredReport(
]); ]);
} }
private function recordStoredReportTelemetry(StoredReport $report, ?OperationRun $operationRun): void
{
if (! $operationRun instanceof OperationRun || ! is_numeric($operationRun->user_id)) {
return;
}
$this->productTelemetryRecorder->record(
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
workspaceId: (int) $report->workspace_id,
tenantId: (int) $report->tenant_id,
userId: (int) $operationRun->user_id,
subjectType: 'stored_report',
subjectId: (int) $report->getKey(),
metadata: [
'report_type' => $report->report_type,
],
occurredAt: $report->created_at ?? now(),
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */

View File

@ -17,6 +17,8 @@
use App\Services\Evidence\EvidenceSnapshotResolver; use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
@ -26,6 +28,7 @@ public function __construct(
private OperationRunService $operationRunService, private OperationRunService $operationRunService,
private EvidenceSnapshotResolver $snapshotResolver, private EvidenceSnapshotResolver $snapshotResolver,
private WorkspaceAuditLogger $auditLogger, private WorkspaceAuditLogger $auditLogger,
private ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
/** /**
@ -51,7 +54,10 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options); $fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
$existing = $this->findExistingPack($tenant, $fingerprint); $existing = $this->findExistingPack($tenant, $fingerprint);
if ($existing instanceof ReviewPack) { if ($existing instanceof ReviewPack) {
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant');
return $existing; return $existing;
} }
@ -70,6 +76,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
$queuedPack = $this->findPackForRun($tenant, $operationRun); $queuedPack = $this->findPackForRun($tenant, $operationRun);
if ($queuedPack instanceof ReviewPack) { if ($queuedPack instanceof ReviewPack) {
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant');
return $queuedPack; return $queuedPack;
} }
} }
@ -109,6 +117,8 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
); );
}); });
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant');
return $reviewPack; return $reviewPack;
} }
@ -134,6 +144,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
if ($existing instanceof ReviewPack) { if ($existing instanceof ReviewPack) {
$this->logReviewExport($review, $user, $existing, 'reused'); $this->logReviewExport($review, $user, $existing, 'reused');
$this->recordReviewPackRequestTelemetry($existing, $user, 'tenant_review');
return $existing; return $existing;
} }
@ -155,6 +166,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
if ($queuedPack instanceof ReviewPack) { if ($queuedPack instanceof ReviewPack) {
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run'); $this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
$this->recordReviewPackRequestTelemetry($queuedPack, $user, 'tenant_review');
return $queuedPack; return $queuedPack;
} }
@ -198,6 +210,7 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
}); });
$this->logReviewExport($review, $user, $reviewPack, 'queued'); $this->logReviewExport($review, $user, $reviewPack, 'queued');
$this->recordReviewPackRequestTelemetry($reviewPack, $user, 'tenant_review');
return $reviewPack; return $reviewPack;
} }
@ -226,6 +239,24 @@ public function generateDownloadUrl(ReviewPack $pack): string
); );
} }
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
{
$this->productTelemetryRecorder->record(
eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
workspaceId: (int) $reviewPack->workspace_id,
tenantId: (int) $reviewPack->tenant_id,
userId: (int) $user->getKey(),
subjectType: 'review_pack',
subjectId: (int) $reviewPack->getKey(),
metadata: [
'source_surface' => $sourceSurface,
'include_operations' => (bool) ($reviewPack->options['include_operations'] ?? false),
'include_pii' => (bool) ($reviewPack->options['include_pii'] ?? false),
],
occurredAt: $reviewPack->created_at ?? now(),
);
}
/** /**
* Find an existing ready, non-expired pack with the same fingerprint. * Find an existing ready, non-expired pack with the same fingerprint.
*/ */

View File

@ -10,15 +10,24 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunCompleted;
use App\Services\Alerts\AlertDispatchService; use App\Services\Alerts\AlertDispatchService;
use App\Services\Audit\AuditRecorder;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\BreakGlassSession; use App\Services\Auth\BreakGlassSession;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\System\AllowedTenantUniverse; 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\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -35,6 +44,9 @@ public function __construct(
private readonly OperationRunService $operationRunService, private readonly OperationRunService $operationRunService,
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly AlertDispatchService $alertDispatchService, private readonly AlertDispatchService $alertDispatchService,
private readonly OperationalControlEvaluator $operationalControls,
private readonly AuditRecorder $auditRecorder,
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
) {} ) {}
/** /**
@ -48,6 +60,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array
action: 'platform.ops.runbooks.preflight', action: 'platform.ops.runbooks.preflight',
scope: $scope, scope: $scope,
operationRunId: null, operationRunId: null,
initiator: null,
context: [ context: [
'preflight' => $result, 'preflight' => $result,
], ],
@ -58,7 +71,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array
public function start( public function start(
FindingsLifecycleBackfillScope $scope, FindingsLifecycleBackfillScope $scope,
?PlatformUser $initiator, User|PlatformUser|null $initiator,
?RunbookReason $reason, ?RunbookReason $reason,
string $source, string $source,
): OperationRun { ): OperationRun {
@ -88,13 +101,41 @@ public function start(
]); ]);
} }
$platformTenant = $this->platformTenant(); $workspace = null;
$workspace = $platformTenant->workspace; $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) { if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Platform tenant is missing its 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()) { if ($scope->isAllTenants()) {
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey()); $lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
$lock = Cache::lock($lockKey, 900); $lock = Cache::lock($lockKey, 900);
@ -120,7 +161,7 @@ public function start(
} }
return $this->startSingleTenant( return $this->startSingleTenant(
tenantId: (int) $scope->tenantId, tenant: $tenant,
initiator: $initiator, initiator: $initiator,
reason: $reason, reason: $reason,
preflight: $preflight, preflight: $preflight,
@ -327,7 +368,7 @@ private function countDriftDuplicateConsolidations(Tenant $tenant): int
private function startAllTenants( private function startAllTenants(
Workspace $workspace, Workspace $workspace,
?PlatformUser $initiator, User|PlatformUser|null $initiator,
?RunbookReason $reason, ?RunbookReason $reason,
array $preflight, array $preflight,
string $source, string $source,
@ -349,7 +390,7 @@ private function startAllTenants(
source: $source, source: $source,
isBreakGlassActive: $isBreakGlassActive, isBreakGlassActive: $isBreakGlassActive,
), ),
initiator: null, initiator: $initiator instanceof User ? $initiator : null,
); );
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
@ -361,6 +402,7 @@ private function startAllTenants(
action: 'platform.ops.runbooks.start', action: 'platform.ops.runbooks.start',
scope: FindingsLifecycleBackfillScope::allTenants(), scope: FindingsLifecycleBackfillScope::allTenants(),
operationRunId: (int) $run->getKey(), operationRunId: (int) $run->getKey(),
initiator: $initiator,
context: [ context: [
'preflight' => $preflight, 'preflight' => $preflight,
'is_break_glass' => $isBreakGlassActive, 'is_break_glass' => $isBreakGlassActive,
@ -382,15 +424,16 @@ private function startAllTenants(
} }
private function startSingleTenant( private function startSingleTenant(
int $tenantId, ?Tenant $tenant,
?PlatformUser $initiator, User|PlatformUser|null $initiator,
?RunbookReason $reason, ?RunbookReason $reason,
array $preflight, array $preflight,
string $source, string $source,
bool $isBreakGlassActive, bool $isBreakGlassActive,
): OperationRun { ): OperationRun {
$tenant = Tenant::query()->whereKey($tenantId)->firstOrFail(); if (! $tenant instanceof Tenant) {
$this->allowedTenantUniverse->ensureAllowed($tenant); throw new \RuntimeException('Target tenant is required for single-tenant runs.');
}
$run = $this->operationRunService->ensureRunWithIdentity( $run = $this->operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
@ -408,7 +451,7 @@ private function startSingleTenant(
source: $source, source: $source,
isBreakGlassActive: $isBreakGlassActive, isBreakGlassActive: $isBreakGlassActive,
), ),
initiator: null, initiator: $initiator instanceof User ? $initiator : null,
); );
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) { if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
@ -420,6 +463,7 @@ private function startSingleTenant(
action: 'platform.ops.runbooks.start', action: 'platform.ops.runbooks.start',
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()), scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
operationRunId: (int) $run->getKey(), operationRunId: (int) $run->getKey(),
initiator: $initiator,
context: [ context: [
'preflight' => $preflight, 'preflight' => $preflight,
'is_break_glass' => $isBreakGlassActive, 'is_break_glass' => $isBreakGlassActive,
@ -458,7 +502,7 @@ private function platformTenant(): Tenant
private function buildRunContext( private function buildRunContext(
int $workspaceId, int $workspaceId,
FindingsLifecycleBackfillScope $scope, FindingsLifecycleBackfillScope $scope,
?PlatformUser $initiator, User|PlatformUser|null $initiator,
?RunbookReason $reason, ?RunbookReason $reason,
array $preflight, array $preflight,
string $source, string $source,
@ -490,6 +534,12 @@ private function buildRunContext(
'name' => (string) $initiator->name, 'name' => (string) $initiator->name,
'is_break_glass' => $isBreakGlassActive, '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; return $context;
@ -514,23 +564,10 @@ private function auditSafely(
string $action, string $action,
FindingsLifecycleBackfillScope $scope, FindingsLifecycleBackfillScope $scope,
?int $operationRunId, ?int $operationRunId,
User|PlatformUser|null $initiator,
array $context = [], array $context = [],
): void { ): void {
try { try {
$platformTenant = $this->platformTenant();
$actor = auth('platform')->user();
$actorId = null;
$actorEmail = null;
$actorName = null;
if ($actor instanceof PlatformUser) {
$actorId = (int) $actor->getKey();
$actorEmail = (string) $actor->email;
$actorName = (string) $actor->name;
}
$metadata = [ $metadata = [
'runbook_key' => self::RUNBOOK_KEY, 'runbook_key' => self::RUNBOOK_KEY,
'scope' => $scope->mode, 'scope' => $scope->mode,
@ -540,6 +577,37 @@ private function auditSafely(
'user_agent' => request()->userAgent(), '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( $this->auditLogger->log(
tenant: $platformTenant, tenant: $platformTenant,
action: $action, action: $action,
@ -558,6 +626,68 @@ private function auditSafely(
} }
} }
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 private function notifyInitiatorSafely(OperationRun $run): void
{ {
try { try {

View File

@ -99,6 +99,12 @@ enum AuditActionId: string
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed'; case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed'; case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
case OperationalControlPaused = 'operational_control.paused';
case OperationalControlUpdated = 'operational_control.updated';
case OperationalControlResumed = 'operational_control.resumed';
case OperationalControlExecutionBlocked = 'operational_control.execution_blocked';
// Workspace selection / switch events (Spec 107). // Workspace selection / switch events (Spec 107).
case WorkspaceAutoSelected = 'workspace.auto_selected'; case WorkspaceAutoSelected = 'workspace.auto_selected';
case WorkspaceSelected = 'workspace.selected'; case WorkspaceSelected = 'workspace.selected';
@ -234,6 +240,11 @@ private static function labels(): array
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed', self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed', self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',
self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution',
'baseline.capture.started' => 'Baseline capture started', 'baseline.capture.started' => 'Baseline capture started',
'baseline.capture.completed' => 'Baseline capture completed', 'baseline.capture.completed' => 'Baseline capture completed',
'baseline.capture.failed' => 'Baseline capture failed', 'baseline.capture.failed' => 'Baseline capture failed',
@ -315,6 +326,11 @@ private static function summaries(): array
self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::OperationalControlPaused->value => 'Operational control paused',
self::OperationalControlUpdated->value => 'Operational control updated',
self::OperationalControlResumed->value => 'Operational control resumed',
self::OperationalControlExecutionBlocked->value => 'Operational control blocked execution',
]; ];
} }

View File

@ -69,6 +69,9 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync'; public const TENANT_SYNC = 'tenant.sync';
// Support diagnostics
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
// Inventory // Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run'; public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';

View File

@ -30,6 +30,8 @@ class PlatformCapabilities
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill'; public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
/** /**
* @return array<string> * @return array<string>
*/ */

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Support\OperationalControls;
use RuntimeException;
final class OperationalControlBlockedException extends RuntimeException
{
private function __construct(
public readonly OperationalControlDecision $decision,
public readonly string $actionLabel,
) {
$message = trim($decision->reasonText ?? '');
parent::__construct($message !== ''
? sprintf('%s is currently paused. %s', $actionLabel, $message)
: sprintf('%s is currently paused.', $actionLabel));
}
public static function forDecision(OperationalControlDecision $decision, string $actionLabel): self
{
return new self($decision, $actionLabel);
}
public function title(): string
{
return sprintf('%s paused', $this->actionLabel);
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Support\OperationalControls;
final class OperationalControlCatalog
{
/**
* @var array<string, array{key: string, label: string, supported_scopes: array<int, string>, operation_types: array<int, string>, affected_surfaces: array<int, string>}>
*/
private const DEFINITIONS = [
'restore.execute' => [
'key' => 'restore.execute',
'label' => 'Restore execution',
'supported_scopes' => ['global', 'workspace'],
'operation_types' => ['restore.execute'],
'affected_surfaces' => ['tenant.restore_runs.create'],
],
];
/**
* @return array<int, string>
*/
public function keys(): array
{
return array_keys(self::DEFINITIONS);
}
/**
* @return array<string, array<string, mixed>>
*/
public function definitions(): array
{
return self::DEFINITIONS;
}
/**
* @return array{key: string, label: string, supported_scopes: array<int, string>, operation_types: array<int, string>, affected_surfaces: array<int, string>}
*/
public function definition(string $controlKey): array
{
$controlKey = trim($controlKey);
if (! array_key_exists($controlKey, self::DEFINITIONS)) {
throw new \InvalidArgumentException("Unknown operational control [{$controlKey}].");
}
return self::DEFINITIONS[$controlKey];
}
public function label(string $controlKey): string
{
return $this->definition($controlKey)['label'];
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Support\OperationalControls;
use Carbon\CarbonInterface;
final class OperationalControlDecision
{
public function __construct(
public readonly string $controlKey,
public readonly string $effectiveState,
public readonly string $matchedScopeType,
public readonly ?int $workspaceId,
public readonly ?string $reasonText,
public readonly ?CarbonInterface $expiresAt,
public readonly ?int $sourceActivationId,
) {}
public static function enabled(string $controlKey): self
{
return new self(
controlKey: $controlKey,
effectiveState: 'enabled',
matchedScopeType: 'none',
workspaceId: null,
reasonText: null,
expiresAt: null,
sourceActivationId: null,
);
}
public static function paused(
string $controlKey,
string $matchedScopeType,
?int $workspaceId,
?string $reasonText,
?CarbonInterface $expiresAt,
?int $sourceActivationId,
): self {
return new self(
controlKey: $controlKey,
effectiveState: 'paused',
matchedScopeType: $matchedScopeType,
workspaceId: $workspaceId,
reasonText: $reasonText,
expiresAt: $expiresAt,
sourceActivationId: $sourceActivationId,
);
}
public function isEnabled(): bool
{
return $this->effectiveState === 'enabled';
}
public function isPaused(): bool
{
return $this->effectiveState === 'paused';
}
public function hasWorkspaceScope(): bool
{
return $this->matchedScopeType === 'workspace' && $this->workspaceId !== null;
}
public function scopeLabel(): string
{
return match ($this->matchedScopeType) {
'global' => 'Global',
'workspace' => $this->workspaceId !== null ? 'Workspace #'.$this->workspaceId : 'Workspace',
default => 'No active pause',
};
}
public function expiresAtIso8601(): ?string
{
return $this->expiresAt?->toIso8601String();
}
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Support\OperationalControls;
use App\Models\OperationalControlActivation;
use App\Models\Workspace;
final class OperationalControlEvaluator
{
public function __construct(
private readonly OperationalControlCatalog $catalog,
) {}
public function evaluate(string $controlKey, Workspace|int|null $workspace = null): OperationalControlDecision
{
$definition = $this->catalog->definition($controlKey);
$workspaceId = $workspace instanceof Workspace
? (int) $workspace->getKey()
: (is_int($workspace) ? $workspace : null);
$globalActivation = OperationalControlActivation::query()
->forControl($definition['key'])
->forGlobalScope()
->notExpired()
->latest('id')
->first();
if ($globalActivation instanceof OperationalControlActivation) {
return OperationalControlDecision::paused(
controlKey: $definition['key'],
matchedScopeType: 'global',
workspaceId: null,
reasonText: $globalActivation->reason_text,
expiresAt: $globalActivation->expires_at,
sourceActivationId: (int) $globalActivation->getKey(),
);
}
if ($workspaceId !== null) {
$workspaceActivation = OperationalControlActivation::query()
->forControl($definition['key'])
->forWorkspaceScope($workspaceId)
->notExpired()
->latest('id')
->first();
if ($workspaceActivation instanceof OperationalControlActivation) {
return OperationalControlDecision::paused(
controlKey: $definition['key'],
matchedScopeType: 'workspace',
workspaceId: $workspaceId,
reasonText: $workspaceActivation->reason_text,
expiresAt: $workspaceActivation->expires_at,
sourceActivationId: (int) $workspaceActivation->getKey(),
);
}
}
return OperationalControlDecision::enabled($definition['key']);
}
}

View File

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Support\Links\RequiredPermissionsLinks;
use InvalidArgumentException;
final class ContextualHelpCatalog
{
public const string ADMIN_CONSENT_REQUIRED = 'admin-consent-required';
public const string REQUIRED_PERMISSIONS_MISSING = 'required-permissions-missing';
public const string CONNECTION_UNHEALTHY = 'connection-unhealthy';
public const string VERIFICATION_STALE = 'verification-stale';
public const string VERIFICATION_FAILED = 'verification-failed';
public const string DIAGNOSTIC_EVIDENCE_INCOMPLETE = 'diagnostic-evidence-incomplete';
public const string RETRYABLE_PROVIDER_FAILURE = 'retryable-provider-failure';
public const string MANUAL_HANDOFF_REQUIRED = 'manual-handoff-required';
public const string LINK_RESOLVER_ADMIN_CONSENT_PRIMARY = 'admin_consent_primary';
public const string LINK_RESOLVER_REQUIRED_PERMISSIONS = 'required_permissions';
/**
* @return list<string>
*/
public function keys(): array
{
return array_keys($this->definitions());
}
/**
* @return array<string, 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 definitions(): array
{
return [
self::ADMIN_CONSENT_REQUIRED => [
'topic_key' => self::ADMIN_CONSENT_REQUIRED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Admin consent required',
'short_explanation' => 'This workflow is blocked until admin consent is granted for the current provider connection.',
'troubleshooting_steps' => [
'Grant admin consent for the current provider connection.',
'Re-run verification or reopen support diagnostics after consent completes.',
],
'safe_next_action' => 'Grant admin consent and re-run verification.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [
[
'label' => 'Grant admin consent',
'kind' => 'action',
'resolver' => self::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
],
[
'label' => 'Admin consent guide',
'kind' => 'docs',
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
],
],
],
self::REQUIRED_PERMISSIONS_MISSING => [
'topic_key' => self::REQUIRED_PERMISSIONS_MISSING,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Required permissions missing',
'short_explanation' => 'The provider app is missing one or more required permissions for the current workflow.',
'troubleshooting_steps' => [
'Review the required permissions matrix for the current tenant.',
'Refresh verification after the missing permissions are granted.',
],
'safe_next_action' => 'Open required permissions and confirm the missing grants.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [
[
'label' => 'Open required permissions',
'kind' => 'action',
'resolver' => self::LINK_RESOLVER_REQUIRED_PERMISSIONS,
],
],
],
self::CONNECTION_UNHEALTHY => [
'topic_key' => self::CONNECTION_UNHEALTHY,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Provider connection needs review',
'short_explanation' => 'The provider connection is degraded or unavailable, so the current result cannot be treated as healthy.',
'troubleshooting_steps' => [
'Review the latest provider connection health signal.',
'Retry the workflow after the provider connection is healthy again.',
],
'safe_next_action' => 'Review the provider connection before retrying.',
'glossary_terms' => ['tenant', 'workspace', 'provider connection'],
'docs_links' => [],
],
self::VERIFICATION_STALE => [
'topic_key' => self::VERIFICATION_STALE,
'surface_families' => ['onboarding'],
'headline' => 'Verification result is stale',
'short_explanation' => 'The most recent verification result is too old or mismatched to trust for the current onboarding decision.',
'troubleshooting_steps' => [
'Run verification again for the currently selected provider connection.',
'Use the refreshed result before continuing onboarding.',
],
'safe_next_action' => 'Refresh verification before continuing onboarding.',
'glossary_terms' => ['tenant', 'workspace', 'verification'],
'docs_links' => [],
],
self::VERIFICATION_FAILED => [
'topic_key' => self::VERIFICATION_FAILED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Verification failed',
'short_explanation' => 'The latest verification run did not produce a decision-grade result for the current tenant context.',
'troubleshooting_steps' => [
'Review the blocking reason before retrying verification.',
'Confirm the prerequisite is fixed, then run verification again.',
],
'safe_next_action' => 'Review the blocking reason and retry verification.',
'glossary_terms' => ['tenant', 'workspace', 'verification'],
'docs_links' => [],
],
self::DIAGNOSTIC_EVIDENCE_INCOMPLETE => [
'topic_key' => self::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
'surface_families' => ['support_diagnostics'],
'headline' => 'Diagnostic evidence is incomplete',
'short_explanation' => 'Support diagnostics can summarize the issue, but the available evidence is not complete enough for a final conclusion.',
'troubleshooting_steps' => [
'Review the available evidence and supporting references in the current support context.',
'Collect a fresh verification or operation result before making a final decision.',
],
'safe_next_action' => 'Collect a fresher or more complete diagnostic signal.',
'glossary_terms' => ['support diagnostics', 'evidence', 'workspace'],
'docs_links' => [],
],
self::RETRYABLE_PROVIDER_FAILURE => [
'topic_key' => self::RETRYABLE_PROVIDER_FAILURE,
'surface_families' => ['support_diagnostics'],
'headline' => 'Provider failure looks retryable',
'short_explanation' => 'The current provider issue appears temporary, so the next safe step is to retry once the dependency recovers.',
'troubleshooting_steps' => [
'Confirm the provider dependency has recovered.',
'Retry the workflow after the provider-side issue clears.',
],
'safe_next_action' => 'Retry after the provider dependency recovers.',
'glossary_terms' => ['support diagnostics', 'provider connection', 'workspace'],
'docs_links' => [],
],
self::MANUAL_HANDOFF_REQUIRED => [
'topic_key' => self::MANUAL_HANDOFF_REQUIRED,
'surface_families' => ['support_diagnostics'],
'headline' => 'Manual support handoff required',
'short_explanation' => 'TenantPilot can summarize the current issue, but a human support handoff is still required for the next step.',
'troubleshooting_steps' => [
'Review the current references before handing off the case.',
'Capture the safe next step and the supporting evidence for the receiving operator.',
],
'safe_next_action' => 'Hand off the case with the current diagnostic summary and supporting references.',
'glossary_terms' => ['support diagnostics', 'tenant', 'workspace'],
'docs_links' => [],
],
];
}
/**
* @return 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 definition(string $topicKey): array
{
$definition = $this->definitions()[trim($topicKey)] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException('Unknown contextual help topic');
}
return $definition;
}
/**
* @return 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 knowledgeSource(): array
{
$topics = array_values(array_map(
fn (array $definition): array => [
'topic_key' => $definition['topic_key'],
'surface_families' => $definition['surface_families'],
'headline' => $definition['headline'],
'short_explanation' => $definition['short_explanation'],
'troubleshooting_steps' => $definition['troubleshooting_steps'],
'safe_next_action' => $definition['safe_next_action'],
'glossary_terms' => $definition['glossary_terms'],
'docs_links' => array_values(array_map(
static fn (array $link): array => [
'label' => (string) $link['label'],
'kind' => (string) ($link['kind'] ?? 'url'),
'url' => isset($link['url']) && is_string($link['url']) ? $link['url'] : null,
'resolver' => isset($link['resolver']) && is_string($link['resolver']) ? $link['resolver'] : null,
],
$definition['docs_links'],
)),
],
$this->definitions(),
));
return [
'version' => 1,
'topic_count' => count($topics),
'topics' => $topics,
];
}
}

View File

@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductKnowledge;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder;
use InvalidArgumentException;
final class ContextualHelpResolver
{
public function __construct(
private readonly ContextualHelpCatalog $catalog,
private readonly PlatformVocabularyGlossary $glossary,
private readonly ReasonPresenter $reasonPresenter,
private readonly OperatorExplanationBuilder $operatorExplanationBuilder,
) {}
/**
* @param array<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* reason_label: ?string,
* diagnostic_code: ?string,
* operator_summary: ?array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }
* }
*/
public function resolve(string $topicKey, array $context = []): array
{
$definition = $this->catalog->definition($topicKey);
$reasonEnvelope = $this->providerReasonEnvelope($context);
$operatorSummary = $this->operatorSummary($context);
return [
'topic_key' => $definition['topic_key'],
'surface_families' => $definition['surface_families'],
'headline' => $this->firstNonEmpty(
$this->stringOrNull($context['headline'] ?? null),
$definition['headline'],
),
'short_explanation' => $this->firstNonEmpty(
$this->stringOrNull($context['short_explanation'] ?? null),
$this->reasonPresenter->shortExplanation($reasonEnvelope),
$definition['short_explanation'],
),
'troubleshooting_steps' => $this->troubleshootingSteps($definition['troubleshooting_steps'], $context),
'safe_next_action' => $this->firstNonEmpty(
$this->stringOrNull($context['safe_next_action'] ?? null),
$operatorSummary['nextActionText'] ?? null,
$definition['safe_next_action'],
),
'docs_links' => $this->resolveLinks($definition['docs_links'], $context),
'canonical_terms' => $this->canonicalTerms($definition['glossary_terms']),
'reason_label' => $this->reasonPresenter->primaryLabel($reasonEnvelope),
'diagnostic_code' => $this->reasonPresenter->diagnosticCode($reasonEnvelope),
'operator_summary' => $operatorSummary,
];
}
/**
* @param array<string, mixed> $context
* @return array{
* topic_key: string,
* surface_families: list<string>,
* headline: string,
* short_explanation: string,
* troubleshooting_steps: list<string>,
* safe_next_action: string,
* docs_links: list<array{
* label: string,
* kind: string,
* url: ?string,
* resolver: ?string
* }>,
* canonical_terms: list<string>,
* reason_label: ?string,
* diagnostic_code: ?string,
* operator_summary: ?array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }
* }|null
*/
public function tryResolve(?string $topicKey, array $context = []): ?array
{
if (! is_string($topicKey) || trim($topicKey) === '') {
return null;
}
try {
return $this->resolve($topicKey, $context);
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @return 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 knowledgeSource(): array
{
return $this->catalog->knowledgeSource();
}
/**
* @param array<string, mixed>|null $verificationReport
*/
public function primaryReasonCodeFromVerificationReport(?array $verificationReport): ?string
{
foreach ($this->relevantChecks($verificationReport) as $check) {
$reasonCode = $check['reason_code'] ?? null;
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return trim($reasonCode);
}
}
return null;
}
public function topicKeyForOnboardingVerification(
?string $reasonCode,
bool $isVerificationStale,
?string $verificationOverall,
?string $runOutcome,
): ?string {
if ($isVerificationStale) {
return ContextualHelpCatalog::VERIFICATION_STALE;
}
$reasonTopicKey = $this->onboardingTopicKeyForReason($reasonCode);
if ($reasonTopicKey !== null) {
return $reasonTopicKey;
}
return $this->isVerificationFailure($verificationOverall, $runOutcome)
? ContextualHelpCatalog::VERIFICATION_FAILED
: null;
}
public function topicKeyForSupportDiagnostics(
?string $reasonCode,
bool $hasIncompleteEvidence,
?string $runOutcome,
): ?string {
$reasonTopicKey = $this->supportDiagnosticsTopicKeyForReason($reasonCode);
if ($reasonTopicKey !== null) {
return $reasonTopicKey;
}
if (is_string($reasonCode) && trim($reasonCode) !== '') {
return $reasonCode === ProviderReasonCodes::UnknownError
? ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED
: null;
}
if ($this->isVerificationFailure(null, $runOutcome)) {
return ContextualHelpCatalog::VERIFICATION_FAILED;
}
return $hasIncompleteEvidence
? ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE
: null;
}
/**
* @param array<string, mixed> $context
*/
private function providerReasonEnvelope(array $context): ?ReasonResolutionEnvelope
{
$tenant = $context['tenant'] ?? null;
$reasonCode = $context['reason_code'] ?? null;
$connection = $context['connection'] ?? null;
$surface = $this->stringOrNull($context['surface'] ?? null) ?? 'detail';
if (! $tenant instanceof Tenant || ! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return $this->reasonPresenter->forProviderReason(
tenant: $tenant,
reasonCode: trim($reasonCode),
connection: $connection instanceof ProviderConnection ? $connection : null,
surface: $surface,
);
}
/**
* @param array<string, mixed> $context
* @return array{
* primaryReason: string,
* nextActionText: string,
* diagnosticsAvailable: bool,
* diagnosticsSummary: ?string
* }|null
*/
private function operatorSummary(array $context): ?array
{
$truth = $context['artifact_truth'] ?? null;
if (! $truth instanceof ArtifactTruthEnvelope) {
return null;
}
return $this->operatorExplanationBuilder->compressionSummaryInputs($truth);
}
/**
* @param array<string, mixed>|null $verificationReport
* @return list<array<string, mixed>>
*/
private function relevantChecks(?array $verificationReport): array
{
$checks = is_array($verificationReport['checks'] ?? null)
? $verificationReport['checks']
: [];
return array_values(array_filter($checks, static function (mixed $check): bool {
if (! is_array($check)) {
return false;
}
$status = $check['status'] ?? null;
return is_string($status)
&& ! in_array($status, ['pass', 'skip', 'running'], true);
}));
}
private function onboardingTopicKeyForReason(?string $reasonCode): ?string
{
return match ($reasonCode) {
ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::IntuneRbacPermissionMissing => ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderConnectionTypeInvalid,
ProviderReasonCodes::DedicatedCredentialMissing,
ProviderReasonCodes::DedicatedCredentialInvalid,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConnectionReviewRequired,
ProviderReasonCodes::ProviderBindingUnsupported,
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::IntuneRbacNotConfigured,
ProviderReasonCodes::IntuneRbacUnhealthy,
ProviderReasonCodes::IntuneRbacStale => ContextualHelpCatalog::CONNECTION_UNHEALTHY,
default => null,
};
}
private function supportDiagnosticsTopicKeyForReason(?string $reasonCode): ?string
{
if ($reasonCode === ProviderReasonCodes::ProviderPermissionRefreshFailed
|| $reasonCode === ProviderReasonCodes::NetworkUnreachable
|| $reasonCode === ProviderReasonCodes::RateLimited) {
return ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE;
}
return $this->onboardingTopicKeyForReason($reasonCode);
}
/**
* @param list<string> $defaultSteps
* @param array<string, mixed> $context
* @return list<string>
*/
private function troubleshootingSteps(array $defaultSteps, array $context): array
{
$contextSteps = $context['troubleshooting_steps'] ?? [];
if (! is_array($contextSteps)) {
return $defaultSteps;
}
$normalizedContextSteps = array_values(array_filter(array_map(
fn (mixed $step): ?string => $this->stringOrNull($step),
$contextSteps,
)));
if ($normalizedContextSteps === []) {
return $defaultSteps;
}
return array_values(array_unique([...$defaultSteps, ...$normalizedContextSteps]));
}
/**
* @param list<array{label: string, kind: string, url?: string, resolver?: string}> $links
* @param array<string, mixed> $context
* @return list<array{label: string, kind: string, url: ?string, resolver: ?string}>
*/
private function resolveLinks(array $links, array $context): array
{
return array_values(array_filter(array_map(
fn (array $link): ?array => $this->resolveLink($link, $context['tenant'] ?? null),
$links,
)));
}
/**
* @param array{label: string, kind: string, url?: string, resolver?: string} $link
* @return array{label: string, kind: string, url: ?string, resolver: ?string}|null
*/
private function resolveLink(array $link, mixed $tenant): ?array
{
$label = $this->stringOrNull($link['label'] ?? null);
if ($label === null) {
return null;
}
$resolved = [
'label' => $label,
'kind' => $this->stringOrNull($link['kind'] ?? null) ?? 'url',
'url' => $this->stringOrNull($link['url'] ?? null),
'resolver' => $this->stringOrNull($link['resolver'] ?? null),
];
if (! $tenant instanceof Tenant || $resolved['resolver'] === null) {
return $resolved;
}
$resolved['url'] = match ($resolved['resolver']) {
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY => RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS => RequiredPermissionsLinks::requiredPermissions($tenant),
default => $resolved['url'],
};
return $resolved;
}
/**
* @param list<string> $terms
* @return list<string>
*/
private function canonicalTerms(array $terms): array
{
return array_values(array_unique(array_filter(array_map(function (string $term): ?string {
$canonical = $this->glossary->canonicalName($term);
return $canonical !== null ? trim($canonical) : $this->stringOrNull($term);
}, $terms))));
}
private function isVerificationFailure(?string $verificationOverall, ?string $runOutcome): bool
{
return in_array($verificationOverall, ['blocked', 'needs_attention'], true)
|| in_array($runOutcome, ['failed', 'blocked', 'partially_succeeded'], true);
}
private function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function firstNonEmpty(?string ...$values): string
{
foreach ($values as $value) {
if ($value !== null && trim($value) !== '') {
return $value;
}
}
return '';
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use App\Models\ProductUsageEvent;
use BackedEnum;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use InvalidArgumentException;
final class ProductTelemetryRecorder
{
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
/**
* @param array<string, mixed> $metadata
*/
public function record(
string $eventName,
int $workspaceId,
int $tenantId,
int $userId,
string $subjectType,
string|int $subjectId,
array $metadata = [],
?DateTimeInterface $occurredAt = null,
): ProductUsageEvent {
$occurredAt ??= now();
return ProductUsageEvent::query()->create([
'workspace_id' => $workspaceId,
'tenant_id' => $tenantId,
'user_id' => $userId,
'event_name' => $eventName,
'feature_area' => $this->catalog->featureArea($eventName),
'subject_type' => $this->normalizeToken($subjectType, 'subject type'),
'subject_id' => $this->normalizeSubjectId($subjectId),
'metadata' => $this->normalizeMetadata($eventName, $metadata),
'occurred_at' => Carbon::instance($occurredAt),
]);
}
/**
* @param array<string, mixed> $metadata
* @return array<string, bool|int|string>
*/
private function normalizeMetadata(string $eventName, array $metadata): array
{
$allowedKeys = $this->catalog->allowedMetadataKeys($eventName);
$unknownKeys = array_values(array_diff(array_keys($metadata), $allowedKeys));
if ($unknownKeys !== []) {
$keys = implode(', ', $unknownKeys);
throw new InvalidArgumentException("Unsupported telemetry metadata keys [{$keys}] for [{$eventName}].");
}
$normalized = [];
foreach ($metadata as $key => $value) {
if ($value === null) {
continue;
}
$normalized[$key] = $this->normalizeMetadataValue($value, $key);
}
return $normalized;
}
/**
* @return bool|int|string
*/
private function normalizeMetadataValue(mixed $value, string $key): bool|int|string
{
if ($value instanceof BackedEnum) {
return $this->normalizeToken((string) $value->value, $key);
}
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value)->toIso8601String();
}
if (is_bool($value) || is_int($value)) {
return $value;
}
if (is_string($value)) {
return $this->normalizeToken($value, $key);
}
throw new InvalidArgumentException("Telemetry metadata [{$key}] must be a bounded scalar, enum, or timestamp.");
}
private function normalizeSubjectId(string|int $subjectId): string
{
if (is_int($subjectId)) {
return (string) $subjectId;
}
return $this->normalizeToken($subjectId, 'subject id');
}
private function normalizeToken(string $value, string $field): string
{
$normalized = trim($value);
if ($normalized === '') {
throw new InvalidArgumentException("Telemetry {$field} cannot be empty.");
}
if (strlen($normalized) > 120) {
throw new InvalidArgumentException("Telemetry {$field} is too long.");
}
if (! preg_match('/\A[a-zA-Z0-9._:+\/-]+\z/', $normalized)) {
throw new InvalidArgumentException("Telemetry {$field} must be a machine-safe token.");
}
if (str_contains($normalized, '@')) {
throw new InvalidArgumentException("Telemetry {$field} cannot contain email-like data.");
}
return $normalized;
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use App\Models\ProductUsageEvent;
use DateTimeInterface;
use Illuminate\Support\Carbon;
final class ProductTelemetrySummaryQuery
{
public function __construct(private readonly ProductUsageEventCatalog $catalog) {}
/**
* @return array{
* window: array{start_at: string, end_at: string},
* active_workspaces: int,
* total_events: int,
* families: array<string, array{label: string, count: int}>
* }
*/
public function summarize(DateTimeInterface $startAt, ?DateTimeInterface $endAt = null): array
{
$startAt = Carbon::instance($startAt);
$endAt = Carbon::instance($endAt ?? now());
$baseQuery = ProductUsageEvent::query()
->where('occurred_at', '>=', $startAt)
->where('occurred_at', '<=', $endAt);
/** @var array<string, int> $counts */
$counts = (clone $baseQuery)
->selectRaw('event_name, COUNT(*) as aggregate')
->groupBy('event_name')
->pluck('aggregate', 'event_name')
->map(static fn (mixed $count): int => (int) $count)
->all();
$families = [];
$totalEvents = 0;
foreach ($this->catalog->visibleFamilies() as $eventName => $label) {
$count = (int) ($counts[$eventName] ?? 0);
$totalEvents += $count;
$families[$eventName] = [
'label' => $label,
'count' => $count,
];
}
return [
'window' => [
'start_at' => $startAt->toIso8601String(),
'end_at' => $endAt->toIso8601String(),
],
'active_workspaces' => (clone $baseQuery)
->distinct()
->count('workspace_id'),
'total_events' => $totalEvents,
'families' => $families,
];
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Support\ProductTelemetry;
use InvalidArgumentException;
final class ProductUsageEventCatalog
{
public const string ONBOARDING_CHECKPOINT_COMPLETED = 'product.onboarding.checkpoint_completed';
public const string SUPPORT_DIAGNOSTICS_OPENED = 'product.support_diagnostics.opened';
public const string OPERATIONS_STARTED = 'product.operations.started';
public const string STORED_REPORT_CREATED = 'product.stored_report.created';
public const string REVIEW_PACK_REQUESTED = 'product.review_pack.requested';
private const DEFINITIONS = [
self::ONBOARDING_CHECKPOINT_COMPLETED => [
'feature_area' => 'onboarding',
'label' => 'Onboarding checkpoints',
'metadata_keys' => ['checkpoint_key', 'lifecycle_state', 'completed_at'],
],
self::SUPPORT_DIAGNOSTICS_OPENED => [
'feature_area' => 'support_diagnostics',
'label' => 'Support diagnostics',
'metadata_keys' => ['source_surface', 'operation_type'],
],
self::OPERATIONS_STARTED => [
'feature_area' => 'operations',
'label' => 'Operations started',
'metadata_keys' => ['operation_type'],
],
self::STORED_REPORT_CREATED => [
'feature_area' => 'stored_reports',
'label' => 'Stored reports',
'metadata_keys' => ['report_type'],
],
self::REVIEW_PACK_REQUESTED => [
'feature_area' => 'review_pack',
'label' => 'Review packs requested',
'metadata_keys' => ['source_surface', 'include_operations', 'include_pii'],
],
];
/**
* @return list<string>
*/
public function names(): array
{
return array_keys(self::DEFINITIONS);
}
/**
* @return array{feature_area: string, label: string, metadata_keys: list<string>}
*/
public function definition(string $eventName): array
{
$definition = self::DEFINITIONS[$eventName] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException("Unknown product telemetry event [{$eventName}].");
}
return $definition;
}
public function featureArea(string $eventName): string
{
return $this->definition($eventName)['feature_area'];
}
public function label(string $eventName): string
{
return $this->definition($eventName)['label'];
}
/**
* @return list<string>
*/
public function allowedMetadataKeys(string $eventName): array
{
return $this->definition($eventName)['metadata_keys'];
}
/**
* @return array<string, string>
*/
public function visibleFamilies(): array
{
$families = [];
foreach ($this->names() as $eventName) {
$families[$eventName] = $this->label($eventName);
}
return $families;
}
}

View File

@ -15,6 +15,35 @@ public static function protectedValueNote(): string
return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.'; return 'Protected values are intentionally hidden as [REDACTED]. Secret-only changes remain detectable without revealing the value.';
} }
public static function supportDiagnosticsNote(): string
{
return 'Support diagnostics are default-redacted. Secrets, credentials, raw provider payloads, full response bodies, and unrestricted log excerpts are intentionally excluded.';
}
/**
* @return list<array{path: ?string, reason: string, replacement_text: string}>
*/
public static function supportDiagnosticsMarkers(): array
{
return [
[
'path' => 'provider_connection.credential',
'reason' => 'credential',
'replacement_text' => '[REDACTED]',
],
[
'path' => 'stored_reports.payload',
'reason' => 'raw_payload',
'replacement_text' => '[REDACTED]',
],
[
'path' => 'audit_logs.metadata.raw',
'reason' => 'restricted_log_excerpt',
'replacement_text' => '[REDACTED]',
],
];
}
public static function noteForPolicyVersion(PolicyVersion $version): ?string public static function noteForPolicyVersion(PolicyVersion $version): ?string
{ {
if (self::fingerprintCount($version->secret_fingerprints) > 0) { if (self::fingerprintCount($version->secret_fingerprints) > 0) {

File diff suppressed because it is too large Load Diff

View File

@ -149,9 +149,6 @@
], ],
], ],
], ],
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
'supported_policy_types' => [ 'supported_policy_types' => [
[ [
'type' => 'deviceConfiguration', 'type' => 'deviceConfiguration',
@ -574,6 +571,8 @@
'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90), 'retention_days' => (int) env('TENANTPILOT_STORED_REPORTS_RETENTION_DAYS', 90),
], ],
'product_usage_event_retention_days' => (int) env('TENANTPILOT_PRODUCT_USAGE_EVENT_RETENTION_DAYS', 90),
'display' => [ 'display' => [
'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false),
'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000),

View File

@ -0,0 +1,55 @@
<?php
namespace Database\Factories;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<OperationalControlActivation>
*/
class OperationalControlActivationFactory extends Factory
{
protected $model = OperationalControlActivation::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'control_key' => 'restore.execute',
'scope_type' => 'global',
'workspace_id' => null,
'reason_text' => fake()->sentence(),
'expires_at' => null,
'created_by_platform_user_id' => PlatformUser::factory(),
'updated_by_platform_user_id' => null,
];
}
public function forControl(string $controlKey): static
{
return $this->state(fn (): array => [
'control_key' => $controlKey,
]);
}
public function forGlobalScope(): static
{
return $this->state(fn (): array => [
'scope_type' => 'global',
'workspace_id' => null,
]);
}
public function workspaceScoped(): static
{
return $this->state(fn (): array => [
'scope_type' => 'workspace',
'workspace_id' => Workspace::factory(),
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProductUsageEvent>
*/
class ProductUsageEventFactory extends Factory
{
protected $model = ProductUsageEvent::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
$catalog = new ProductUsageEventCatalog();
$eventName = ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED;
return [
'tenant_id' => Tenant::factory()->for(Workspace::factory()),
'workspace_id' => function (array $attributes): int {
$tenantId = $attributes['tenant_id'] ?? null;
if (! is_numeric($tenantId)) {
return (int) Workspace::factory()->create()->getKey();
}
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
if (! $tenant instanceof Tenant || $tenant->workspace_id === null) {
return (int) Workspace::factory()->create()->getKey();
}
return (int) $tenant->workspace_id;
},
'user_id' => User::factory(),
'event_name' => $eventName,
'feature_area' => $catalog->featureArea($eventName),
'subject_type' => 'tenant_onboarding_session',
'subject_id' => (string) fake()->numberBetween(1, 999999),
'metadata' => [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
],
'occurred_at' => now(),
];
}
/**
* @param array<string, mixed> $metadata
*/
public function forEvent(string $eventName, array $metadata = []): static
{
$catalog = new ProductUsageEventCatalog();
return $this->state(fn (): array => [
'event_name' => $eventName,
'feature_area' => $catalog->featureArea($eventName),
'metadata' => $metadata,
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('operational_control_activations', function (Blueprint $table): void {
$table->id();
$table->string('control_key');
$table->string('scope_type');
$table->foreignId('workspace_id')->nullable()->constrained('workspaces')->cascadeOnDelete();
$table->text('reason_text');
$table->timestampTz('expires_at')->nullable();
$table->foreignId('created_by_platform_user_id')->constrained('platform_users')->restrictOnDelete();
$table->foreignId('updated_by_platform_user_id')->nullable()->constrained('platform_users')->nullOnDelete();
$table->timestamps();
$table->index(['control_key', 'scope_type']);
$table->index(['workspace_id', 'control_key']);
$table->index('expires_at');
});
DB::statement("CREATE UNIQUE INDEX operational_control_activations_global_unique ON operational_control_activations (control_key) WHERE scope_type = 'global'");
DB::statement("CREATE UNIQUE INDEX operational_control_activations_workspace_unique ON operational_control_activations (control_key, workspace_id) WHERE scope_type = 'workspace' AND workspace_id IS NOT NULL");
}
public function down(): void
{
Schema::dropIfExists('operational_control_activations');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('product_usage_events', function (Blueprint $table): void {
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('event_name', 120);
$table->string('feature_area', 60);
$table->string('subject_type', 120);
$table->string('subject_id', 120);
$table->jsonb('metadata')->default('{}');
$table->timestampTz('occurred_at');
$table->timestamps();
$table->index(['workspace_id', 'tenant_id', 'occurred_at']);
$table->index(['event_name', 'occurred_at']);
$table->index(['subject_type', 'subject_id', 'occurred_at']);
});
}
public function down(): void
{
Schema::dropIfExists('product_usage_events');
}
};

View File

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

View File

@ -0,0 +1,80 @@
@php
$help = is_array($help ?? null) ? $help : [];
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
? (string) ($help['headline'])
: 'Contextual help';
$reasonLabel = is_string($help['reason_label'] ?? null) && trim((string) ($help['reason_label'] ?? '')) !== ''
? (string) $help['reason_label']
: null;
$showReasonLabel = $reasonLabel !== null && trim(mb_strtolower($reasonLabel)) !== trim(mb_strtolower($headline));
@endphp
@if ($help !== [])
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/80" data-testid="contextual-help-block">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge color="info" size="sm">
Contextual help
</x-filament::badge>
@if ($showReasonLabel)
<x-filament::badge color="gray" size="sm">
{{ $reasonLabel }}
</x-filament::badge>
@endif
</div>
<div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $headline }}
</div>
<div class="text-sm text-gray-700 dark:text-gray-200">
{{ (string) ($help['short_explanation'] ?? '') }}
</div>
</div>
@if (is_string($help['safe_next_action'] ?? null) && trim((string) ($help['safe_next_action'] ?? '')) !== '')
<x-filament::callout
color="info"
heading="Safe next action"
:description="(string) $help['safe_next_action']"
/>
@endif
@if ($steps !== [])
<ul class="list-disc space-y-1 pl-5 text-sm text-gray-700 dark:text-gray-200">
@foreach ($steps as $step)
@if (is_string($step) && trim($step) !== '')
<li>{{ $step }}</li>
@endif
@endforeach
</ul>
@endif
@if ($links !== [])
<div class="flex flex-wrap gap-2">
@foreach ($links as $link)
@php
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url']
: null;
@endphp
@if ($linkUrl)
<x-filament::button
tag="a"
:href="$linkUrl"
size="sm"
color="primary"
>
{{ (string) ($link['label'] ?? 'Open') }}
</x-filament::button>
@endif
@endforeach
</div>
@endif
</div>
</div>
@endif

View File

@ -6,6 +6,7 @@
$redactionNotes = is_array($redactionNotes ?? null) $redactionNotes = is_array($redactionNotes ?? null)
? array_values(array_filter($redactionNotes, 'is_string')) ? array_values(array_filter($redactionNotes, 'is_string'))
: []; : [];
$contextualHelp = is_array($contextualHelp ?? null) ? $contextualHelp : null;
$assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : []; $assistVisibility = is_array($assistVisibility ?? null) ? $assistVisibility : [];
$assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== '' $assistActionName = is_string($assistActionName ?? null) && trim((string) $assistActionName) !== ''
? trim((string) $assistActionName) ? trim((string) $assistActionName)
@ -14,12 +15,6 @@
? trim((string) $technicalDetailsActionName) ? trim((string) $technicalDetailsActionName)
: 'wizardVerificationTechnicalDetails'; : 'wizardVerificationTechnicalDetails';
$showAssist = (bool) ($assistVisibility['is_visible'] ?? false); $showAssist = (bool) ($assistVisibility['is_visible'] ?? false);
$assistReason = is_string($assistVisibility['reason'] ?? null) ? (string) $assistVisibility['reason'] : 'hidden_irrelevant';
$assistDescription = match ($assistReason) {
'permission_blocked' => 'Stored permission diagnostics show blockers. Review them without leaving onboarding.',
'permission_attention' => 'Stored permission diagnostics need attention before you rerun verification.',
default => 'Review required permissions without leaving onboarding.',
};
$completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null; $completedAt = is_string($run['completed_at'] ?? null) && trim((string) $run['completed_at']) !== '' ? (string) $run['completed_at'] : null;
$completedAtLabel = null; $completedAtLabel = null;
@ -52,7 +47,7 @@
<x-dynamic-component :component="$fieldWrapperView" :field="$field"> <x-dynamic-component :component="$fieldWrapperView" :field="$field">
<div class="space-y-4"> <div class="space-y-4">
<x-filament::section <x-filament::section
heading="Verification report" heading="Stored verification details"
:description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'" :description="$completedAtLabel ? ('Completed: ' . $completedAtLabel) : 'Stored details for the latest verification operation.'"
> >
@if ($runState === 'no_run') @if ($runState === 'no_run')
@ -113,28 +108,8 @@
</x-filament::button> </x-filament::button>
</div> </div>
@if ($showAssist) @if ($contextualHelp !== null)
<div class="rounded-lg border border-warning-300 bg-warning-50 p-4 shadow-sm dark:border-warning-700 dark:bg-warning-950/40"> @include('filament.components.product-knowledge.contextual-help', ['help' => $contextualHelp])
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-semibold text-warning-950 dark:text-warning-50">
Required permissions assist
</div>
<div class="text-sm text-warning-900 dark:text-warning-100">
{{ $assistDescription }}
</div>
</div>
<x-filament::button
size="sm"
color="warning"
data-testid="verification-assist-trigger"
wire:click="mountAction('{{ $assistActionName }}')"
>
View required permissions
</x-filament::button>
</div>
</div>
@endif @endif
@include('filament.components.verification-report-viewer', [ @include('filament.components.verification-report-viewer', [

View File

@ -0,0 +1,167 @@
@php
use Illuminate\Support\Str;
/** @var array<string, mixed> $bundle */
$summary = is_array($bundle['summary'] ?? null) ? $bundle['summary'] : [];
$context = is_array($bundle['context'] ?? null) ? $bundle['context'] : [];
$sections = is_array($bundle['sections'] ?? null) ? $bundle['sections'] : [];
$redaction = is_array($bundle['redaction'] ?? null) ? $bundle['redaction'] : [];
$notes = is_array($bundle['notes'] ?? null) ? $bundle['notes'] : [];
$availabilityColor = static function (?string $availability): string {
return match ($availability) {
'available', 'current', 'fresh', 'ready' => 'success',
'partial', 'stale' => 'warning',
'error', 'missing', 'unavailable' => 'danger',
default => 'gray',
};
};
$referenceDescription = static function (array $reference): string {
$parts = [
is_string($reference['type'] ?? null) && trim((string) $reference['type']) !== ''
? (string) $reference['type']
: 'reference',
is_string($reference['availability'] ?? null) && trim((string) $reference['availability']) !== ''
? (string) $reference['availability']
: 'missing',
];
if (is_string($reference['freshness_note'] ?? null) && trim((string) $reference['freshness_note']) !== '') {
$parts[] = (string) $reference['freshness_note'];
}
return implode(' - ', $parts);
};
@endphp
<div class="space-y-4">
<x-filament::section
:heading="data_get($summary, 'headline', data_get($bundle, 'headline', 'Support diagnostics'))"
:description="data_get($summary, 'dominant_issue', data_get($bundle, 'dominant_issue', 'No dominant issue available.'))"
>
<x-slot name="afterHeader">
<div class="flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm">
{{ data_get($context, 'type', data_get($bundle, 'context_type', 'tenant')) }}
</x-filament::badge>
<x-filament::badge color="gray" size="sm">
{{ data_get($summary, 'freshness_state', data_get($bundle, 'freshness_state', 'mixed')) }}
</x-filament::badge>
<x-filament::badge color="warning" size="sm">
{{ str_replace('_', '-', (string) data_get($redaction, 'mode', data_get($bundle, 'redaction_mode', 'default-redacted'))) }}
</x-filament::badge>
</div>
</x-slot>
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div class="space-y-1">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Workspace</dt>
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'workspace_label', 'Workspace unavailable') }}</dd>
</div>
<div class="space-y-1">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tenant</dt>
<dd class="text-gray-950 dark:text-white">{{ data_get($context, 'tenant_label', 'Tenant unavailable') }}</dd>
</div>
</dl>
</x-filament::section>
@if (is_array($bundle['contextual_help'] ?? null))
@include('filament.components.product-knowledge.contextual-help', ['help' => $bundle['contextual_help']])
@endif
@if ($notes !== [])
<x-filament::section
heading="Support notes"
description="The bundle stays read-only and redacted even when the source records include provider-only details."
compact
>
<div class="space-y-2">
@foreach ($notes as $note)
@if (is_string($note) && trim($note) !== '')
<div class="flex items-start gap-2">
<x-filament::badge color="warning" size="sm">Note</x-filament::badge>
<p class="text-sm leading-6 text-gray-700 dark:text-gray-200">{{ $note }}</p>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@endif
<div class="space-y-3">
@foreach ($sections as $section)
@php
$references = is_array($section['references'] ?? null) ? $section['references'] : [];
$markers = is_array($section['redaction_markers'] ?? null) ? $section['redaction_markers'] : [];
$availability = is_string($section['availability'] ?? null) && trim((string) $section['availability']) !== ''
? (string) $section['availability']
: 'missing';
$sectionLabel = $section['label'] ?? $section['key'] ?? 'Section';
$sectionSummary = $section['summary'] ?? 'No summary available.';
@endphp
<x-filament::section
:heading="$sectionLabel"
:description="$sectionSummary"
compact
>
<x-slot name="afterHeader">
<x-filament::badge :color="$availabilityColor($availability)" size="sm">
{{ $availability }}
</x-filament::badge>
</x-slot>
<div class="space-y-3">
@if (is_string($section['freshness_note'] ?? null) && trim((string) $section['freshness_note']) !== '')
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $section['freshness_note'] }}</p>
@endif
@if ($references !== [])
<div class="space-y-2">
@foreach ($references as $reference)
@php
$referenceLabel = $reference['label'] ?? 'Reference unavailable';
$referenceUrl = is_string($reference['url'] ?? null) && trim((string) $reference['url']) !== ''
? (string) $reference['url']
: null;
$referenceActionLabel = $reference['action_label'] ?? ($referenceUrl ? 'Open' : 'Unavailable');
@endphp
<x-filament::section
:heading="$referenceLabel"
:description="$referenceDescription($reference)"
compact
secondary
>
<x-slot name="afterHeader">
@if ($referenceUrl)
<x-filament::link :href="$referenceUrl" size="sm">
{{ $referenceActionLabel }}
</x-filament::link>
@else
<x-filament::badge color="gray" size="sm">
{{ $referenceActionLabel }}
</x-filament::badge>
@endif
</x-slot>
</x-filament::section>
@endforeach
</div>
@endif
@if ($markers !== [])
<div class="flex flex-wrap gap-2">
@foreach ($markers as $marker)
<x-filament::badge color="warning" size="sm">
{{ trim((string) (($marker['replacement_text'] ?? '[REDACTED]').' '.Str::of((string) ($marker['reason'] ?? 'redacted'))->replace('_', ' '))) }}
</x-filament::badge>
@endforeach
</div>
@endif
</div>
</x-filament::section>
@endforeach
</div>
</div>

View File

@ -136,9 +136,9 @@
@if (! $hasStoredPermissionData) @if (! $hasStoredPermissionData)
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200"> <div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
<div class="font-semibold">Keine Daten verfügbar</div> <div class="font-semibold">No data available</div>
<div class="mt-1"> <div class="mt-1">
Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor. No stored verification data is available for this tenant.
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>. <a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
</div> </div>
</div> </div>

View File

@ -0,0 +1,120 @@
@php
$controls = $this->controlCards();
@endphp
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section>
<div class="flex items-start gap-3">
<x-heroicon-o-pause-circle class="h-6 w-6 shrink-0 text-amber-500 dark:text-amber-400" />
<div>
<p class="text-sm font-semibold text-amber-700 dark:text-amber-300">Runtime safety controls</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
Use these bounded operational controls to pause risky starts without hiding the underlying surface. Global pauses win over workspace-specific pauses.
</p>
</div>
</div>
</x-filament::section>
<div class="grid gap-6">
@foreach ($controls as $control)
<x-filament::section>
<x-slot name="heading">
{{ $control['label'] }}
</x-slot>
<x-slot name="description">
{{ implode(', ', $control['affected_surfaces']) }}
</x-slot>
<x-slot name="afterHeader">
<x-filament::badge color="{{ $control['effective_state'] === 'paused' ? 'danger' : 'success' }}" size="sm">
{{ $control['state_label'] }}
</x-filament::badge>
</x-slot>
@php
$pauseActionName = 'pause_'.$control['action_slug'];
$resumeActionName = 'resume_'.$control['action_slug'];
$historyActionName = 'view_history_'.$control['action_slug'];
@endphp
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
@foreach ($control['supported_scopes'] as $scope)
<x-filament::badge color="gray" size="sm">
{{ ucfirst($scope) }}
</x-filament::badge>
@endforeach
</div>
<div class="flex flex-wrap items-center gap-2">
@if ($control['effective_state'] === 'paused')
<x-filament::button
color="gray"
size="sm"
icon="heroicon-o-play"
wire:click="mountAction('{{ $resumeActionName }}')"
>
Resume
</x-filament::button>
@else
<x-filament::button
color="danger"
size="sm"
icon="heroicon-o-pause"
wire:click="mountAction('{{ $pauseActionName }}')"
>
Pause
</x-filament::button>
@endif
<x-filament::button
color="gray"
size="sm"
wire:click="mountAction('{{ $historyActionName }}')"
>
History
</x-filament::button>
</div>
@if ($control['active_activations'] !== [])
<div class="space-y-3">
@foreach ($control['active_activations'] as $activation)
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $activation['scope_label'] }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Owner: {{ $activation['owner_name'] }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $activation['expires_label'] }}
</span>
</div>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $activation['reason_text'] }}
</p>
</div>
@endforeach
</div>
@else
<div class="rounded-lg border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
No active pauses. New starts are currently enabled.
</div>
@endif
<p class="text-xs text-gray-500 dark:text-gray-400">
Use the card actions to pause, resume, or inspect audit history for this control.
</p>
</div>
</x-filament::section>
@endforeach
</div>
</div>
</x-filament-panels::page>

View File

@ -0,0 +1,6 @@
<x-filament-panels::header
:actions="[]"
:breadcrumbs="$breadcrumbs"
:heading="$heading"
:subheading="$subheading"
/>

View File

@ -0,0 +1,29 @@
<div class="space-y-3">
@if ($events->isEmpty())
<p class="text-sm text-gray-500 dark:text-gray-400">
No audit history exists yet for {{ $label }}.
</p>
@else
@foreach ($events as $event)
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-sm font-semibold text-gray-900 dark:text-white">
{{ \App\Support\Audit\AuditActionId::labelFor((string) $event->action) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $event->recorded_at?->diffForHumans() ?? 'Unknown time' }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ $event->actorDisplayLabel() }}
</span>
</div>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{{ $event->summaryText() }}
</p>
</div>
@endforeach
@endif
</div>

View File

@ -39,6 +39,11 @@
->name('stored-reports:prune') ->name('stored-reports:prune')
->withoutOverlapping(); ->withoutOverlapping();
Schedule::command('tenantpilot:product-usage:prune')
->daily()
->name('tenantpilot:product-usage:prune')
->withoutOverlapping();
Schedule::command('tenantpilot:review-pack:prune') Schedule::command('tenantpilot:review-pack:prune')
->daily() ->daily()
->name('tenantpilot:review-pack:prune') ->name('tenantpilot:review-pack:prune')

View File

@ -146,6 +146,7 @@ function buildReportService(GraphClientInterface $graphMock): EntraAdminRolesRep
graphClient: $graphMock, graphClient: $graphMock,
catalog: new HighPrivilegeRoleCatalog, catalog: new HighPrivilegeRoleCatalog,
graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class), graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class),
productTelemetryRecorder: app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class),
); );
} }

View File

@ -28,6 +28,7 @@ function buildScanReportService(GraphClientInterface $graphClient): EntraAdminRo
$graphClient, $graphClient,
new HighPrivilegeRoleCatalog, new HighPrivilegeRoleCatalog,
app(MicrosoftGraphOptionsResolver::class), app(MicrosoftGraphOptionsResolver::class),
app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class),
); );
} }

View File

@ -9,12 +9,13 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('does not expose maintenance actions in /admin findings list by default', function () { it('exposes the findings lifecycle backfill action for entitled tenant operators', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class) Livewire::test(ListFindings::class)
->assertActionDoesNotExist('backfill_lifecycle'); ->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle');
}); });

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Jobs\BackfillFindingLifecycleJob;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Support\Audit\AuditActionId;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps the findings backfill action visible but blocks execution when a control is active', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Workspace-specific pause.',
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle')
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['workspace_id'] ?? null)->toBe((int) $tenant->workspace_id);
});
it('does not block findings backfill for a different workspace when the pause is workspace-scoped', function (): void {
Queue::fake();
[$blockedUser, $blockedTenant] = createUserWithTenant(role: 'owner');
[$allowedUser, $allowedTenant] = createUserWithTenant(role: 'owner');
Finding::factory()->create([
'tenant_id' => (int) $allowedTenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'findings.lifecycle.backfill',
'workspace_id' => (int) $blockedTenant->workspace_id,
'reason_text' => 'Paused only for the blocked workspace.',
]);
$this->actingAs($allowedUser);
Filament::setTenant($allowedTenant, true);
Livewire::test(ListFindings::class)
->assertActionExists('backfill_lifecycle')
->assertActionEnabled('backfill_lifecycle')
->callAction('backfill_lifecycle');
$run = OperationRun::query()
->where('type', 'findings.lifecycle.backfill')
->where('tenant_id', (int) $allowedTenant->getKey())
->latest('id')
->first();
expect($run)->not->toBeNull();
Queue::assertPushed(BackfillFindingLifecycleJob::class, function (BackfillFindingLifecycleJob $job) use ($allowedTenant): bool {
return $job->tenantId === (int) $allowedTenant->getKey()
&& $job->workspaceId === (int) $allowedTenant->workspace_id;
});
expect(AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->where('tenant_id', (int) $allowedTenant->getKey())
->exists())->toBeFalse();
});

View File

@ -7,6 +7,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -14,12 +15,157 @@
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Verification\VerificationReportWriter; use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Testing\TestAction; use Filament\Actions\Testing\TestAction;
use Livewire\Livewire; use Livewire\Livewire;
function managedReadinessPermissionKeys(): array
{
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
return array_values(array_filter(array_map(static function (mixed $permission): ?string {
if (! is_array($permission)) {
return null;
}
$key = $permission['key'] ?? null;
return is_string($key) && trim($key) !== '' ? trim($key) : null;
}, $configured)));
}
function seedManagedReadinessPermissions(Tenant $tenant, ?int $staleDays = null, ?string $missingKey = null): ?string
{
$keys = managedReadinessPermissionKeys();
$missingKey ??= $keys[0] ?? null;
foreach ($keys as $key) {
if ($missingKey !== null && $key === $missingKey) {
continue;
}
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'permission_key' => $key,
'status' => 'granted',
'details' => ['source' => 'readiness-test'],
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
]);
}
return $missingKey;
}
/**
* @return array{0: User, 1: TenantOnboardingSession, 2: ProviderConnection, 3: OperationRun|null, 4: string|null}
*/
function createManagedReadinessBlockerDraft(string $state): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => fake()->uuid(),
'name' => 'Blocker Tenant '.str_replace('_', ' ', $state),
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connectionState = [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Blocker connection',
'is_default' => true,
];
if ($state === 'missing_consent') {
$connectionState['consent_status'] = ProviderConsentStatus::Required->value;
}
if ($state === 'revoked_consent') {
$connectionState['consent_status'] = ProviderConsentStatus::Revoked->value;
}
if ($state === 'disabled_connection') {
$connectionState['is_enabled'] = false;
$connectionState['consent_status'] = ProviderConsentStatus::Granted->value;
}
$connection = ProviderConnection::factory()->platform()->create($connectionState);
$run = null;
$missingKey = null;
if ($state === 'blocked_verification' || $state === 'permission_gap') {
$connection->forceFill([
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value,
])->save();
$missingKey = seedManagedReadinessPermissions($tenant);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permissions.admin_consent',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required provider permissions.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
}
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => array_filter([
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
], static fn (mixed $value): bool => $value !== null),
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $draft, $connection, $run, $missingKey];
}
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void { it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -869,6 +1015,368 @@
->assertSee('Draft Owner'); ->assertSee('Draft Owner');
}); });
it('shows route-bound readiness progress and check-not-run guidance with one primary next action', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create(['name' => 'Readiness Owner']);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '31313131-3131-3131-3131-313131313131',
'name' => 'No Check Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'No check connection',
'is_default' => true,
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Onboarding readiness')
->assertSee('Step')
->assertDontSee('Current checkpoint')
->assertSee('Verify access')
->assertSee('Verification has not run yet')
->assertSee('Provider connection')
->assertSee('Primary next action')
->assertSee('Start verification');
expect(substr_count($response->getContent(), 'Primary next action'))->toBe(1);
});
it('shows route-bound ready readiness with freshness and canonical operation evidence', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '32323232-3232-3232-3232-323232323232',
'name' => 'Ready Readiness Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Ready for activation')
->assertSee('Verification and permission evidence are current.')
->assertSee('Complete onboarding')
->assertSee('Supporting evidence')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('classifies consent, disabled connection, and blocked verification readiness blockers', function (string $state, string $summary, string $nextAction): void {
[$user, $draft] = createManagedReadinessBlockerDraft($state);
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Onboarding readiness')
->assertSee($summary)
->assertSee($nextAction);
})->with([
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant admin consent'],
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant admin consent'],
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
]);
it('keeps permission gap detail out of the top-level page once a verification report is present', function (): void {
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Permission or consent blocker needs attention')
->assertDontSee('Permission diagnostics')
->assertSee('Supporting evidence')
->assertSee('View required permissions')
->assertSee('Review permissions');
if (is_string($missingKey) && $missingKey !== '') {
$response->assertDontSee($missingKey);
}
$response->assertDontSee('Microsoft Graph readiness');
});
it('shows permission diagnostics as a fallback when no verification report is present', function (): void {
[$user, $draft] = createManagedReadinessBlockerDraft('missing_consent');
$tenant = $draft->tenant()->firstOrFail();
$missingKey = seedManagedReadinessPermissions($tenant);
$response = $this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Permission diagnostics')
->assertDontSee('Supporting evidence');
if (is_string($missingKey) && $missingKey !== '') {
$response->assertSee($missingKey);
}
});
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '52525252-5252-5252-5252-525252525252',
'name' => 'Stale Evidence Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, staleDays: 45, missingKey: '__none__');
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Stale readiness connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Readiness needs attention')
->assertSee('Permission data is older than the 30-day freshness window.')
->assertSee('Rerun verification')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('downgrades route-bound readiness when verification evidence belongs to another selected connection', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '53535353-5353-5353-5353-535353535353',
'name' => 'Mismatched Evidence Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
$oldConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '54545454-5454-5454-5454-545454545454',
'display_name' => 'Previous connection',
]);
$selectedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Selected connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $oldConnection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $selectedConnection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertSuccessful()
->assertSee('Verification needs refresh')
->assertSee('Verification evidence belongs to a different provider connection.')
->assertSee('Rerun verification')
->assertSee('Open operation')
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
});
it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void { it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -1171,6 +1679,10 @@
$component->call('startVerification'); $component->call('startVerification');
$component->call('startVerification'); $component->call('startVerification');
$component
->assertSee('Onboarding readiness')
->assertSee('Open operation');
expect(OperationRun::query() expect(OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', 'provider.connection.check') ->where('type', 'provider.connection.check')

View File

@ -259,6 +259,53 @@
->assertActionEnabled('cancel_onboarding_draft'); ->assertActionEnabled('cancel_onboarding_draft');
}); });
it('keeps destructive draft actions confirmation protected and capability gated', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$manager = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $manager,
role: 'manager',
workspaceRole: 'manager',
ensureDefaultMicrosoftProviderConnection: false,
);
$resumableDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $manager,
'updated_by' => $manager,
]);
$cancelledDraft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $manager,
'updated_by' => $manager,
'status' => 'cancelled',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
Livewire::actingAs($manager)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $resumableDraft->getKey(),
])
->assertActionExists('cancel_onboarding_draft', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired())
->assertActionEnabled('cancel_onboarding_draft');
Livewire::actingAs($manager)
->test(ManagedTenantOnboardingWizard::class, [
'onboardingDraft' => (int) $cancelledDraft->getKey(),
])
->assertActionExists('delete_onboarding_draft_header', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired())
->assertActionEnabled('delete_onboarding_draft_header');
});
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void { it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
@ -320,6 +367,71 @@
->assertForbidden(); ->assertForbidden();
}); });
it('keeps readiness routes hidden from non-members and wrong workspaces while capability denials stay forbidden', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Readiness Authorization Tenant',
]);
$owner = User::factory()->create();
$nonMember = User::factory()->create();
$wrongWorkspaceUser = User::factory()->create();
$readonly = User::factory()->create();
$wrongWorkspace = Workspace::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $owner,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
createUserWithTenant(
tenant: $tenant,
user: $readonly,
role: 'readonly',
workspaceRole: 'readonly',
ensureDefaultMicrosoftProviderConnection: false,
);
\App\Models\WorkspaceMembership::factory()->create([
'workspace_id' => (int) $wrongWorkspace->getKey(),
'user_id' => (int) $wrongWorkspaceUser->getKey(),
'role' => 'owner',
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $owner,
'updated_by' => $owner,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($nonMember)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertNotFound();
session()->put(WorkspaceContext::SESSION_KEY, (int) $wrongWorkspace->getKey());
$this->actingAs($wrongWorkspaceUser)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertNotFound();
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($readonly)
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
->assertForbidden();
});
it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void { it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([

View File

@ -2,11 +2,47 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
function seedPickerReadinessPermissions(Tenant $tenant, ?int $staleDays = null): void
{
$configured = array_merge(
config('intune_permissions.permissions', []),
config('entra_permissions.permissions', []),
);
foreach ($configured as $permission) {
if (! is_array($permission)) {
continue;
}
$key = $permission['key'] ?? null;
if (! is_string($key) || trim($key) === '') {
continue;
}
TenantPermission::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'permission_key' => trim($key),
'status' => 'granted',
'details' => ['source' => 'picker-readiness-test'],
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
]);
}
}
it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void { it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -124,3 +160,273 @@
->assertDontSee('Completed Draft') ->assertDontSee('Completed Draft')
->assertDontSee('Cancelled Draft'); ->assertDontSee('Cancelled Draft');
}); });
it('shows compact readiness snippets for multiple resumable drafts while keeping picker actions', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$blockedTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '41414141-4141-4141-4141-414141414141',
'name' => 'Needs Connection Tenant',
]);
$readyTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '42424242-4242-4242-4242-424242424242',
'name' => 'Ready Picker Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$blockedTenant->getKey() => ['role' => 'owner'],
$readyTenant->getKey() => ['role' => 'owner'],
]);
seedPickerReadinessPermissions($readyTenant);
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $readyTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $readyTenant->tenant_id,
'display_name' => 'Ready picker connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $readyTenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $blockedTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'connection',
'state' => [
'entra_tenant_id' => (string) $blockedTenant->tenant_id,
'tenant_name' => (string) $blockedTenant->name,
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $readyTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $readyTenant->tenant_id,
'tenant_name' => (string) $readyTenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding'))
->assertSuccessful()
->assertSee('Needs Connection Tenant')
->assertSee('Ready Picker Tenant')
->assertSee('Compact readiness')
->assertSee('Provider connection required')
->assertSee('Connect provider')
->assertSee('Ready for activation')
->assertSee('Verification and permission evidence are current.')
->assertSee('Resume onboarding')
->assertSee('View summary');
});
it('shows stale and mismatched readiness cues across multiple drafts in the picker', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$staleTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '44444444-4444-4444-4444-444444444444',
'name' => 'Picker Stale Tenant',
]);
$mismatchTenant = Tenant::factory()->onboarding()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => '45454545-4545-4545-4545-454545454545',
'name' => 'Picker Mismatch Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$staleTenant->getKey() => ['role' => 'owner'],
$mismatchTenant->getKey() => ['role' => 'owner'],
]);
seedPickerReadinessPermissions($staleTenant, staleDays: 45);
seedPickerReadinessPermissions($mismatchTenant);
$staleConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $staleTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $staleTenant->tenant_id,
'display_name' => 'Stale picker connection',
'is_default' => true,
]);
$oldMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $mismatchTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '46464646-4646-4646-4646-464646464646',
'display_name' => 'Old mismatch picker connection',
]);
$selectedMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $mismatchTenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $mismatchTenant->tenant_id,
'display_name' => 'Selected mismatch picker connection',
'is_default' => true,
]);
$staleRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $staleTenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $staleConnection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$mismatchRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $mismatchTenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $oldMismatchConnection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $staleTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $staleTenant->tenant_id,
'tenant_name' => (string) $staleTenant->name,
'provider_connection_id' => (int) $staleConnection->getKey(),
'verification_operation_run_id' => (int) $staleRun->getKey(),
],
]);
createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $mismatchTenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'complete',
'state' => [
'entra_tenant_id' => (string) $mismatchTenant->tenant_id,
'tenant_name' => (string) $mismatchTenant->name,
'provider_connection_id' => (int) $selectedMismatchConnection->getKey(),
'verification_operation_run_id' => (int) $mismatchRun->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding'))
->assertSuccessful()
->assertSee('Picker Stale Tenant')
->assertSee('Picker Mismatch Tenant')
->assertSee('Permission data is older than the 30-day freshness window.')
->assertSee('Verification evidence belongs to a different provider connection.')
->assertSee('Rerun verification');
});
it('preserves the single-draft landing redirect instead of rendering compact readiness', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'state' => [
'entra_tenant_id' => '43434343-4343-4343-4343-434343434343',
'tenant_name' => 'Single Redirect Tenant',
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$this->actingAs($user)
->get(route('admin.onboarding'))
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
});

View File

@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: User, 1: Tenant, 2: TenantOnboardingSession}
*/
function createProductKnowledgeOnboardingDraft(string $state, string $workspaceRole = 'owner', string $tenantRole = 'owner'): array
{
$tenant = Tenant::factory()->onboarding()->create();
[$user, $tenant] = createUserWithTenant(
tenant: $tenant,
role: $tenantRole,
workspaceRole: $workspaceRole,
ensureDefaultMicrosoftProviderConnection: false,
);
$workspace = $tenant->workspace()->firstOrFail();
$verificationConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verification connection',
'is_default' => true,
'consent_status' => 'granted',
]);
$selectedConnection = $verificationConnection;
$checks = [];
$outcome = OperationRunOutcome::Blocked->value;
if ($state === 'admin_consent') {
$checks[] = [
'key' => 'permissions.admin_consent',
'title' => 'Admin consent',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'message' => 'Admin consent is required before verification can proceed.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'required_permissions') {
$checks[] = [
'key' => 'permissions.required',
'title' => 'Required application permissions',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'Missing required application permissions.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'connection_unhealthy') {
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => ProviderReasonCodes::ProviderAuthFailed,
'message' => 'Stored provider credentials are no longer valid.',
'evidence' => [],
'next_steps' => [],
];
} elseif ($state === 'verification_stale') {
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'dummy',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Currently selected connection',
'is_default' => false,
'consent_status' => 'granted',
]);
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'pass',
'severity' => 'info',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Connection is healthy.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Succeeded->value;
} elseif ($state === 'verification_failed') {
$checks[] = [
'key' => 'provider.connection.check',
'title' => 'Provider connection check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => '',
'message' => 'Verification failed after the prerequisite checks ran.',
'evidence' => [],
'next_steps' => [],
];
$outcome = OperationRunOutcome::Failed->value;
}
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => $outcome,
'context' => [
'provider_connection_id' => (int) $verificationConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'entra_tenant_name' => (string) $tenant->name,
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', $checks),
],
]);
$draft = createOnboardingDraft([
'workspace' => $workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'entra_tenant_id' => (string) $tenant->tenant_id,
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $selectedConnection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$user, $tenant, $draft];
}
it('renders onboarding contextual help for each in-scope verification topic', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, , $draft] = createProductKnowledgeOnboardingDraft($state);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $user->last_workspace_id])
->followingRedirects()
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$response->assertSuccessful()
->assertSee('Verification report')
->assertSee('Stored verification details')
->assertSee($headline)
->assertDontSee('Permission diagnostics')
->assertSee($safeNextAction);
$dom = new \DOMDocument();
@$dom->loadHTML($response->getContent());
$xpath = new \DOMXPath($dom);
$headlineNodes = $xpath->query(sprintf(
'//*[@data-testid="contextual-help-block"]//*[normalize-space(text())="%s"]',
$headline,
));
$storedVerificationDetailsHeadings = $xpath->query(
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Stored verification details"]',
);
$verificationReportHeadings = $xpath->query(
'//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6][normalize-space(text())="Verification report"]',
);
expect($headlineNodes?->length)->toBe(1);
expect($storedVerificationDetailsHeadings?->length)->toBe(1);
expect($verificationReportHeadings?->length)->toBeLessThanOrEqual(1);
if ($state === 'admin_consent') {
$primaryNextActionNode = $xpath->query(
'//*[normalize-space(text())="Primary next action"]/following::*[(self::a or self::button) and normalize-space(text())!=""][1]',
);
expect(trim((string) $primaryNextActionNode?->item(0)?->textContent))->toContain('Grant admin consent');
}
if ($linkLabel !== null) {
$response->assertSee($linkLabel);
}
})->with([
'admin consent required' => [
'admin_consent',
'Admin consent required',
'Grant admin consent and re-run verification.',
'Grant admin consent',
],
'required permissions missing' => [
'required_permissions',
'Required permissions missing',
'Open required permissions and confirm the missing grants.',
'Open required permissions',
],
'connection unhealthy' => [
'connection_unhealthy',
'Provider connection needs review',
'Review the provider connection before retrying.',
null,
],
'verification stale' => [
'verification_stale',
'Verification result is stale',
'Refresh verification before continuing onboarding.',
null,
],
'verification failed' => [
'verification_failed',
'Verification failed',
'Review the blocking reason and retry verification.',
null,
],
]);
it('keeps onboarding contextual help deny-as-not-found for workspace members outside the tenant scope', function (): void {
[$authorizedUser, $tenant, $draft] = createProductKnowledgeOnboardingDraft('admin_consent');
$workspace = $tenant->workspace()->firstOrFail();
$outOfScopeUser = User::factory()->create();
WorkspaceMembership::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $outOfScopeUser->getKey(),
'role' => 'owner',
]);
$this->actingAs($outOfScopeUser)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Onboarding\OnboardingDraftMutationService;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingLifecycleState;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records onboarding checkpoint telemetry after a tenant-linked checkpoint transition persists', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
]);
$verificationRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'verify',
'state' => [
'entra_tenant_id' => (string) $tenant->tenant_id,
'tenant_name' => (string) $tenant->name,
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
],
]);
app(OnboardingDraftMutationService::class)->mutate(
draft: $draft,
actor: $user,
mutator: static function (): void {},
);
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($event->event_name)->toBe(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->and($event->workspace_id)->toBe((int) $tenant->workspace_id)
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->user_id)->toBe((int) $user->getKey())
->and($event->subject_type)->toBe('tenant_onboarding_session')
->and($event->subject_id)->toBe((string) $draft->getKey())
->and($event->metadata['checkpoint_key'] ?? null)->toBe(OnboardingCheckpoint::VerifyAccess->value)
->and($event->metadata['lifecycle_state'] ?? null)->toBe(OnboardingLifecycleState::ReadyForActivation->value)
->and($event->metadata['completed_at'] ?? null)->not->toBeNull()
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain((string) $tenant->name)
->and($serializedEvent)->not->toContain((string) ($draft->state['tenant_name'] ?? ''));
});
it('does not record onboarding telemetry for pre-tenant drafts', function (): void {
$workspace = \App\Models\Workspace::factory()->create();
$user = \App\Models\User::factory()->create();
$draft = createOnboardingDraft([
'workspace' => $workspace,
'started_by' => $user,
'updated_by' => $user,
'current_step' => 'identify',
'state' => [
'entra_tenant_id' => fake()->uuid(),
'tenant_name' => 'Pre Tenant Draft',
],
]);
app(OnboardingDraftMutationService::class)->mutate(
draft: $draft,
actor: $user,
mutator: static function (): void {},
);
expect(ProductUsageEvent::query()->count())->toBe(0);
});

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Tests\Support\OpsUx\SourceFileScanner;
it('keeps the in-scope operational controls on the shared service and evaluator paths', function (): void {
$root = SourceFileScanner::projectRoot();
$checks = [
[
'file' => $root.'/app/Filament/Resources/FindingResource/Pages/ListFindings.php',
'required' => [
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'FindingsLifecycleBackfillScope::singleTenant(',
],
'forbidden' => [
"config('tenantpilot.allow_admin_maintenance_actions'",
'allow_admin_maintenance_actions',
'OperationalControlActivation::',
],
],
[
'file' => $root.'/app/Filament/System/Pages/Ops/Runbooks.php',
'required' => [
'FindingsLifecycleBackfillRunbookService',
'OperationalControlBlockedException',
'$runbookService->start(',
],
'forbidden' => [
'OperationalControlActivation::',
"config('tenantpilot.allow_admin_maintenance_actions'",
],
],
[
'file' => $root.'/app/Filament/Resources/RestoreRunResource.php',
'required' => [
'guardRestoreExecutionOperationalControl(',
'OperationalControlEvaluator::class',
'OperationalControlBlockedException',
],
'forbidden' => [
'OperationalControlActivation::',
"config('tenantpilot.allow_admin_maintenance_actions'",
],
],
[
'file' => $root.'/config/tenantpilot.php',
'required' => [],
'forbidden' => [
'allow_admin_maintenance_actions',
'ALLOW_ADMIN_MAINTENANCE_ACTIONS',
],
],
];
foreach ($checks as $check) {
$source = SourceFileScanner::read($check['file']);
foreach ($check['required'] as $needle) {
expect($source)->toContain($needle);
}
foreach ($check['forbidden'] as $needle) {
expect($source)->not->toContain($needle);
}
}
})->group('surface-guard');

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationalControlActivation;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
function seedRestoreAuthorizationContext(): array
{
$tenant = Tenant::factory()->create([
'tenant_id' => fake()->uuid(),
'name' => 'Authorization Tenant',
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
ensureDefaultProviderConnection($tenant, 'microsoft');
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'display_name' => 'Authorization Restore Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Authorization Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => ['displayName' => 'Authorization Restore Policy'],
]);
Filament::setTenant($tenant, true);
return [$tenant, $backupSet, $backupItem];
}
it('keeps non-members at 404 even when restore execution is paused', function (): void {
[$tenant] = seedRestoreAuthorizationContext();
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Paused while access is under review.',
]);
$user = User::factory()->create();
$this->actingAs($user)
->get(RestoreRunResource::getUrl('create', panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('keeps members without tenant-manage at 403 even when restore execution is paused', function (): void {
[$tenant] = seedRestoreAuthorizationContext();
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Paused while access is under review.',
]);
[$user] = createUserWithTenant(tenant: $tenant, role: 'operator');
$this->actingAs($user)
->get(RestoreRunResource::getUrl('create', panel: 'tenant', tenant: $tenant))
->assertForbidden();
});
it('shows paused-state feedback only to entitled users blocked by an operational control', function (): void {
[$tenant, $backupSet, $backupItem] = seedRestoreAuthorizationContext();
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Paused for tenant-safe validation.',
]);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Authorization Tenant',
])
->call('create')
->assertNotified('Restore execution paused');
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records telemetry for user-initiated tenant-bound operation starts only once per created run', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$service = app(OperationRunService::class);
$run = $service->ensureRun(
tenant: $tenant,
type: OperationRunType::ReviewPackGenerate->value,
inputs: [
'include_pii' => true,
'include_operations' => true,
],
initiator: $user,
);
$sameRun = $service->ensureRun(
tenant: $tenant,
type: OperationRunType::ReviewPackGenerate->value,
inputs: [
'include_pii' => true,
'include_operations' => true,
],
initiator: $user,
);
expect($sameRun->getKey())->toBe($run->getKey())
->and(ProductUsageEvent::query()->count())->toBe(1);
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($event->event_name)->toBe(ProductUsageEventCatalog::OPERATIONS_STARTED)
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->workspace_id)->toBe((int) $tenant->workspace_id)
->and($event->user_id)->toBe((int) $user->getKey())
->and($event->subject_type)->toBe('operation_run')
->and($event->subject_id)->toBe((string) $run->getKey())
->and($event->metadata)->toBe([
'operation_type' => OperationRunType::ReviewPackGenerate->value,
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain($user->name)
->and($serializedEvent)->not->toContain((string) $tenant->name);
});
it('does not record telemetry for system-initiated operation starts', function (): void {
$tenant = Tenant::factory()->create();
app(OperationRunService::class)->ensureRun(
tenant: $tenant,
type: OperationRunType::ReviewPackGenerate->value,
inputs: [
'include_pii' => true,
],
initiator: null,
);
expect(ProductUsageEvent::query()->count())->toBe(0);
});

View File

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateReviewPackJob;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Services\EntraAdminRoles\EntraAdminRolesReportService;
use App\Services\EntraAdminRoles\HighPrivilegeRoleCatalog;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\ReviewPackService;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
function productTelemetryRoleDefinitions(): array
{
return [[
'id' => 'def-ga-001',
'displayName' => 'Global Administrator',
'templateId' => '62e90394-69f5-4237-9190-012177145e10',
'isBuiltIn' => true,
]];
}
function productTelemetryRoleAssignments(): array
{
return [[
'id' => 'assign-1',
'roleDefinitionId' => 'def-ga-001',
'principalId' => 'user-aaa',
'directoryScopeId' => '/',
'principal' => [
'@odata.type' => '#microsoft.graph.user',
'displayName' => 'Alice Admin',
],
]];
}
function buildTelemetryGraphMock(): GraphClientInterface
{
return new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return match ($policyType) {
'entraRoleDefinitions' => new GraphResponse(success: true, data: productTelemetryRoleDefinitions(), status: 200),
'entraRoleAssignments' => new GraphResponse(success: true, data: productTelemetryRoleAssignments(), status: 200),
default => new GraphResponse(success: false, status: 404, errors: ['Unknown type']),
};
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(success: false, status: 501);
}
};
}
function buildTelemetryReportService(): EntraAdminRolesReportService
{
return new EntraAdminRolesReportService(
graphClient: buildTelemetryGraphMock(),
catalog: new HighPrivilegeRoleCatalog,
graphOptionsResolver: app(MicrosoftGraphOptionsResolver::class),
productTelemetryRecorder: app(\App\Support\ProductTelemetry\ProductTelemetryRecorder::class),
);
}
it('records telemetry when a user-initiated Entra admin roles report is created', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
ensureDefaultProviderConnection($tenant);
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => OperationRunType::InventorySync->value,
]);
$result = buildTelemetryReportService()->generate($tenant, $run);
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($result->created)->toBeTrue()
->and($event->event_name)->toBe(ProductUsageEventCatalog::STORED_REPORT_CREATED)
->and($event->subject_type)->toBe('stored_report')
->and($event->subject_id)->toBe((string) $result->storedReportId)
->and($event->metadata)->toBe([
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain('Alice Admin')
->and($serializedEvent)->not->toContain((string) $tenant->name);
});
it('records telemetry when a user-initiated permission posture report is created', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => OperationRunType::InventorySync->value,
]);
$result = app(PermissionPostureFindingGenerator::class)->generate($tenant, [
'overall_status' => 'missing',
'permissions' => [
[
'key' => 'DeviceManagementApps.ReadWrite.All',
'type' => 'application',
'status' => 'missing',
'features' => ['policy-sync'],
],
],
'last_refreshed_at' => now()->toIso8601String(),
], $run);
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($event->event_name)->toBe(ProductUsageEventCatalog::STORED_REPORT_CREATED)
->and($event->subject_type)->toBe('stored_report')
->and($event->subject_id)->toBe((string) $result->storedReportId)
->and($event->metadata)->toBe([
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain('DeviceManagementApps.ReadWrite.All')
->and($serializedEvent)->not->toContain((string) $tenant->name);
});
it('records telemetry when a user requests a review pack generation', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
seedTenantReviewEvidence($tenant);
Notification::fake();
$pack = app(ReviewPackService::class)->generate($tenant, $user, [
'include_pii' => true,
'include_operations' => true,
]);
$event = ProductUsageEvent::query()->where('event_name', ProductUsageEventCatalog::REVIEW_PACK_REQUESTED)->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($pack->status)->toBe('queued')
->and($event->subject_type)->toBe('review_pack')
->and($event->subject_id)->toBe((string) $pack->getKey())
->and($event->metadata)->toBe([
'source_surface' => 'tenant',
'include_operations' => true,
'include_pii' => true,
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain((string) $tenant->name);
$job = new GenerateReviewPackJob(
reviewPackId: (int) $pack->getKey(),
operationRunId: (int) $pack->operation_run_id,
);
app()->call([$job, 'handle']);
});

View File

@ -12,7 +12,7 @@
$this->actingAs($user) $this->actingAs($user)
->get("/admin/tenants/{$tenant->external_id}/required-permissions") ->get("/admin/tenants/{$tenant->external_id}/required-permissions")
->assertSuccessful() ->assertSuccessful()
->assertSee('Keine Daten verfügbar') ->assertSee('No data available')
->assertSee($expectedUrl, false) ->assertSee($expectedUrl, false)
->assertSee('Start verification'); ->assertSee('Start verification');
}); });

View File

@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\System\Pages\Ops\Controls;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\AuditLog;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Policy;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\RestoreRunStatus;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
function seedOperationalRestoreExecutionContext(bool $withProviderConnection = true, ?Workspace $workspace = null): array
{
$workspace ??= Workspace::factory()->create(['name' => 'Restore Workspace']);
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => fake()->uuid(),
'name' => 'Restore Tenant',
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
$tenant->makeCurrent();
if ($withProviderConnection) {
ensureDefaultProviderConnection($tenant, 'microsoft');
}
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => fake()->uuid(),
'policy_type' => 'deviceConfiguration',
'display_name' => 'Restore Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Restore Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => ['id' => $policy->external_id],
'metadata' => ['displayName' => 'Restore Policy'],
]);
$user = User::factory()->create([
'email' => fake()->unique()->safeEmail(),
'name' => 'Restore Operator',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
Filament::setTenant($tenant, true);
return [$tenant, $backupSet, $backupItem, $user, $workspace];
}
it('blocks restore execution before any operation run, restore run, job, or provider start is created', function (): void {
Bus::fake();
[$tenant, $backupSet, $backupItem, $user] = seedOperationalRestoreExecutionContext();
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $tenant->workspace_id,
'reason_text' => 'Paused during restore safety review.',
]);
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Restore Tenant',
])
->call('create')
->assertNotified('Restore execution paused');
expect(RestoreRun::query()->count())->toBe(0)
->and(OperationRun::query()->where('type', 'restore.execute')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('restore.execute');
Bus::assertNotDispatched(ExecuteRestoreRunJob::class);
});
it('does not retroactively mutate already accepted restore execution runs when a later pause is activated', function (): void {
[$tenant, $backupSet, $backupItem, $user, $workspace] = seedOperationalRestoreExecutionContext(withProviderConnection: false);
$operationRun = OperationRun::factory()
->forTenant($tenant)
->queued()
->create([
'type' => 'restore.execute',
'outcome' => 'pending',
'initiator_name' => $user->name,
'context' => [
'backup_set_id' => (int) $backupSet->getKey(),
'target_scope' => ['entra_tenant_id' => $tenant->graphTenantId()],
],
]);
$restoreRun = RestoreRun::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'operation_run_id' => (int) $operationRun->getKey(),
'requested_by' => $user->email,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => 'accepted-before-pause',
'requested_items' => [(int) $backupItem->getKey()],
'preview' => ['summary' => []],
'metadata' => ['confirmed_by' => $user->email],
'group_mapping' => [],
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
$this->actingAs($platformUser, 'platform');
Livewire::test(Controls::class)
->callAction('pause_restore_execute', data: [
'scope_type' => 'workspace',
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => 'Pause after the run was already accepted.',
'expires_at' => now()->addHour()->toDateTimeString(),
])
->assertNotified('Restore execution paused');
expect($operationRun->fresh())
->not->toBeNull()
->and($operationRun->fresh()?->status)->toBe('queued')
->and($operationRun->fresh()?->outcome)->toBe('pending');
expect($restoreRun->fresh())
->not->toBeNull()
->and($restoreRun->fresh()?->status)->toBe(RestoreRunStatus::Queued->value)
->and((int) ($restoreRun->fresh()?->operation_run_id ?? 0))->toBe((int) $operationRun->getKey());
});
it('does not block restore execution for a different workspace when the pause is workspace-scoped', function (): void {
Bus::fake();
$blockedWorkspace = Workspace::factory()->create(['name' => 'Blocked Workspace']);
$allowedWorkspace = Workspace::factory()->create(['name' => 'Allowed Workspace']);
[$blockedTenant] = seedOperationalRestoreExecutionContext(workspace: $blockedWorkspace);
[$allowedTenant, $backupSet, $backupItem, $user] = seedOperationalRestoreExecutionContext(workspace: $allowedWorkspace);
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $blockedTenant->workspace_id,
'reason_text' => 'Paused only for the blocked workspace.',
]);
$this->actingAs($user);
Filament::setTenant($allowedTenant, true);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->callFormComponentAction('check_results', 'run_restore_checks')
->goToNextWizardStep()
->callFormComponentAction('preview_diffs', 'run_restore_preview')
->goToNextWizardStep()
->fillForm([
'is_dry_run' => false,
'acknowledged_impact' => true,
'tenant_confirm' => 'Restore Tenant',
])
->call('create')
->assertHasNoFormErrors();
$restoreRun = RestoreRun::query()
->where('tenant_id', (int) $allowedTenant->getKey())
->latest('id')
->first();
$operationRun = OperationRun::query()
->where('tenant_id', (int) $allowedTenant->getKey())
->where('type', 'restore.execute')
->latest('id')
->first();
expect($restoreRun)->not->toBeNull()
->and($operationRun)->not->toBeNull();
Bus::assertDispatched(ExecuteRestoreRunJob::class);
});

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
function operationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('opens a redacted support diagnostic bundle from the tenantless operation viewer', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Contoso Microsoft connection',
'verification_status' => ProviderVerificationStatus::Blocked->value,
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'last_error_message' => 'raw-provider-secret-message',
'last_health_check_at' => now()->subMinutes(15),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'raw_response_body' => 'secret-provider-body',
],
'failure_summary' => [[
'message' => 'Run failed after provider validation.',
]],
'completed_at' => now()->subMinutes(10),
]);
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'current_operation_run_id' => (int) $run->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'last_seen_at' => now()->subMinutes(8),
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => [
'raw_response_body' => 'stored-report-secret-body',
],
'fingerprint' => 'permission-fingerprint',
]);
$evidenceSnapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $run->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'fingerprint' => fake()->sha256(),
'status' => 'active',
'completeness_state' => 'complete',
'summary' => [
'dimension_count' => 1,
'missing_dimensions' => 0,
'stale_dimensions' => 0,
],
'generated_at' => now()->subMinutes(7),
]);
$review = TenantReview::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(),
'operation_run_id' => (int) $run->getKey(),
'generated_at' => now()->subMinutes(7),
]);
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'operation_run_id' => (int) $run->getKey(),
'generated_at' => now()->subMinutes(6),
]);
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'action' => 'operation.failed',
'resource_type' => 'operation_run',
'resource_id' => (string) $run->getKey(),
'target_label' => 'Operation #'.$run->getKey(),
'metadata' => [
'raw_response_body' => 'audit-secret-body',
'reason_code' => 'provider_permission_missing',
],
'outcome' => 'success',
'recorded_at' => now()->subMinutes(5),
]);
operationSupportDiagnosticsComponent($user, $run)
->assertActionVisible('openSupportDiagnostics')
->assertActionEnabled('openSupportDiagnostics')
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics')
->assertActionHasIcon('openSupportDiagnostics', 'heroicon-o-lifebuoy')
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics')
->assertMountedActionModalSee(OperationRunLinks::identifier($run))
->assertMountedActionModalSee('The compare finished, but no decision-grade result is available yet.')
->assertMountedActionModalSee('Contoso Microsoft connection')
->assertMountedActionModalSee('High finding #'.$finding->getKey())
->assertMountedActionModalSee('permission posture report')
->assertMountedActionModalSee('Tenant review #'.$review->getKey())
->assertMountedActionModalSee('Review pack #'.$pack->getKey())
->assertMountedActionModalSee('Operation failed')
->assertMountedActionModalSee('default-redacted')
->assertMountedActionModalSee('[REDACTED]')
->assertMountedActionModalDontSee('raw-provider-secret-message')
->assertMountedActionModalDontSee('secret-provider-body')
->assertMountedActionModalDontSee('stored-report-secret-body')
->assertMountedActionModalDontSee('audit-secret-body');
});
it('keeps tenantless operation detail deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeSupportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function productKnowledgeSupportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('keeps tenant support diagnostics contextual help deny-as-not-found for workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('returns forbidden for entitled run viewers without support diagnostics capability when requesting the contextual-help bundle', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
->assertActionVisible('openSupportDiagnostics')
->assertActionDisabled('openSupportDiagnostics')
->call('operationRunSupportDiagnosticBundle')
->assertForbidden();
});
it('omits support diagnostics contextual help when the dominant issue does not map to a catalog topic', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Fallback Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Fallback connection',
'last_error_reason_code' => 'ext.support.manual_lookup_needed',
'last_health_check_at' => now()->subMinutes(5),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'completed_at' => now()->subMinutes(3),
]);
productKnowledgeSupportDiagnosticsOperationAuthorizationComponent($user, $run)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalDontSee('Contextual help')
->assertMountedActionModalDontSee('ext.support.manual_lookup_needed');
});
it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function productKnowledgeTenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function productKnowledgeOperationSupportDiagnosticsComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
/**
* @return array{0: User, 1: Tenant, 2: OperationRun}
*/
function createProductKnowledgeSupportDiagnosticScenario(string $state): array
{
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$reasonCode = match ($state) {
'admin_consent' => ProviderReasonCodes::ProviderConsentMissing,
'required_permissions' => ProviderReasonCodes::ProviderPermissionMissing,
'connection_unhealthy' => ProviderReasonCodes::ProviderAuthFailed,
'retryable_provider_failure' => ProviderReasonCodes::RateLimited,
'manual_handoff_required' => ProviderReasonCodes::UnknownError,
default => null,
};
$connection = $reasonCode !== null
? ProviderConnection::factory()->withCredential()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Contoso Microsoft connection',
'verification_status' => $reasonCode === ProviderReasonCodes::UnknownError
? ProviderVerificationStatus::Blocked->value
: ProviderVerificationStatus::Healthy->value,
'last_error_reason_code' => $reasonCode,
'last_health_check_at' => now()->subMinutes(15),
])
: null;
$runOutcome = match ($state) {
'verification_failed', 'manual_handoff_required' => OperationRunOutcome::Failed->value,
default => OperationRunOutcome::Succeeded->value,
};
$failureSummary = match ($state) {
'verification_failed' => [[
'message' => 'The operation failed and needs follow-up.',
]],
'manual_handoff_required' => [[
'message' => 'A human support handoff is required for the next step.',
'reason_code' => ProviderReasonCodes::UnknownError,
]],
default => [],
};
$context = [];
if ($connection instanceof ProviderConnection) {
$context['provider_connection_id'] = (int) $connection->getKey();
}
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => $runOutcome,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => $context,
'failure_summary' => $failureSummary,
'completed_at' => now()->subMinutes(10),
]);
return [$user, $tenant, $run];
}
it('renders shared product knowledge in tenant support diagnostics', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, $tenant] = createProductKnowledgeSupportDiagnosticScenario($state);
$component = productKnowledgeTenantSupportDiagnosticsComponent($user, $tenant);
$component->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Contextual help')
->assertMountedActionModalSee($headline)
->assertMountedActionModalSee($safeNextAction);
if ($linkLabel !== null) {
$component->assertMountedActionModalSee($linkLabel);
}
})->with([
'tenant admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
'tenant required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
'tenant connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
'tenant verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
'tenant diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
'tenant retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
'tenant manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
]);
it('renders shared product knowledge in operation support diagnostics', function (
string $state,
string $headline,
string $safeNextAction,
?string $linkLabel,
): void {
[$user, , $run] = createProductKnowledgeSupportDiagnosticScenario($state);
$component = productKnowledgeOperationSupportDiagnosticsComponent($user, $run);
$component->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Contextual help')
->assertMountedActionModalSee($headline)
->assertMountedActionModalSee($safeNextAction);
if ($linkLabel !== null) {
$component->assertMountedActionModalSee($linkLabel);
}
})->with([
'operation admin consent required' => ['admin_consent', 'Admin consent required', 'Grant admin consent and re-run verification.', 'Grant admin consent'],
'operation required permissions missing' => ['required_permissions', 'Required permissions missing', 'Open required permissions and confirm the missing grants.', 'Open required permissions'],
'operation connection unhealthy' => ['connection_unhealthy', 'Provider connection needs review', 'Review the provider connection before retrying.', null],
'operation verification failed' => ['verification_failed', 'Verification failed', 'Review the blocking reason and retry verification.', null],
'operation diagnostic evidence incomplete' => ['diagnostic_evidence_incomplete', 'Diagnostic evidence is incomplete', 'Collect a fresher or more complete diagnostic signal.', null],
'operation retryable provider failure' => ['retryable_provider_failure', 'Provider failure looks retryable', 'Retry after the provider dependency recovers.', null],
'operation manual handoff required' => ['manual_handoff_required', 'Manual support handoff required', 'Hand off the case with the current diagnostic summary and supporting references.', null],
]);

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function tenantDiagnosticsTelemetryComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function operationDiagnosticsTelemetryComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('records telemetry when support diagnostics are opened from the tenant dashboard', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
tenantDiagnosticsTelemetryComponent($user, $tenant)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics');
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($event->event_name)->toBe(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED)
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->workspace_id)->toBe((int) $tenant->workspace_id)
->and($event->user_id)->toBe((int) $user->getKey())
->and($event->subject_type)->toBe('tenant')
->and($event->subject_id)->toBe((string) $tenant->getKey())
->and($event->metadata)->toBe([
'source_surface' => 'tenant_dashboard',
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain($user->name)
->and($serializedEvent)->not->toContain((string) $tenant->name);
});
it('records telemetry when support diagnostics are opened from the canonical operation viewer', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => OperationRunType::ReviewPackGenerate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
operationDiagnosticsTelemetryComponent($user, $run)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics');
$event = ProductUsageEvent::query()->sole();
$serializedEvent = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
expect($event->event_name)->toBe(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED)
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->workspace_id)->toBe((int) $tenant->workspace_id)
->and($event->user_id)->toBe((int) $user->getKey())
->and($event->subject_type)->toBe('operation_run')
->and($event->subject_id)->toBe((string) $run->getKey())
->and($event->metadata)->toBe([
'source_surface' => 'operation_run_viewer',
'operation_type' => OperationRunType::ReviewPackGenerate->value,
])
->and($serializedEvent)->not->toContain('@')
->and($serializedEvent)->not->toContain($user->name)
->and($serializedEvent)->not->toContain((string) $tenant->name);
});

View File

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Audit\AuditActionId;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
function supportDiagnosticsTenantAuditComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function supportDiagnosticsOperationAuditComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
function seedSupportDiagnosticsAuditFixture(string $role = 'owner'): array
{
$tenant = Tenant::factory()->create(['name' => 'Audit Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: $role);
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Audit connection',
'verification_status' => ProviderVerificationStatus::Blocked->value,
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'last_error_message' => 'raw-provider-secret-message',
'last_health_check_at' => now()->subMinutes(15),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'raw_response_body' => 'secret-provider-body',
],
'completed_at' => now()->subMinutes(10),
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => [
'raw_response_body' => 'stored-report-secret-body',
],
]);
AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'action' => 'operation.failed',
'resource_type' => 'operation_run',
'resource_id' => (string) $run->getKey(),
'target_label' => 'Operation #'.$run->getKey(),
'metadata' => [
'raw_response_body' => 'audit-secret-body',
],
'outcome' => 'success',
'recorded_at' => now()->subMinutes(5),
]);
return [$user, $tenant, $run];
}
it('audits tenant support diagnostics opens with redacted metadata and no side effects', function (): void {
[$user, $tenant] = seedSupportDiagnosticsAuditFixture();
bindFailHardGraphClient();
Bus::fake();
Queue::fake();
$operationRunCount = OperationRun::query()->count();
assertNoOutboundHttp(function () use ($user, $tenant): void {
supportDiagnosticsTenantAuditComponent($user, $tenant)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics');
});
Bus::assertNothingDispatched();
Queue::assertNothingPushed();
$audit = AuditLog::query()
->where('action', AuditActionId::SupportDiagnosticsOpened->value)
->latest('id')
->firstOrFail();
$metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR);
expect(OperationRun::query()->count())->toBe($operationRunCount)
->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1)
->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0)
->and($audit->resource_type)->toBe('support_diagnostic_bundle')
->and($audit->resource_id)->toBe('tenant:'.$tenant->getKey())
->and($audit->operation_run_id)->toBeNull()
->and($audit->metadata['context_type'] ?? null)->toBe('tenant')
->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted')
->and($audit->metadata['section_count'] ?? null)->toBe(8)
->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0)
->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $tenant->getKey())
->and($metadataJson)->not->toContain('raw-provider-secret-message')
->and($metadataJson)->not->toContain('secret-provider-body')
->and($metadataJson)->not->toContain('stored-report-secret-body')
->and($metadataJson)->not->toContain('audit-secret-body');
});
it('audits operation support diagnostics opens with redacted metadata and no side effects', function (): void {
[$user, $tenant, $run] = seedSupportDiagnosticsAuditFixture();
bindFailHardGraphClient();
Bus::fake();
Queue::fake();
$operationRunCount = OperationRun::query()->count();
assertNoOutboundHttp(function () use ($user, $run): void {
supportDiagnosticsOperationAuditComponent($user, $run)
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics');
});
Bus::assertNothingDispatched();
Queue::assertNothingPushed();
$audit = AuditLog::query()
->where('action', AuditActionId::SupportDiagnosticsOpened->value)
->latest('id')
->firstOrFail();
$metadataJson = json_encode($audit->metadata, JSON_THROW_ON_ERROR);
expect(OperationRun::query()->count())->toBe($operationRunCount)
->and(AuditLog::query()->where('action', AuditActionId::SupportDiagnosticsOpened->value)->count())->toBe(1)
->and(AuditLog::query()->where('action', AuditActionId::BaselineCompareStarted->value)->count())->toBe(0)
->and($audit->resource_type)->toBe('support_diagnostic_bundle')
->and($audit->resource_id)->toBe('operation_run:'.$run->getKey())
->and($audit->operation_run_id)->toBe((int) $run->getKey())
->and($audit->metadata['context_type'] ?? null)->toBe('operation_run')
->and($audit->metadata['redaction_mode'] ?? null)->toBe('default_redacted')
->and($audit->metadata['section_count'] ?? null)->toBe(8)
->and($audit->metadata['reference_count'] ?? null)->toBeGreaterThan(0)
->and($audit->metadata['primary_context_id'] ?? null)->toBe((string) $run->getKey())
->and($metadataJson)->not->toContain('raw-provider-secret-message')
->and($metadataJson)->not->toContain('secret-provider-body')
->and($metadataJson)->not->toContain('stored-report-secret-body')
->and($metadataJson)->not->toContain('audit-secret-body');
});

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Livewire\Livewire;
function supportDiagnosticsTenantAuthorizationComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
function supportDiagnosticsOperationAuthorizationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
}
it('keeps tenant support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('returns forbidden for entitled tenant members without support diagnostics capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
supportDiagnosticsTenantAuthorizationComponent($user, $tenant)
->assertActionVisible('openSupportDiagnostics')
->assertActionDisabled('openSupportDiagnostics')
->call('tenantSupportDiagnosticBundle')
->assertForbidden();
});
it('keeps operation-run support diagnostics deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertNotFound();
});
it('returns forbidden for entitled run viewers without support diagnostics capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'completed_at' => now(),
]);
supportDiagnosticsOperationAuthorizationComponent($user, $run)
->assertActionVisible('openSupportDiagnostics')
->assertActionDisabled('openSupportDiagnostics')
->call('operationRunSupportDiagnosticBundle')
->assertForbidden();
});

View File

@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Support\Auth\UiTooltips;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Livewire\Livewire;
function tenantSupportDiagnosticsComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
{
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
return Livewire::actingAs($user)->test(TenantDashboard::class);
}
it('opens a redacted tenant support diagnostic bundle from the tenant dashboard', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$connection = ProviderConnection::factory()
->withCredential()
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Contoso Microsoft connection',
'verification_status' => ProviderVerificationStatus::Blocked->value,
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'last_error_message' => 'raw-provider-secret-message',
'last_health_check_at' => now()->subMinutes(15),
]);
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'raw_response_body' => 'secret-provider-body',
],
'failure_summary' => [[
'message' => 'Compare failed after provider permission validation.',
]],
'completed_at' => now()->subMinutes(10),
]);
$finding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'current_operation_run_id' => (int) $run->getKey(),
'severity' => Finding::SEVERITY_HIGH,
'last_seen_at' => now()->subMinutes(8),
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'payload' => [
'raw_response_body' => 'stored-report-secret-body',
],
'fingerprint' => 'permission-fingerprint',
]);
$evidenceSnapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $run->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'fingerprint' => fake()->sha256(),
'status' => 'active',
'completeness_state' => 'complete',
'summary' => [
'dimension_count' => 1,
'missing_dimensions' => 0,
'stale_dimensions' => 0,
],
'generated_at' => now()->subMinutes(7),
]);
$review = TenantReview::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'evidence_snapshot_id' => (int) $evidenceSnapshot->getKey(),
'operation_run_id' => (int) $run->getKey(),
'status' => TenantReviewStatus::Ready->value,
'generated_at' => now()->subMinutes(7),
]);
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'operation_run_id' => (int) $run->getKey(),
'generated_at' => now()->subMinutes(6),
]);
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
AuditLog::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'action' => 'operation.failed',
'resource_type' => 'operation_run',
'resource_id' => (string) $run->getKey(),
'target_label' => 'Operation #'.$run->getKey(),
'metadata' => [
'raw_response_body' => 'audit-secret-body',
'reason_code' => 'provider_permission_missing',
],
'outcome' => 'success',
'recorded_at' => now()->subMinutes(5),
]);
tenantSupportDiagnosticsComponent($user, $tenant)
->assertActionVisible('openSupportDiagnostics')
->assertActionEnabled('openSupportDiagnostics')
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getLabel() === 'Open support diagnostics')
->mountAction('openSupportDiagnostics')
->assertMountedActionModalSee('Support diagnostics')
->assertMountedActionModalSee('Contoso Support Tenant')
->assertMountedActionModalSee('Permissions missing')
->assertMountedActionModalSee('provider app is missing required Microsoft Graph permissions')
->assertMountedActionModalSee('Operation #'.$run->getKey())
->assertMountedActionModalSee('High finding #'.$finding->getKey())
->assertMountedActionModalSee('permission posture report')
->assertMountedActionModalSee('Tenant review #'.$review->getKey())
->assertMountedActionModalSee('Review pack #'.$pack->getKey())
->assertMountedActionModalSee('Operation failed')
->assertMountedActionModalSee('default-redacted')
->assertMountedActionModalSee('[REDACTED]')
->assertMountedActionModalDontSee('raw-provider-secret-message')
->assertMountedActionModalDontSee('secret-provider-body')
->assertMountedActionModalDontSee('stored-report-secret-body')
->assertMountedActionModalDontSee('audit-secret-body');
});
it('denies non-entitled tenant dashboard access as not found', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'role' => 'operator',
]);
$this
->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertNotFound();
});
it('shows support diagnostics as disabled for entitled members without the support capability', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
tenantSupportDiagnosticsComponent($user, $tenant)
->assertActionVisible('openSupportDiagnostics')
->assertActionDisabled('openSupportDiagnostics')
->assertActionExists('openSupportDiagnostics', fn (Action $action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
});

View File

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Controls;
use App\Models\AuditLog;
use App\Models\OperationalControlActivation;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
function makeControlsManager(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_CONTROLS_MANAGE,
],
'is_active' => true,
]);
}
it('returns 403 for platform users missing the operational controls capability', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [PlatformCapabilities::ACCESS_SYSTEM_PANEL],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get(Controls::getUrl(panel: 'system'))
->assertForbidden();
});
it('renders compact card actions and only shows the action that matches the current control state', function (): void {
$user = makeControlsManager();
$this->actingAs($user, 'platform');
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee("mountAction('pause_restore_execute')", escape: false)
->assertDontSee('Findings lifecycle backfill')
->assertDontSee("mountAction('pause_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('resume_findings_lifecycle_backfill')", escape: false)
->assertDontSee("mountAction('view_history_findings_lifecycle_backfill')", escape: false)
->assertDontSee('Pause Restore execution')
->assertDontSee('Resume Restore execution');
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'restore.execute',
'reason_text' => 'Paused for compact action rendering coverage.',
]);
$this->get(Controls::getUrl(panel: 'system'))
->assertSuccessful()
->assertSee("mountAction('resume_restore_execute')", escape: false)
->assertDontSee("mountAction('pause_restore_execute')", escape: false)
->assertDontSee('Findings lifecycle backfill');
});
it('previews, pauses, updates, resumes, and exposes on-demand history for restore execution', function (): void {
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
$user = makeControlsManager();
$this->actingAs($user, 'platform');
$component = Livewire::test(Controls::class)
->assertActionExists('pause_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('resume_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('view_history_restore_execute', fn (Action $action): bool => $action->getLabel() === 'View Restore execution history');
$preview = $component->instance()->scopeImpactPreview('restore.execute', 'global', null);
expect($preview['workspace_count'])->toBe(2)
->and($preview['tenant_count'])->toBe(3)
->and($preview['summary'])->toContain('2 workspaces')
->and($preview['summary'])->toContain('3 tenants');
$component
->callAction('pause_restore_execute', data: [
'scope_type' => 'global',
'reason_text' => 'Paused for incident review.',
'expires_at' => now()->addDay()->toDateTimeString(),
])
->assertNotified('Restore execution paused');
$activation = OperationalControlActivation::query()
->forControl('restore.execute')
->forGlobalScope()
->first();
expect($activation)->not->toBeNull()
->and($activation?->reason_text)->toBe('Paused for incident review.');
$summary = $component->instance()->controlSummary('restore.execute');
expect($summary['effective_state'])->toBe('paused')
->and($summary['active_activations'])->toHaveCount(1)
->and($summary['active_activations'][0]['owner_name'])->toBe($user->name);
$component
->callAction('pause_restore_execute', data: [
'scope_type' => 'global',
'reason_text' => 'Updated incident review scope.',
'expires_at' => now()->addDays(2)->toDateTimeString(),
])
->assertNotified('Restore execution updated');
expect($activation?->fresh()?->reason_text)->toBe('Updated incident review scope.');
$component
->callAction('resume_restore_execute', data: [
'scope_type' => 'global',
])
->assertNotified('Restore execution resumed');
expect(OperationalControlActivation::query()
->forControl('restore.execute')
->forGlobalScope()
->count())->toBe(0);
$audits = AuditLog::query()
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlUpdated->value,
AuditActionId::OperationalControlResumed->value,
])
->where('metadata->control_key', 'restore.execute')
->orderBy('id')
->get();
expect($audits)->toHaveCount(3)
->and($audits->pluck('workspace_id')->unique()->all())->toBe([null])
->and($audits->pluck('tenant_id')->unique()->all())->toBe([null])
->and($audits[0]->action)->toBe(AuditActionId::OperationalControlPaused->value)
->and($audits[1]->action)->toBe(AuditActionId::OperationalControlUpdated->value)
->and($audits[2]->action)->toBe(AuditActionId::OperationalControlResumed->value);
$component
->mountAction('view_history_restore_execute')
->assertActionMounted('view_history_restore_execute');
});
it('supports workspace-scoped pauses and removes expired conflicting activations before replacement writes', function (): void {
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
$expired = OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $workspaceA->getKey(),
'reason_text' => 'Expired pause.',
'expires_at' => now()->subHour(),
]);
$user = makeControlsManager();
$this->actingAs($user, 'platform');
$component = Livewire::test(Controls::class)
->assertActionExists('pause_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionExists('resume_restore_execute', fn (Action $action): bool => $action->isConfirmationRequired());
$preview = $component->instance()->scopeImpactPreview('restore.execute', 'workspace', (int) $workspaceA->getKey());
expect($preview['workspace_count'])->toBe(1)
->and($preview['tenant_count'])->toBe(2)
->and($preview['summary'])->toContain('Acme');
$component
->callAction('pause_restore_execute', data: [
'scope_type' => 'workspace',
'workspace_id' => (int) $workspaceA->getKey(),
'reason_text' => 'Paused for workspace restore maintenance.',
'expires_at' => now()->addDay()->toDateTimeString(),
])
->assertNotified('Restore execution paused');
expect(OperationalControlActivation::query()->whereKey((int) $expired->getKey())->exists())->toBeFalse();
$activation = OperationalControlActivation::query()
->forControl('restore.execute')
->forWorkspaceScope((int) $workspaceA->getKey())
->notExpired()
->first();
expect($activation)->not->toBeNull()
->and((int) ($activation?->workspace_id ?? 0))->toBe((int) $workspaceA->getKey());
$component
->callAction('pause_restore_execute', data: [
'scope_type' => 'workspace',
'workspace_id' => (int) $workspaceA->getKey(),
'reason_text' => 'Updated workspace restore maintenance.',
'expires_at' => now()->addDays(3)->toDateTimeString(),
])
->assertNotified('Restore execution updated');
expect($activation?->fresh()?->reason_text)->toBe('Updated workspace restore maintenance.');
$component
->callAction('resume_restore_execute', data: [
'scope_type' => 'workspace',
'workspace_id' => (int) $workspaceA->getKey(),
])
->assertNotified('Restore execution resumed');
expect(OperationalControlActivation::query()
->forControl('restore.execute')
->forWorkspaceScope((int) $workspaceA->getKey())
->count())->toBe(0);
$audits = AuditLog::query()
->whereIn('action', [
AuditActionId::OperationalControlPaused->value,
AuditActionId::OperationalControlUpdated->value,
AuditActionId::OperationalControlResumed->value,
])
->where('metadata->control_key', 'restore.execute')
->orderBy('id')
->get();
expect($audits)->toHaveCount(3)
->and($audits[0]->workspace_id)->toBe((int) $workspaceA->getKey())
->and($audits[0]->tenant_id)->toBeNull();
});

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Filament\System\Pages\Ops\Runbooks;
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\OperationalControlActivation;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
Tenant::factory()->create([
'tenant_id' => null,
'external_id' => 'platform',
'name' => 'Platform',
]);
});
it('blocks all-tenant findings lifecycle runbooks when the control is globally paused', function (): void {
Queue::fake();
$platformTenant = Tenant::query()->where('external_id', 'platform')->firstOrFail();
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $platformTenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'due_at' => null,
]);
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'findings.lifecycle.backfill',
'reason_text' => 'Paused during incident response.',
]);
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
PlatformCapabilities::RUNBOOKS_RUN,
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform');
Livewire::test(Runbooks::class)
->callAction('preflight', data: [
'scope_mode' => 'all_tenants',
])
->assertSet('preflight.affected_count', 1)
->callAction('run', data: [
'typed_confirmation' => 'BACKFILL',
'reason_code' => 'DATA_REPAIR',
'reason_text' => 'Attempt blocked by control',
])
->assertNotified('Findings lifecycle backfill paused');
expect(OperationRun::query()->where('type', 'findings.lifecycle.backfill')->count())->toBe(0);
$audit = AuditLog::query()
->where('action', AuditActionId::OperationalControlExecutionBlocked->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->workspace_id)->toBeNull()
->and($audit?->tenant_id)->toBeNull()
->and($audit?->status)->toBe('blocked')
->and($audit?->metadata['control_key'] ?? null)->toBe('findings.lifecycle.backfill')
->and($audit?->metadata['requested_scope'] ?? null)->toBe('all_tenants');
});

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Filament\Pages\TenantDashboard;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('does not emit telemetry or audit rows on passive dashboard and detail renders', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
$run = OperationRun::factory()->forTenant($tenant)->create([
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => OperationRunType::ReviewPackGenerate->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
test()->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
setTenantPanelContext($tenant);
Livewire::actingAs($user)->test(TenantDashboard::class);
Filament::setTenant(null, true);
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
test()->actingAs($platformUser, 'platform');
Livewire::test(ProductTelemetryKpis::class);
expect(ProductUsageEvent::query()->count())->toBe(0)
->and(AuditLog::query()->count())->toBe(0);
});
it('keeps the system dashboard aggregate-only without exposing raw telemetry identifiers', function (): void {
$tenant = Tenant::factory()->create([
'name' => 'NoLeak Tenant Name',
]);
ProductUsageEvent::factory()->forEvent(
ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
[
'source_surface' => 'tenant',
'include_operations' => true,
'include_pii' => false,
],
)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'subject_type' => 'review_pack',
'subject_id' => 'review-pack-raw-424242',
'occurred_at' => now()->subHour(),
]);
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
$this->actingAs($platformUser, 'platform')
->get('/system?window=24h')
->assertSuccessful()
->assertSee('Product telemetry')
->assertSee('Review packs requested')
->assertDontSee('review-pack-raw-424242')
->assertDontSee('NoLeak Tenant Name');
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
});
it('shows aggregate telemetry to existing dashboard-eligible platform users', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::OPS_VIEW,
PlatformCapabilities::RUNBOOKS_VIEW,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get('/system')
->assertSuccessful()
->assertSeeLivewire(ProductTelemetryKpis::class);
});
it('forbids aggregate telemetry for platform users outside the existing dashboard gate', function (): void {
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($user, 'platform')
->get('/system')
->assertForbidden();
});

View File

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser;
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use App\Support\SystemConsole\SystemConsoleWindow;
use Carbon\CarbonImmutable;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Filament::setCurrentPanel('system');
Filament::bootCurrentPanel();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-04-27 12:00:00'));
});
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function productTelemetryStats($component): array
{
$method = new ReflectionMethod(ProductTelemetryKpis::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => [
'value' => (string) $stat->getValue(),
'description' => $stat->getDescription(),
],
])
->all();
}
function actingAsSystemConsoleUser(): PlatformUser
{
$user = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
test()->actingAs($user, 'platform');
return $user;
}
function seedProductTelemetryEvent(
Tenant $tenant,
User $user,
string $eventName,
array $metadata,
string $subjectType,
int|string $subjectId,
CarbonImmutable $occurredAt,
): ProductUsageEvent {
return ProductUsageEvent::factory()
->forEvent($eventName, $metadata)
->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'subject_type' => $subjectType,
'subject_id' => (string) $subjectId,
'occurred_at' => $occurredAt,
]);
}
it('summarizes the five visible telemetry families and active workspaces for the selected window', function (): void {
actingAsSystemConsoleUser();
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->create();
$userB = User::factory()->create();
seedProductTelemetryEvent(
tenant: $tenantA,
user: $userA,
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
metadata: [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'ready',
'completed_at' => CarbonImmutable::now()->subHour(),
],
subjectType: 'tenant_onboarding_session',
subjectId: 101,
occurredAt: CarbonImmutable::now()->subHour(),
);
seedProductTelemetryEvent(
tenant: $tenantA,
user: $userA,
eventName: ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
metadata: [
'source_surface' => 'tenant_dashboard',
],
subjectType: 'tenant',
subjectId: (int) $tenantA->getKey(),
occurredAt: CarbonImmutable::now()->subMinutes(90),
);
seedProductTelemetryEvent(
tenant: $tenantB,
user: $userB,
eventName: ProductUsageEventCatalog::OPERATIONS_STARTED,
metadata: [
'operation_type' => 'review_pack.generate',
],
subjectType: 'operation_run',
subjectId: 202,
occurredAt: CarbonImmutable::now()->subHours(2),
);
seedProductTelemetryEvent(
tenant: $tenantB,
user: $userB,
eventName: ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
metadata: [
'source_surface' => 'tenant',
'include_operations' => true,
'include_pii' => false,
],
subjectType: 'review_pack',
subjectId: 303,
occurredAt: CarbonImmutable::now()->subHours(3),
);
seedProductTelemetryEvent(
tenant: $tenantB,
user: $userB,
eventName: ProductUsageEventCatalog::STORED_REPORT_CREATED,
metadata: [
'report_type' => 'permission_posture',
],
subjectType: 'stored_report',
subjectId: 404,
occurredAt: CarbonImmutable::now()->subDays(2),
);
$lastDayStats = productTelemetryStats(Livewire::withQueryParams([
'window' => SystemConsoleWindow::LastDay,
])->test(ProductTelemetryKpis::class));
expect($lastDayStats['Active workspaces'])->toBe([
'value' => '2',
'description' => '4 events in Last 24 hours',
])
->and($lastDayStats['Onboarding checkpoints']['value'])->toBe('1')
->and($lastDayStats['Support diagnostics']['value'])->toBe('1')
->and($lastDayStats['Operations started']['value'])->toBe('1')
->and($lastDayStats['Stored reports']['value'])->toBe('0')
->and($lastDayStats['Review packs requested']['value'])->toBe('1');
$lastWeekStats = productTelemetryStats(Livewire::withQueryParams([
'window' => SystemConsoleWindow::LastWeek,
])->test(ProductTelemetryKpis::class));
expect($lastWeekStats['Active workspaces'])->toBe([
'value' => '2',
'description' => '5 events in Last 7 days',
])
->and($lastWeekStats['Stored reports']['value'])->toBe('1');
});
it('renders an explicit zero state when the selected window has no telemetry rows', function (): void {
actingAsSystemConsoleUser();
$stats = productTelemetryStats(Livewire::withQueryParams([
'window' => SystemConsoleWindow::LastDay,
])->test(ProductTelemetryKpis::class));
expect($stats['Active workspaces'])->toBe([
'value' => '0',
'description' => 'No telemetry recorded in Last 24 hours.',
])
->and($stats['Onboarding checkpoints']['value'])->toBe('0')
->and($stats['Support diagnostics']['value'])->toBe('0')
->and($stats['Operations started']['value'])->toBe('0')
->and($stats['Stored reports']['value'])->toBe('0')
->and($stats['Review packs requested']['value'])->toBe('0');
});
it('uses the injected dashboard window when the livewire request query is absent', function (): void {
actingAsSystemConsoleUser();
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
seedProductTelemetryEvent(
tenant: $tenant,
user: $user,
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
metadata: [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'ready',
'completed_at' => CarbonImmutable::now()->subDays(3),
],
subjectType: 'tenant_onboarding_session',
subjectId: 909,
occurredAt: CarbonImmutable::now()->subDays(3),
);
$stats = productTelemetryStats(Livewire::test(ProductTelemetryKpis::class, [
'window' => SystemConsoleWindow::LastWeek,
]));
expect($stats['Active workspaces'])->toBe([
'value' => '1',
'description' => '1 events in Last 7 days',
])
->and($stats['Onboarding checkpoints'])->toBe([
'value' => '1',
'description' => 'Last 7 days',
]);
});

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\ProductUsageEvent;
use App\Models\ReviewPack;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Models\User;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prunes only product usage events older than the configured retention window', function (): void {
config()->set('tenantpilot.product_usage_event_retention_days', 90);
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$oldEvent = ProductUsageEvent::factory()->forEvent(
ProductUsageEventCatalog::OPERATIONS_STARTED,
['operation_type' => 'review_pack.generate'],
)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'subject_type' => 'operation_run',
'subject_id' => 'prune-old-run',
'occurred_at' => now()->subDays(120),
]);
$recentEvent = ProductUsageEvent::factory()->forEvent(
ProductUsageEventCatalog::OPERATIONS_STARTED,
['operation_type' => 'review_pack.generate'],
)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'subject_type' => 'operation_run',
'subject_id' => 'prune-recent-run',
'occurred_at' => now()->subDays(10),
]);
$operationRun = OperationRun::factory()->forTenant($tenant)->create([
'created_at' => now()->subDays(120),
]);
$storedReport = StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'created_at' => now()->subDays(120),
]);
$reviewPack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'created_at' => now()->subDays(120),
]);
$auditLog = AuditLog::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'action' => 'platform.test.audit',
'status' => 'success',
'metadata' => [],
'recorded_at' => now()->subDays(120),
]);
$this->artisan('tenantpilot:product-usage:prune')
->expectsOutputToContain('Deleted 1 product usage event(s) older than 90 days.')
->assertSuccessful();
expect(ProductUsageEvent::query()->whereKey($oldEvent->getKey())->exists())->toBeFalse()
->and(ProductUsageEvent::query()->whereKey($recentEvent->getKey())->exists())->toBeTrue()
->and(OperationRun::query()->whereKey($operationRun->getKey())->exists())->toBeTrue()
->and(StoredReport::query()->whereKey($storedReport->getKey())->exists())->toBeTrue()
->and(ReviewPack::query()->whereKey($reviewPack->getKey())->exists())->toBeTrue()
->and(AuditLog::query()->whereKey($auditLog->getKey())->exists())->toBeTrue();
});
it('honors the explicit days override when pruning product usage events', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$oldEvent = ProductUsageEvent::factory()->forEvent(
ProductUsageEventCatalog::STORED_REPORT_CREATED,
['report_type' => 'permission_posture'],
)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'subject_type' => 'stored_report',
'subject_id' => 'old-report',
'occurred_at' => now()->subDays(35),
]);
$recentEvent = ProductUsageEvent::factory()->forEvent(
ProductUsageEventCatalog::STORED_REPORT_CREATED,
['report_type' => 'permission_posture'],
)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'subject_type' => 'stored_report',
'subject_id' => 'recent-report',
'occurred_at' => now()->subDays(10),
]);
$this->artisan('tenantpilot:product-usage:prune --days=30')
->expectsOutputToContain('Deleted 1 product usage event(s) older than 30 days.')
->assertSuccessful();
expect(ProductUsageEvent::query()->whereKey($oldEvent->getKey())->exists())->toBeFalse()
->and(ProductUsageEvent::query()->whereKey($recentEvent->getKey())->exists())->toBeTrue();
});

View File

@ -3,9 +3,14 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\System\Pages\Dashboard; use App\Filament\System\Pages\Dashboard;
use App\Filament\System\Widgets\ControlTowerKpis;
use App\Filament\System\Widgets\ControlTowerRecentFailures;
use App\Filament\System\Widgets\ControlTowerTopOffenders;
use App\Filament\System\Widgets\ProductTelemetryKpis;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\SystemConsole\SystemConsoleWindow; use App\Support\SystemConsole\SystemConsoleWindow;
use Filament\Widgets\WidgetConfiguration;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@ -50,3 +55,34 @@
]) ])
->assertSet('window', SystemConsoleWindow::LastWeek); ->assertSet('window', SystemConsoleWindow::LastWeek);
}); });
it('passes the selected window into all window-aware dashboard widgets', function () {
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::CONSOLE_VIEW,
],
'is_active' => true,
]);
$component = Livewire::actingAs($platformUser, 'platform')
->withQueryParams(['window' => SystemConsoleWindow::LastWeek])
->test(Dashboard::class)
->assertSet('window', SystemConsoleWindow::LastWeek);
$widgets = $component->instance()->getWidgets();
expect($widgets)->toHaveCount(5)
->and($widgets[1])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[2])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[3])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[4])->toBeInstanceOf(WidgetConfiguration::class)
->and($widgets[1]->widget)->toBe(ControlTowerKpis::class)
->and($widgets[2]->widget)->toBe(ProductTelemetryKpis::class)
->and($widgets[3]->widget)->toBe(ControlTowerTopOffenders::class)
->and($widgets[4]->widget)->toBe(ControlTowerRecentFailures::class)
->and($widgets[1]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[2]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[3]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek])
->and($widgets[4]->getProperties())->toBe(['window' => SystemConsoleWindow::LastWeek]);
});

View File

@ -48,6 +48,41 @@
->and($result->status())->toBe(404); ->and($result->status())->toBe(404);
}); });
it('returns not found for workspace members missing linked tenant entitlement', function (): void {
$tenant = Tenant::factory()->onboarding()->create();
$owner = User::factory()->create();
$workspaceOnlyUser = User::factory()->create();
createUserWithTenant(
tenant: $tenant,
user: $owner,
role: 'owner',
workspaceRole: 'owner',
ensureDefaultMicrosoftProviderConnection: false,
);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $workspaceOnlyUser->getKey(),
'role' => 'owner',
]);
$draft = createOnboardingDraft([
'workspace' => $tenant->workspace,
'tenant' => $tenant,
'started_by' => $owner,
'updated_by' => $owner,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$result = app(TenantOnboardingSessionPolicy::class)->view($workspaceOnlyUser, $draft);
expect($result)->toBeInstanceOf(Response::class)
->and($result->allowed())->toBeFalse()
->and($result->status())->toBe(404);
});
it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void { it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void {
$tenant = Tenant::factory()->onboarding()->create(); $tenant = Tenant::factory()->onboarding()->create();
$readonlyUser = User::factory()->create(); $readonlyUser = User::factory()->create();

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Support\OperationalControls\OperationalControlCatalog;
it('exposes only active runtime controls in the bounded control catalog', function (): void {
$catalog = app(OperationalControlCatalog::class);
expect($catalog->keys())->toBe(['restore.execute'])
->and($catalog->definition('restore.execute'))->toMatchArray([
'key' => 'restore.execute',
'label' => 'Restore execution',
'supported_scopes' => ['global', 'workspace'],
'operation_types' => ['restore.execute'],
]);
});
it('rejects removed or unknown control keys', function (): void {
$catalog = app(OperationalControlCatalog::class);
expect(fn (): array => $catalog->definition('findings.lifecycle.backfill'))
->toThrow(\InvalidArgumentException::class)
->and(fn (): array => $catalog->definition('tenant.review.compose'))
->toThrow(\InvalidArgumentException::class);
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Models\OperationalControlActivation;
use App\Models\Workspace;
use App\Support\OperationalControls\OperationalControlEvaluator;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns enabled when no activation matches', function (): void {
$workspace = Workspace::factory()->create();
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
expect($decision->isEnabled())->toBeTrue()
->and($decision->effectiveState)->toBe('enabled')
->and($decision->scopeLabel())->toBe('No active pause')
->and($decision->matchedScopeType)->toBe('none')
->and($decision->workspaceId)->toBeNull()
->and($decision->reasonText)->toBeNull()
->and($decision->sourceActivationId)->toBeNull();
});
it('returns the matching workspace pause when present', function (): void {
$workspace = Workspace::factory()->create();
$activation = OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => 'Restore execution is paused for this workspace.',
]);
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
expect($decision->isPaused())->toBeTrue()
->and($decision->effectiveState)->toBe('paused')
->and($decision->hasWorkspaceScope())->toBeTrue()
->and($decision->scopeLabel())->toBe('Workspace #'.(int) $workspace->getKey())
->and($decision->matchedScopeType)->toBe('workspace')
->and($decision->workspaceId)->toBe((int) $workspace->getKey())
->and($decision->reasonText)->toBe('Restore execution is paused for this workspace.')
->and($decision->sourceActivationId)->toBe((int) $activation->getKey());
});

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\OperationalControlActivation;
use App\Models\Workspace;
use App\Support\OperationalControls\OperationalControlEvaluator;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('prefers an active global pause over a workspace pause', function (): void {
$workspace = Workspace::factory()->create();
OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => 'Workspace pause.',
]);
$globalActivation = OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'restore.execute',
'reason_text' => 'Global incident pause.',
]);
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
expect($decision->isPaused())->toBeTrue()
->and($decision->matchedScopeType)->toBe('global')
->and($decision->workspaceId)->toBeNull()
->and($decision->reasonText)->toBe('Global incident pause.')
->and($decision->sourceActivationId)->toBe((int) $globalActivation->getKey());
});
it('ignores expired global activations when resolving the effective state', function (): void {
$workspace = Workspace::factory()->create();
OperationalControlActivation::factory()->forGlobalScope()->create([
'control_key' => 'restore.execute',
'reason_text' => 'Expired global pause.',
'expires_at' => now()->subMinute(),
]);
$workspaceActivation = OperationalControlActivation::factory()->workspaceScoped()->create([
'control_key' => 'restore.execute',
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => 'Active workspace pause.',
]);
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
expect($decision->isPaused())->toBeTrue()
->and($decision->matchedScopeType)->toBe('workspace')
->and($decision->workspaceId)->toBe((int) $workspace->getKey())
->and($decision->reasonText)->toBe('Active workspace pause.')
->and($decision->sourceActivationId)->toBe((int) $workspaceActivation->getKey());
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
it('exposes the locked first-slice topic catalog and safe knowledge source', function (): void {
$catalog = new ContextualHelpCatalog();
expect($catalog->keys())->toBe([
ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING,
ContextualHelpCatalog::CONNECTION_UNHEALTHY,
ContextualHelpCatalog::VERIFICATION_STALE,
ContextualHelpCatalog::VERIFICATION_FAILED,
ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE,
ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE,
ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED,
])->and($catalog->definition(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
'surface_families' => ['onboarding', 'support_diagnostics'],
'headline' => 'Admin consent required',
'safe_next_action' => 'Grant admin consent and re-run verification.',
]);
$knowledgeSource = $catalog->knowledgeSource();
$topicsByKey = collect($knowledgeSource['topics'])->keyBy('topic_key');
expect($knowledgeSource)->toMatchArray([
'version' => 1,
'topic_count' => 8,
])->and($topicsByKey->keys()->all())->toBe($catalog->keys())
->and($topicsByKey->get(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED))->toMatchArray([
'headline' => 'Admin consent required',
'docs_links' => [
[
'label' => 'Grant admin consent',
'kind' => 'action',
'url' => null,
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
],
[
'label' => 'Admin consent guide',
'kind' => 'docs',
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
'resolver' => null,
],
],
]);
});
it('rejects unknown contextual help topics', function (): void {
$catalog = new ContextualHelpCatalog();
expect(fn (): array => $catalog->definition('unknown-topic'))
->toThrow(InvalidArgumentException::class, 'Unknown contextual help topic');
});
it('keeps every machine-readable topic on the approved metadata surface only', function (): void {
$knowledgeSource = app(\App\Support\ProductKnowledge\ContextualHelpResolver::class)->knowledgeSource();
$allowedResolvers = [
null,
ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
];
foreach ($knowledgeSource['topics'] as $topic) {
expect(array_keys($topic))->toBe([
'topic_key',
'surface_families',
'headline',
'short_explanation',
'troubleshooting_steps',
'safe_next_action',
'glossary_terms',
'docs_links',
]);
foreach ($topic['docs_links'] as $link) {
expect(array_keys($link))->toBe(['label', 'kind', 'url', 'resolver'])
->and($link['resolver'])->toBeIn($allowedResolvers);
}
}
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
it('returns null for blank or unknown topic keys through the fallback contract', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->tryResolve(null))->toBeNull()
->and($resolver->tryResolve(''))->toBeNull()
->and($resolver->tryResolve('unknown-topic'))->toBeNull();
});
it('keeps dynamic link metadata but no tenant-specific url when tenant context is unavailable', function (): void {
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING);
expect($payload['docs_links'][0])->toMatchArray([
'label' => 'Open required permissions',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
'url' => null,
]);
});
it('exposes a safe machine-readable knowledge source without tenant or secret fields', function (): void {
$knowledgeSource = app(ContextualHelpResolver::class)->knowledgeSource();
$encoded = json_encode($knowledgeSource, JSON_THROW_ON_ERROR);
expect($encoded)->not->toContain('tenant_id')
->not->toContain('provider_connection_id')
->not->toContain('raw_response_body')
->not->toContain('credential')
->and($knowledgeSource['topic_count'])->toBe(8);
});
it('keeps explicit unmapped support-diagnostics reason codes on the null fallback path', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->topicKeyForSupportDiagnostics(
reasonCode: 'ext.support.manual_lookup_needed',
hasIncompleteEvidence: true,
runOutcome: null,
))->toBeNull()
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::UnknownError,
hasIncompleteEvidence: true,
runOutcome: null,
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED);
});

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\OperationRunOutcome;
use App\Support\ProductKnowledge\ContextualHelpCatalog;
use App\Support\ProductKnowledge\ContextualHelpResolver;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function contextualHelpTruthEnvelope(Tenant $tenant): ArtifactTruthEnvelope
{
return new ArtifactTruthEnvelope(
artifactFamily: 'support_diagnostics',
artifactKey: 'tenant_support_diagnostics',
workspaceId: (int) $tenant->workspace_id,
tenantId: (int) $tenant->getKey(),
executionOutcome: 'blocked',
artifactExistence: 'created',
contentState: 'partial',
freshnessState: 'current',
publicationReadiness: null,
supportState: 'supported',
actionability: 'required',
primaryLabel: 'Verification blocked',
primaryExplanation: 'Verification cannot continue until the prerequisite is resolved.',
diagnosticLabel: 'Admin consent required',
nextActionLabel: 'Retry verification',
nextActionUrl: null,
relatedRunId: null,
relatedArtifactUrl: null,
);
}
it('resolves contextual help with reason translation, operator summary, and tenant-aware links', function (): void {
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED, [
'tenant' => $tenant,
'reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'artifact_truth' => contextualHelpTruthEnvelope($tenant),
]);
expect($payload)->toMatchArray([
'topic_key' => ContextualHelpCatalog::ADMIN_CONSENT_REQUIRED,
'headline' => 'Admin consent required',
'short_explanation' => 'The provider connection cannot continue until admin consent is granted.',
'safe_next_action' => 'Retry verification',
'reason_label' => 'Admin consent required',
'diagnostic_code' => ProviderReasonCodes::ProviderConsentMissing,
])->and($payload['docs_links'][0])->toMatchArray([
'label' => 'Grant admin consent',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_ADMIN_CONSENT_PRIMARY,
'url' => RequiredPermissionsLinks::adminConsentGuideUrl(),
])->and($payload['operator_summary'])->toMatchArray([
'nextActionText' => 'Retry verification',
'diagnosticsAvailable' => true,
]);
});
it('resolves required permissions links against the current tenant', function (): void {
[, $tenant] = createUserWithTenant(ensureDefaultMicrosoftProviderConnection: false);
$payload = app(ContextualHelpResolver::class)->resolve(ContextualHelpCatalog::REQUIRED_PERMISSIONS_MISSING, [
'tenant' => $tenant,
]);
expect($payload['docs_links'][0])->toMatchArray([
'label' => 'Open required permissions',
'resolver' => ContextualHelpCatalog::LINK_RESOLVER_REQUIRED_PERMISSIONS,
'url' => RequiredPermissionsLinks::requiredPermissions($tenant),
]);
});
it('maps support diagnostics topics from shared reason and outcome signals', function (): void {
$resolver = app(ContextualHelpResolver::class);
expect($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::RateLimited,
hasIncompleteEvidence: false,
runOutcome: null,
))->toBe(ContextualHelpCatalog::RETRYABLE_PROVIDER_FAILURE)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: ProviderReasonCodes::UnknownError,
hasIncompleteEvidence: false,
runOutcome: OperationRunOutcome::Failed->value,
))->toBe(ContextualHelpCatalog::MANUAL_HANDOFF_REQUIRED)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: null,
hasIncompleteEvidence: false,
runOutcome: OperationRunOutcome::Failed->value,
))->toBe(ContextualHelpCatalog::VERIFICATION_FAILED)
->and($resolver->topicKeyForSupportDiagnostics(
reasonCode: null,
hasIncompleteEvidence: true,
runOutcome: null,
))->toBe(ContextualHelpCatalog::DIAGNOSTIC_EVIDENCE_INCOMPLETE);
});

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('records a tenant-owned usage event with the catalog feature area', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();
$user = User::factory()->create();
$event = app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant_onboarding_session',
subjectId: 42,
metadata: [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
],
);
expect($event->event_name)->toBe(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED)
->and($event->feature_area)->toBe('onboarding')
->and($event->workspace_id)->toBe((int) $workspace->getKey())
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->user_id)->toBe((int) $user->getKey())
->and($event->subject_type)->toBe('tenant_onboarding_session')
->and($event->subject_id)->toBe('42')
->and($event->metadata)->toBe([
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
]);
$this->assertDatabaseHas('product_usage_events', [
'id' => (int) $event->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'event_name' => ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
'feature_area' => 'onboarding',
'subject_type' => 'tenant_onboarding_session',
'subject_id' => '42',
]);
});
it('rejects unknown event names before writing telemetry rows', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();
$user = User::factory()->create();
expect(fn () => app(ProductTelemetryRecorder::class)->record(
eventName: 'product.unknown',
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant',
subjectId: 99,
))->toThrow(InvalidArgumentException::class, 'Unknown product telemetry event');
});

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetryRecorder;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
dataset('unsafeProductTelemetryMetadata', [
'email address' => ['checkpoint_key', 'operator@example.com'],
'free text' => ['checkpoint_key', 'completed checkpoint'],
'nested payload' => ['checkpoint_key', ['raw' => 'payload']],
]);
it('normalizes timestamp metadata into ISO strings', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();
$user = User::factory()->create();
$completedAt = CarbonImmutable::parse('2026-04-26T19:40:38+00:00');
$event = app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant_onboarding_session',
subjectId: 7,
metadata: [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
'completed_at' => $completedAt,
],
);
expect($event->metadata)->toBe([
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
'completed_at' => $completedAt->toIso8601String(),
]);
});
it('rejects metadata keys that are not declared for the event', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();
$user = User::factory()->create();
expect(fn () => app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::OPERATIONS_STARTED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'operation_run',
subjectId: 9,
metadata: [
'unknown_key' => 'tenant.review_pack.generate',
],
))->toThrow(InvalidArgumentException::class, 'Unsupported telemetry metadata keys');
});
it('rejects unsafe metadata values', function (string $key, mixed $value) {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create();
$user = User::factory()->create();
expect(fn () => app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant_onboarding_session',
subjectId: 77,
metadata: [
$key => $value,
'lifecycle_state' => 'completed',
],
))->toThrow(InvalidArgumentException::class);
})->with('unsafeProductTelemetryMetadata');

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Models\ProductUsageEvent;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Support\ProductTelemetry\ProductTelemetrySummaryQuery;
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('summarizes visible families and active workspaces for a selected window', function () {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$tenantA = Tenant::factory()->for($workspaceA)->create();
$workspaceB = Workspace::factory()->create();
$tenantB = Tenant::factory()->for($workspaceB)->create();
ProductUsageEvent::factory()
->forEvent(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED, [
'checkpoint_key' => 'tenant_connected',
'lifecycle_state' => 'completed',
])
->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenantA->getKey(),
'user_id' => (int) $user->getKey(),
'subject_type' => 'tenant_onboarding_session',
'subject_id' => '11',
'occurred_at' => now()->subHours(2),
]);
ProductUsageEvent::factory()
->forEvent(ProductUsageEventCatalog::OPERATIONS_STARTED, [
'operation_type' => 'tenant.review_pack.generate',
])
->create([
'workspace_id' => (int) $workspaceB->getKey(),
'tenant_id' => (int) $tenantB->getKey(),
'user_id' => (int) $user->getKey(),
'subject_type' => 'operation_run',
'subject_id' => '22',
'occurred_at' => now()->subHours(1),
]);
ProductUsageEvent::factory()
->forEvent(ProductUsageEventCatalog::STORED_REPORT_CREATED, [
'report_type' => 'permission_posture',
])
->create([
'workspace_id' => (int) $workspaceB->getKey(),
'tenant_id' => (int) $tenantB->getKey(),
'user_id' => (int) $user->getKey(),
'subject_type' => 'stored_report',
'subject_id' => '33',
'occurred_at' => now()->subMinutes(30),
]);
ProductUsageEvent::factory()
->forEvent(ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED, [
'source_surface' => 'tenant_dashboard',
])
->create([
'workspace_id' => (int) $workspaceA->getKey(),
'tenant_id' => (int) $tenantA->getKey(),
'user_id' => (int) $user->getKey(),
'subject_type' => 'tenant',
'subject_id' => '44',
'occurred_at' => now()->subDays(3),
]);
$summary = app(ProductTelemetrySummaryQuery::class)->summarize(now()->subDay());
expect($summary['active_workspaces'])->toBe(2)
->and($summary['total_events'])->toBe(3)
->and($summary['families'][ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED])
->toBe(['label' => 'Onboarding checkpoints', 'count' => 1])
->and($summary['families'][ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED])
->toBe(['label' => 'Support diagnostics', 'count' => 0])
->and($summary['families'][ProductUsageEventCatalog::OPERATIONS_STARTED])
->toBe(['label' => 'Operations started', 'count' => 1])
->and($summary['families'][ProductUsageEventCatalog::STORED_REPORT_CREATED])
->toBe(['label' => 'Stored reports', 'count' => 1])
->and($summary['families'][ProductUsageEventCatalog::REVIEW_PACK_REQUESTED])
->toBe(['label' => 'Review packs requested', 'count' => 0]);
});
it('returns a zero summary when the selected window has no events', function () {
$summary = app(ProductTelemetrySummaryQuery::class)->summarize(now()->subDay());
expect($summary['active_workspaces'])->toBe(0)
->and($summary['total_events'])->toBe(0)
->and(array_column($summary['families'], 'count'))
->toBe([0, 0, 0, 0, 0]);
});

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
it('exposes the bounded first-slice event catalog', function () {
$catalog = new ProductUsageEventCatalog();
expect($catalog->names())->toBe([
ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED,
ProductUsageEventCatalog::OPERATIONS_STARTED,
ProductUsageEventCatalog::STORED_REPORT_CREATED,
ProductUsageEventCatalog::REVIEW_PACK_REQUESTED,
])->and($catalog->visibleFamilies())->toBe([
ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED => 'Onboarding checkpoints',
ProductUsageEventCatalog::SUPPORT_DIAGNOSTICS_OPENED => 'Support diagnostics',
ProductUsageEventCatalog::OPERATIONS_STARTED => 'Operations started',
ProductUsageEventCatalog::STORED_REPORT_CREATED => 'Stored reports',
ProductUsageEventCatalog::REVIEW_PACK_REQUESTED => 'Review packs requested',
])->and($catalog->allowedMetadataKeys(ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED))
->toBe(['checkpoint_key', 'lifecycle_state', 'completed_at']);
});
it('rejects unknown product telemetry events', function () {
$catalog = new ProductUsageEventCatalog();
expect(fn () => $catalog->definition('product.unknown'))
->toThrow(InvalidArgumentException::class, 'Unknown product telemetry event');
});

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds tenant bundles with stable section order and stable reference order', function (): void {
$tenant = Tenant::factory()->create(['name' => 'Deterministic Tenant']);
ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Deterministic connection',
]);
OperationRun::factory()
->forTenant($tenant)
->create([
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now()->subMinute(),
]);
$firstFinding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'severity' => Finding::SEVERITY_LOW,
'last_seen_at' => now()->subMinutes(4),
]);
$secondFinding = Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'severity' => Finding::SEVERITY_CRITICAL,
'last_seen_at' => now()->subMinutes(2),
]);
$builder = app(SupportDiagnosticBundleBuilder::class);
$firstBundle = $builder->forTenant($tenant);
$secondBundle = $builder->forTenant($tenant->fresh());
expect(array_column($firstBundle['sections'], 'key'))
->toBe([
'overview',
'provider_connection',
'operation_context',
'findings',
'stored_reports',
'tenant_review',
'review_pack',
'audit_history',
])
->and(array_column($firstBundle['sections'], 'key'))->toBe(array_column($secondBundle['sections'], 'key'));
$firstFindingsSection = collect($firstBundle['sections'])->firstWhere('key', 'findings');
$secondFindingsSection = collect($secondBundle['sections'])->firstWhere('key', 'findings');
expect(array_column($firstFindingsSection['references'], 'record_id'))
->toBe([(string) $firstFinding->getKey(), (string) $secondFinding->getKey()])
->and($firstFindingsSection['references'])->toBe($secondFindingsSection['references']);
});
it('degrades missing operation-run support context without failing bundle generation', function (): void {
$workspace = Workspace::factory()->create();
$tenantlessRun = OperationRun::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'type' => OperationRunType::BaselineCompare->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'summary_counts' => [
'total' => 0,
'processed' => 0,
],
'completed_at' => now(),
]);
$bundle = app(SupportDiagnosticBundleBuilder::class)->forOperationRun($tenantlessRun);
$sections = collect($bundle['sections'])->keyBy('key');
expect($bundle['context']['tenant_id'])->toBeNull()
->and($bundle['summary']['completeness_note'])->toContain('Provider connection')
->and($bundle['summary']['completeness_note'])->toContain('Review pack')
->and($sections['operation_context']['availability'])->toBe('available')
->and($sections['provider_connection']['availability'])->toBe('missing')
->and($sections['findings']['availability'])->toBe('missing')
->and($sections['stored_reports']['availability'])->toBe('missing')
->and($sections['tenant_review']['availability'])->toBe('missing')
->and($sections['review_pack']['availability'])->toBe('missing');
});
it('marks references without canonical destinations as inaccessible', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
$method = new \ReflectionMethod(SupportDiagnosticBundleBuilder::class, 'modelReference');
$reference = $method->invoke(
app(SupportDiagnosticBundleBuilder::class),
'provider_connection',
$connection,
'Detached provider connection',
'Open provider connection',
null,
null,
);
expect($reference)
->toBeArray()
->and($reference['availability'])->toBe('inaccessible')
->and($reference['access_reason'])->toBe('Canonical destination is not available from this context.');
});

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