Compare commits
3 Commits
240-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d96abc65fb | |||
| 17d3ca8313 | |||
| ab6eccaf40 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -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)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
@ -290,9 +294,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||
- 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 -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
939
.github/skills/spec-kit-end-to-end/SKILL.md
vendored
Normal file
939
.github/skills/spec-kit-end-to-end/SKILL.md
vendored
Normal file
@ -0,0 +1,939 @@
|
||||
---
|
||||
name: spec-kit-end-to-end
|
||||
description: End-to-end Spec Kit workflow for TenantPilot/TenantAtlas: select the next suitable spec candidate from roadmap/spec-candidates when needed, create or update spec.md/plan.md/tasks.md, optionally implement the active spec, run tests, browser smoke checks where applicable, post-implementation analysis, fix confirmed findings, and repeat until no in-scope findings remain or a stop condition is reached.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit End-to-End Workflow
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to run an end-to-end Spec Kit workflow for TenantPilot/TenantAtlas.
|
||||
|
||||
This skill supports three modes:
|
||||
|
||||
1. **Preparation only**: select or scope the next suitable feature from roadmap/spec-candidates and create or update `spec.md`, `plan.md`, and `tasks.md`.
|
||||
2. **Implementation only**: implement an already prepared spec, run tests/checks, run strict post-implementation analysis, fix confirmed findings, and repeat until clean or a bounded stop condition is reached.
|
||||
3. **End-to-end**: select or create a spec and then implement it in the same invocation, but only when the user explicitly requests end-to-end execution.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
```text
|
||||
feature idea / roadmap item / spec candidate / active spec
|
||||
→ determine requested mode
|
||||
→ inspect repo truth, constitution, roadmap, spec candidates, existing specs, and relevant code
|
||||
→ create or update spec.md + plan.md + tasks.md when preparation is needed
|
||||
→ evaluate quality gates
|
||||
→ implement only when the user explicitly asks for implementation or end-to-end execution
|
||||
→ 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 + analysis + fix loop until clean or bounded stop condition is reached
|
||||
→ final report
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks for any Spec Kit workflow around TenantPilot/TenantAtlas, including:
|
||||
|
||||
- selecting the next best spec candidate from `docs/product/spec-candidates.md` and roadmap sources
|
||||
- turning a feature idea, roadmap item, or candidate into `spec.md`, `plan.md`, and `tasks.md`
|
||||
- preparing Spec Kit artifacts in one pass
|
||||
- implementing an existing or newly prepared spec
|
||||
- running implementation followed by strict analysis and fix iterations
|
||||
- executing a full end-to-end flow from candidate selection to implementation verification
|
||||
|
||||
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
|
||||
Erstelle die Spec Kit Artefakte und implementiere sie danach mit Analyse/Fix-Loop.
|
||||
```
|
||||
|
||||
```text
|
||||
Implementiere die aktive Spec und analysiere danach, ob alles passt.
|
||||
```
|
||||
|
||||
```text
|
||||
Mach Spec Kit implement und danach analyse. Behebe alle Abweichungen und wiederhole bis sauber.
|
||||
```
|
||||
|
||||
```text
|
||||
Run end-to-end: choose next spec, create spec/plan/tasks, implement, analyze, fix until no in-scope findings remain.
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Work strictly repo-based.
|
||||
- Use the repository's actual Spec Kit workflow, scripts, templates, branch naming rules, and generated paths when available.
|
||||
- Determine the requested mode before changing files:
|
||||
- preparation only
|
||||
- implementation only
|
||||
- end-to-end preparation plus implementation
|
||||
- Do not implement application code unless the user explicitly asks for implementation, `implement`, or end-to-end execution.
|
||||
- When in preparation-only mode, create or update only Spec Kit preparation artifacts unless repository conventions require additional documentation artifacts.
|
||||
- When in implementation mode, implement only the active or explicitly named Spec Kit feature.
|
||||
- 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.
|
||||
- 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 and patches over broad rewrites.
|
||||
- Treat repository truth as authoritative over assumptions.
|
||||
- If repository truth conflicts with the user-provided draft or spec, keep repository truth and document the deviation.
|
||||
- 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, 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 preparation to implementation unless the Spec Readiness Gate passes or the user explicitly accepts the documented readiness risks.
|
||||
- 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:
|
||||
|
||||
- feature title and short goal
|
||||
- full spec candidate
|
||||
- roadmap item
|
||||
- rough problem statement
|
||||
- UX or architecture improvement idea
|
||||
- explicit spec directory such as `specs/<number>-<slug>/`
|
||||
- instruction to use the current active Spec Kit feature
|
||||
- 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 implementation is requested but 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 the sources relevant to the requested mode.
|
||||
|
||||
For preparation mode, 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
|
||||
|
||||
For implementation mode, 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 running any Spec Kit command or 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 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.
|
||||
|
||||
## Mode Selection
|
||||
|
||||
Select exactly one mode per invocation unless the user explicitly asks for end-to-end execution.
|
||||
|
||||
### Preparation Only
|
||||
|
||||
Use when the user asks to:
|
||||
|
||||
- create spec/plan/tasks
|
||||
- prepare a feature
|
||||
- choose the next best spec candidate
|
||||
- turn roadmap/spec-candidates into a spec
|
||||
- run specify/plan/tasks/analyze without implementation
|
||||
- avoid implementation
|
||||
|
||||
Output is limited to Spec Kit preparation artifacts, preparation-artifact fixes, and final preparation summary.
|
||||
|
||||
### Implementation Only
|
||||
|
||||
Use when the user asks to:
|
||||
|
||||
- implement an active spec
|
||||
- run Spec Kit implement
|
||||
- analyze after implementation
|
||||
- fix implementation findings
|
||||
|
||||
Requires an existing active or explicitly named spec.
|
||||
|
||||
### End-to-End
|
||||
|
||||
Use only when the user explicitly asks to:
|
||||
|
||||
- choose/create the spec and then implement it
|
||||
- run the full workflow
|
||||
- go from candidate to implementation
|
||||
- prepare and implement in one pass
|
||||
|
||||
End-to-end mode must keep preparation and implementation phases clearly separated.
|
||||
|
||||
End-to-end mode must pass the Candidate Selection Gate and Spec Readiness Gate before implementation begins.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Quality gates are mandatory checkpoints. They make the workflow safe for agentic execution without allowing uncontrolled scope expansion.
|
||||
|
||||
### 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 implementation starts, including end-to-end mode.
|
||||
|
||||
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:
|
||||
|
||||
- In preparation-only mode, report the readiness gaps and provide the manual analysis prompt.
|
||||
- In end-to-end mode, stop before implementation unless the user explicitly asked to proceed despite the documented readiness risks.
|
||||
- Do not compensate for an unclear spec by inventing implementation scope.
|
||||
|
||||
### Gate 3: 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 4: 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 5: 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 documentation-only, spec-only, 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 6: 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 7: 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, 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.
|
||||
|
||||
## 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
|
||||
|
||||
Use this section when the selected mode is preparation-only or end-to-end.
|
||||
|
||||
### 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
|
||||
|
||||
In preparation-only mode, 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`
|
||||
- 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 in preparation-only mode 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: Evaluate the Spec Readiness Gate
|
||||
|
||||
After preparation analyze has passed or preparation-artifact issues have been fixed, evaluate the Spec Readiness Gate.
|
||||
|
||||
In preparation-only mode, 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.
|
||||
|
||||
## Implementation Loop
|
||||
|
||||
Only execute this section when the selected mode is implementation-only or end-to-end.
|
||||
|
||||
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.
|
||||
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 a Spec Kit command, preparation analyze phase, 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 running Spec Kit commands or 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
|
||||
|
||||
For preparation-only mode, 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. Quality gates evaluated and their result
|
||||
11. Recommended next implementation prompt
|
||||
12. Explicit statement that no application implementation was performed
|
||||
|
||||
For implementation-only or end-to-end mode, 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 Prompts
|
||||
|
||||
For preparation-only mode, provide a ready-to-copy 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.
|
||||
```
|
||||
|
||||
For preparation-only mode, also provide a ready-to-copy implementation prompt 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.
|
||||
```
|
||||
|
||||
For implementation-only or end-to-end mode, 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 Invocations
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Nutze den Skill spec-kit-end-to-end.
|
||||
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.
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Implementiere die aktive Spec. Danach analyse gegen spec/plan/tasks/constitution ausführen, alle in-scope Findings beheben und wiederhole bis sauber.
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
Run end-to-end: wähle die nächste sinnvolle Spec aus spec-candidates/roadmap, erstelle spec/plan/tasks, implementiere sie danach und wiederhole analyse/fix bis sauber.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
1. Run preparation mode first.
|
||||
2. Clearly report the selected candidate and created spec directory.
|
||||
3. Continue into implementation mode only because the user explicitly requested end-to-end execution.
|
||||
4. Implement only the newly created active spec scope.
|
||||
5. Run tests/checks, browser smoke checks where applicable, post-implementation analysis, and bounded fix iterations.
|
||||
6. Fix all confirmed in-scope findings regardless of severity when safe and bounded.
|
||||
7. Report final implementation status, gates, browser smoke result, and residual risks.
|
||||
```
|
||||
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
398
.github/skills/spec-kit-next-best-one-shot/SKILL.md
vendored
@ -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.
|
||||
```
|
||||
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
294
.github/skills/spec-kit-one-shot-prep/SKILL.md
vendored
@ -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.
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -51,6 +52,14 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
|
||||
reason: null,
|
||||
source: 'cli',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $e) {
|
||||
$this->error(sprintf(
|
||||
'Backfill paused for tenant %d: %s',
|
||||
(int) $tenant->getKey(),
|
||||
$e->getMessage(),
|
||||
));
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillRunbookService;
|
||||
use App\Services\Runbooks\FindingsLifecycleBackfillScope;
|
||||
use App\Services\Runbooks\RunbookReason;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -31,6 +32,10 @@ public function handle(FindingsLifecycleBackfillRunbookService $runbookService):
|
||||
|
||||
$this->info('Deploy runbooks started (if needed).');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (OperationalControlBlockedException $e) {
|
||||
$this->info('Deploy runbooks paused: '.$e->getMessage());
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (ValidationException $e) {
|
||||
$errors = $e->errors();
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
@ -25,6 +26,8 @@
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
@ -39,6 +42,7 @@
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\EmbeddedSchema;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
@ -81,6 +85,11 @@ public function getTitle(): string|Htmlable
|
||||
*/
|
||||
public ?array $navigationContextPayload = null;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
/**
|
||||
* @return array<Action|ActionGroup>
|
||||
*/
|
||||
@ -130,6 +139,10 @@ protected function getHeaderActions(): array
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: OperationRunLinks::index());
|
||||
|
||||
if (isset($this->run)) {
|
||||
$actions[] = $this->openSupportDiagnosticsAction();
|
||||
}
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
}
|
||||
@ -208,6 +221,116 @@ 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,
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
|
||||
public function mount(OperationRun $run): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -11,13 +11,28 @@
|
||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||
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\Rbac\UiEnforcement;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Widgets\Widget;
|
||||
use Filament\Widgets\WidgetConfiguration;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDashboard extends Dashboard
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $supportDiagnosticsAuditKeys = [];
|
||||
|
||||
/**
|
||||
* @param array<mixed> $parameters
|
||||
*/
|
||||
@ -46,4 +61,101 @@ public function getColumns(): int|array
|
||||
{
|
||||
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,
|
||||
);
|
||||
|
||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Services\Onboarding\OnboardingDraftMutationService;
|
||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||
@ -38,6 +39,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Links\RequiredPermissionsLinks;
|
||||
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
@ -314,6 +316,7 @@ public function content(Schema $schema): Schema
|
||||
SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll')
|
||||
->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()),
|
||||
...$this->resumeContextSchema(),
|
||||
...$this->routeBoundReadinessSchema(),
|
||||
Wizard::make([
|
||||
Step::make('Identify managed tenant')
|
||||
->description('Create or resume a managed tenant in this workspace.')
|
||||
@ -926,6 +929,7 @@ private function draftPickerSchema(): array
|
||||
Text::make('Draft age')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
||||
...$this->draftCompactReadinessSchema($draft),
|
||||
SchemaActions::make([
|
||||
Action::make('resume_draft_'.$draft->getKey())
|
||||
->label($this->resumeOnboardingActionLabel())
|
||||
@ -978,6 +982,840 @@ private function resumeContextSchema(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function routeBoundReadinessSchema(): array
|
||||
{
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $this->onboardingReadinessPayload($draft);
|
||||
|
||||
$schema = [
|
||||
Section::make('Onboarding readiness')
|
||||
->description($payload['blocker']['operator_summary'])
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Text::make('Current checkpoint')
|
||||
->color('gray'),
|
||||
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
||||
->badge()
|
||||
->color('info'),
|
||||
Text::make('Lifecycle')
|
||||
->color('gray'),
|
||||
Text::make($payload['checkpoint']['lifecycle_label'])
|
||||
->badge()
|
||||
->color($this->readinessLifecycleColor($payload['checkpoint']['lifecycle_state'])),
|
||||
Text::make('Provider connection')
|
||||
->color('gray'),
|
||||
Text::make($this->readinessProviderLine($payload))
|
||||
->badge()
|
||||
->color($this->readinessSummaryColor($payload)),
|
||||
Text::make('Freshness')
|
||||
->color('gray'),
|
||||
Text::make($payload['freshness']['note']),
|
||||
Text::make('Primary next action')
|
||||
->color('gray'),
|
||||
Text::make($payload['next_action']['label'])
|
||||
->badge()
|
||||
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
||||
]),
|
||||
];
|
||||
|
||||
$permissionDiagnostics = $this->readinessPermissionDiagnosticsSchema($payload, 'route_bound_readiness');
|
||||
$supportingEvidence = $this->readinessSupportingEvidenceSchema($payload, 'route_bound_readiness');
|
||||
|
||||
return array_merge($schema, $permissionDiagnostics, $supportingEvidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function draftCompactReadinessSchema(TenantOnboardingSession $draft): array
|
||||
{
|
||||
$payload = $this->onboardingReadinessPayload($draft);
|
||||
|
||||
return [
|
||||
Text::make('Compact readiness')
|
||||
->color('gray'),
|
||||
Text::make($payload['blocker']['operator_summary'])
|
||||
->badge()
|
||||
->color($this->readinessSummaryColor($payload)),
|
||||
Text::make('Freshness')
|
||||
->color('gray'),
|
||||
Text::make($payload['freshness']['note']),
|
||||
Text::make('Next action')
|
||||
->color('gray'),
|
||||
Text::make($payload['next_action']['label'])
|
||||
->badge()
|
||||
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array
|
||||
{
|
||||
$links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : [];
|
||||
|
||||
if ($links === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$actions = [];
|
||||
|
||||
foreach (array_values($links) as $index => $link) {
|
||||
if (! is_array($link)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = is_string($link['label'] ?? null) ? $link['label'] : OperationRunLinks::openLabel();
|
||||
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
|
||||
|
||||
if ($url === null || $url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actions[] = Action::make($keyPrefix.'_supporting_operation_'.$index)
|
||||
->label($label)
|
||||
->color('gray')
|
||||
->url($url);
|
||||
}
|
||||
|
||||
if ($actions === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
Section::make('Supporting evidence')
|
||||
->description('Open canonical operation detail when deeper diagnostics are needed.')
|
||||
->compact()
|
||||
->schema([
|
||||
SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
private function readinessPermissionDiagnosticsSchema(array $payload, string $keyPrefix): array
|
||||
{
|
||||
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : null;
|
||||
|
||||
if ($permissions === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : [];
|
||||
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||
$errors = (int) ($counts['error'] ?? 0);
|
||||
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
|
||||
$isVisible = (bool) ($assist['is_visible'] ?? false);
|
||||
|
||||
if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$schema = [
|
||||
Text::make('Missing application permissions')
|
||||
->color('gray'),
|
||||
Text::make((string) $missingApplication)
|
||||
->badge()
|
||||
->color($missingApplication > 0 ? 'warning' : 'success'),
|
||||
Text::make('Missing delegated permissions')
|
||||
->color('gray'),
|
||||
Text::make((string) $missingDelegated)
|
||||
->badge()
|
||||
->color($missingDelegated > 0 ? 'warning' : 'success'),
|
||||
];
|
||||
|
||||
$applicationLine = $this->readinessMissingPermissionLine($permissions, 'application');
|
||||
$delegatedLine = $this->readinessMissingPermissionLine($permissions, 'delegated');
|
||||
|
||||
if ($applicationLine !== null) {
|
||||
$schema[] = Text::make('Application permission detail')->color('gray');
|
||||
$schema[] = Text::make($applicationLine);
|
||||
}
|
||||
|
||||
if ($delegatedLine !== null) {
|
||||
$schema[] = Text::make('Delegated permission detail')->color('gray');
|
||||
$schema[] = Text::make($delegatedLine);
|
||||
}
|
||||
|
||||
$url = is_string($permissions['required_permissions_url'] ?? null) ? $permissions['required_permissions_url'] : null;
|
||||
|
||||
if ($url !== null && $url !== '') {
|
||||
$schema[] = SchemaActions::make([
|
||||
Action::make($keyPrefix.'_review_permissions')
|
||||
->label('Review permissions')
|
||||
->color('gray')
|
||||
->url($url),
|
||||
])->key($keyPrefix.'_permission_diagnostics_actions');
|
||||
}
|
||||
|
||||
return [
|
||||
Section::make('Permission diagnostics')
|
||||
->description('Provider-owned permission detail stays secondary to readiness while still giving exact remediation context.')
|
||||
->compact()
|
||||
->columns(2)
|
||||
->schema($schema),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string},
|
||||
* checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string},
|
||||
* provider_summary: array<string, mixed>|null,
|
||||
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null},
|
||||
* verification_assist: array{is_visible: bool, reason: string},
|
||||
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
||||
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
|
||||
* blocker: array{reason_code: string|null, blocking_reason_code: string|null, operator_summary: string},
|
||||
* next_action: array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null},
|
||||
* supporting_links: list<array{label: string, url: string}>
|
||||
* }
|
||||
*/
|
||||
private function onboardingReadinessPayload(TenantOnboardingSession $draft): array
|
||||
{
|
||||
$snapshot = $this->lifecycleService()->snapshot($draft);
|
||||
$stage = app(OnboardingDraftStageResolver::class)->resolve($draft);
|
||||
$lifecycleState = $snapshot['lifecycle_state'] instanceof OnboardingLifecycleState
|
||||
? $snapshot['lifecycle_state']
|
||||
: OnboardingLifecycleState::Draft;
|
||||
$currentCheckpoint = $snapshot['current_checkpoint'] instanceof OnboardingCheckpoint
|
||||
? $snapshot['current_checkpoint']
|
||||
: null;
|
||||
$lastCompletedCheckpoint = $snapshot['last_completed_checkpoint'] instanceof OnboardingCheckpoint
|
||||
? $snapshot['last_completed_checkpoint']
|
||||
: null;
|
||||
|
||||
$tenant = $draft->tenant instanceof Tenant ? $draft->tenant : null;
|
||||
$providerConnection = $this->readinessProviderConnection($draft);
|
||||
$selectedProviderConnectionId = $providerConnection instanceof ProviderConnection
|
||||
? (int) $providerConnection->getKey()
|
||||
: null;
|
||||
|
||||
$verificationRun = $this->lifecycleService()->verificationRun($draft);
|
||||
$verificationStatus = $this->lifecycleService()->verificationStatus(
|
||||
draft: $draft,
|
||||
selectedProviderConnectionId: $selectedProviderConnectionId,
|
||||
run: $verificationRun,
|
||||
);
|
||||
$verificationMatchesSelectedConnection = $verificationRun instanceof OperationRun
|
||||
? $this->readinessRunMatchesSelectedConnection($verificationRun, $selectedProviderConnectionId)
|
||||
: null;
|
||||
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
|
||||
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
|
||||
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
||||
$permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [
|
||||
'last_refreshed_at' => null,
|
||||
'is_stale' => true,
|
||||
];
|
||||
|
||||
$connectionRecentlyUpdated = $this->readinessConnectionRecentlyUpdated($draft);
|
||||
$verificationMismatch = $verificationRun instanceof OperationRun && $verificationMatchesSelectedConnection === false;
|
||||
$supportingLinks = $this->readinessSupportingLinks($verificationRun, $draft, $selectedProviderConnectionId);
|
||||
$readinessSummary = $this->readinessSummaryText(
|
||||
draft: $draft,
|
||||
lifecycleState: $lifecycleState,
|
||||
providerConnection: $providerConnection,
|
||||
verificationRun: $verificationRun,
|
||||
verificationStatus: $verificationStatus,
|
||||
permissions: $permissions,
|
||||
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||
verificationMismatch: $verificationMismatch,
|
||||
);
|
||||
|
||||
return [
|
||||
'draft' => [
|
||||
'id' => (int) $draft->getKey(),
|
||||
'tenant_name' => $this->draftTitle($draft),
|
||||
'stage_label' => $stage->label(),
|
||||
'draft_status_label' => $draft->status()->label(),
|
||||
'started_by' => $draft->startedByUser?->name ?? 'Unknown',
|
||||
'updated_by' => $draft->updatedByUser?->name ?? 'Unknown',
|
||||
'last_updated_human' => $draft->updated_at?->diffForHumans() ?? '—',
|
||||
],
|
||||
'checkpoint' => [
|
||||
'current_checkpoint' => $currentCheckpoint?->value,
|
||||
'current_checkpoint_label' => $currentCheckpoint?->label(),
|
||||
'last_completed_checkpoint' => $lastCompletedCheckpoint?->label(),
|
||||
'lifecycle_state' => $lifecycleState->value,
|
||||
'lifecycle_label' => $lifecycleState->label(),
|
||||
],
|
||||
'provider_summary' => $this->readinessProviderSummary($providerConnection),
|
||||
'verification' => [
|
||||
'status' => $verificationStatus,
|
||||
'status_label' => BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, $verificationStatus)->label,
|
||||
'run_id' => $verificationRun instanceof OperationRun ? (int) $verificationRun->getKey() : null,
|
||||
'run_url' => $verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun)
|
||||
? OperationRunLinks::tenantlessView($verificationRun)
|
||||
: null,
|
||||
'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value,
|
||||
'matches_selected_connection' => $verificationMatchesSelectedConnection,
|
||||
'overall' => $verificationRun instanceof OperationRun
|
||||
? $this->readinessVerificationOverall($verificationRun, $verificationReport)
|
||||
: null,
|
||||
],
|
||||
'verification_assist' => $tenant instanceof Tenant && $verificationReport !== null
|
||||
? app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $verificationReport)
|
||||
: $this->hiddenVerificationAssistVisibility(),
|
||||
'permissions' => $permissions,
|
||||
'freshness' => [
|
||||
'connection_recently_updated' => $connectionRecentlyUpdated,
|
||||
'verification_mismatch' => $verificationMismatch,
|
||||
'permission_last_refreshed_at' => $permissionFreshness['last_refreshed_at'] ?? null,
|
||||
'permission_data_is_stale' => (bool) ($permissionFreshness['is_stale'] ?? true),
|
||||
'note' => $this->readinessFreshnessNote(
|
||||
verificationRun: $verificationRun,
|
||||
verificationStatus: $verificationStatus,
|
||||
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||
verificationMismatch: $verificationMismatch,
|
||||
permissionFreshness: $permissionFreshness,
|
||||
),
|
||||
],
|
||||
'blocker' => [
|
||||
'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null,
|
||||
'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null,
|
||||
'operator_summary' => $readinessSummary,
|
||||
],
|
||||
'next_action' => $this->readinessNextAction(
|
||||
draft: $draft,
|
||||
lifecycleState: $lifecycleState,
|
||||
providerConnection: $providerConnection,
|
||||
verificationRun: $verificationRun,
|
||||
verificationStatus: $verificationStatus,
|
||||
permissions: $permissions,
|
||||
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||
verificationMismatch: $verificationMismatch,
|
||||
supportingLinks: $supportingLinks,
|
||||
),
|
||||
'supporting_links' => $supportingLinks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function readinessProviderLine(array $payload): string
|
||||
{
|
||||
$summary = is_array($payload['provider_summary'] ?? null) ? $payload['provider_summary'] : null;
|
||||
|
||||
if ($summary === null) {
|
||||
return 'Not connected';
|
||||
}
|
||||
|
||||
$readiness = is_string($summary['readiness_summary'] ?? null) ? $summary['readiness_summary'] : 'Needs review';
|
||||
$targetScope = is_string($summary['target_scope_summary'] ?? null) ? $summary['target_scope_summary'] : null;
|
||||
|
||||
return $targetScope === null || $targetScope === ''
|
||||
? $readiness
|
||||
: sprintf('%s - %s', $readiness, $targetScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function readinessSummaryColor(array $payload): string
|
||||
{
|
||||
$summary = strtolower((string) ($payload['blocker']['operator_summary'] ?? ''));
|
||||
|
||||
if (str_contains($summary, 'ready') || str_contains($summary, 'completed')) {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (str_contains($summary, 'running') || str_contains($summary, 'continue')) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
if (str_contains($summary, 'required') || str_contains($summary, 'disabled') || str_contains($summary, 'attention') || str_contains($summary, 'refresh')) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
private function readinessLifecycleColor(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
OnboardingLifecycleState::ReadyForActivation->value,
|
||||
OnboardingLifecycleState::Completed->value => 'success',
|
||||
OnboardingLifecycleState::Verifying->value,
|
||||
OnboardingLifecycleState::Bootstrapping->value => 'info',
|
||||
OnboardingLifecycleState::ActionRequired->value => 'warning',
|
||||
OnboardingLifecycleState::Cancelled->value => 'danger',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function readinessNextActionColor(string $kind): string
|
||||
{
|
||||
return match ($kind) {
|
||||
'complete_onboarding' => 'success',
|
||||
'grant_consent',
|
||||
'review_provider_connection',
|
||||
'review_permissions',
|
||||
'rerun_verification',
|
||||
'review_bootstrap' => 'warning',
|
||||
'start_verification',
|
||||
'open_operation' => 'info',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection
|
||||
{
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$providerConnectionId = $this->normalizeReadinessInteger(
|
||||
$state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null,
|
||||
);
|
||||
|
||||
if ($providerConnectionId === null || $draft->tenant_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ProviderConnection::query()
|
||||
->whereKey($providerConnectionId)
|
||||
->where('workspace_id', (int) $draft->workspace_id)
|
||||
->where('tenant_id', (int) $draft->tenant_id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function readinessProviderSummary(?ProviderConnection $connection): ?array
|
||||
{
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$summary = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
} catch (InvalidArgumentException) {
|
||||
return [
|
||||
'provider' => (string) $connection->provider,
|
||||
'target_scope' => [],
|
||||
'consent_state' => (string) $connection->consent_status,
|
||||
'verification_state' => (string) $connection->verification_status,
|
||||
'readiness_summary' => 'Target scope needs review',
|
||||
'target_scope_summary' => 'Target scope needs review',
|
||||
'contextual_identity_line' => null,
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
];
|
||||
}
|
||||
|
||||
return array_merge($summary->toArray(), [
|
||||
'target_scope_summary' => $summary->targetScopeSummary(),
|
||||
'contextual_identity_line' => $summary->contextualIdentityLine(),
|
||||
'is_enabled' => (bool) $connection->is_enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}
|
||||
*/
|
||||
private function readinessPermissionOverview(Tenant $tenant): array
|
||||
{
|
||||
$viewModel = app(TenantRequiredPermissionsViewModelBuilder::class)->build($tenant, [
|
||||
'status' => 'all',
|
||||
'type' => 'all',
|
||||
'features' => [],
|
||||
'search' => '',
|
||||
]);
|
||||
|
||||
$overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : [];
|
||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
||||
$permissions = is_array($viewModel['permissions'] ?? null) ? $viewModel['permissions'] : [];
|
||||
|
||||
return [
|
||||
'overall' => is_string($overview['overall'] ?? null) ? $overview['overall'] : null,
|
||||
'counts' => [
|
||||
'missing_application' => max(0, (int) ($counts['missing_application'] ?? 0)),
|
||||
'missing_delegated' => max(0, (int) ($counts['missing_delegated'] ?? 0)),
|
||||
'present' => max(0, (int) ($counts['present'] ?? 0)),
|
||||
'error' => max(0, (int) ($counts['error'] ?? 0)),
|
||||
],
|
||||
'freshness' => [
|
||||
'last_refreshed_at' => is_string($freshness['last_refreshed_at'] ?? null) ? $freshness['last_refreshed_at'] : null,
|
||||
'is_stale' => (bool) ($freshness['is_stale'] ?? true),
|
||||
],
|
||||
'missing_permissions' => [
|
||||
'application' => $this->readinessMissingPermissionKeys($permissions, 'application'),
|
||||
'delegated' => $this->readinessMissingPermissionKeys($permissions, 'delegated'),
|
||||
],
|
||||
'required_permissions_url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
* @return list<string>
|
||||
*/
|
||||
private function readinessMissingPermissionKeys(array $permissions, string $type): array
|
||||
{
|
||||
return collect($permissions)
|
||||
->filter(static fn (mixed $row): bool => is_array($row)
|
||||
&& ($row['status'] ?? null) === 'missing'
|
||||
&& ($row['type'] ?? null) === $type
|
||||
&& is_string($row['key'] ?? null)
|
||||
&& trim((string) $row['key']) !== '')
|
||||
->map(static fn (array $row): string => trim((string) $row['key']))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $permissions
|
||||
* @param 'application'|'delegated' $type
|
||||
*/
|
||||
private function readinessMissingPermissionLine(array $permissions, string $type): ?string
|
||||
{
|
||||
$missing = is_array($permissions['missing_permissions'][$type] ?? null)
|
||||
? $permissions['missing_permissions'][$type]
|
||||
: [];
|
||||
|
||||
$missing = array_values(array_filter($missing, static fn (mixed $value): bool => is_string($value) && trim($value) !== ''));
|
||||
|
||||
if ($missing === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(', ', array_slice($missing, 0, 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{last_refreshed_at?: string|null, is_stale?: bool} $permissionFreshness
|
||||
*/
|
||||
private function readinessFreshnessNote(
|
||||
?OperationRun $verificationRun,
|
||||
string $verificationStatus,
|
||||
bool $connectionRecentlyUpdated,
|
||||
bool $verificationMismatch,
|
||||
array $permissionFreshness,
|
||||
): string {
|
||||
if (! $verificationRun instanceof OperationRun) {
|
||||
return 'Verification has not run yet.';
|
||||
}
|
||||
|
||||
if ($connectionRecentlyUpdated) {
|
||||
return 'Provider connection changed; rerun verification to refresh readiness.';
|
||||
}
|
||||
|
||||
if ($verificationMismatch) {
|
||||
return 'Verification evidence belongs to a different provider connection.';
|
||||
}
|
||||
|
||||
if ((bool) ($permissionFreshness['is_stale'] ?? true)) {
|
||||
return is_string($permissionFreshness['last_refreshed_at'] ?? null)
|
||||
? 'Permission data is older than the 30-day freshness window.'
|
||||
: 'Permission check has not run yet.';
|
||||
}
|
||||
|
||||
if ($verificationStatus === 'in_progress') {
|
||||
return 'Verification is running; refresh for the latest result.';
|
||||
}
|
||||
|
||||
return 'Verification and permission evidence are current.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $permissions
|
||||
*/
|
||||
private function readinessSummaryText(
|
||||
TenantOnboardingSession $draft,
|
||||
OnboardingLifecycleState $lifecycleState,
|
||||
?ProviderConnection $providerConnection,
|
||||
?OperationRun $verificationRun,
|
||||
string $verificationStatus,
|
||||
?array $permissions,
|
||||
bool $connectionRecentlyUpdated,
|
||||
bool $verificationMismatch,
|
||||
): string {
|
||||
if (! $this->readinessDraftHasTenantIdentity($draft)) {
|
||||
return 'Tenant identity required';
|
||||
}
|
||||
|
||||
if (! $providerConnection instanceof ProviderConnection) {
|
||||
return 'Provider connection required';
|
||||
}
|
||||
|
||||
if (! (bool) $providerConnection->is_enabled) {
|
||||
return 'Provider connection disabled';
|
||||
}
|
||||
|
||||
$consentState = $providerConnection->consent_status instanceof ProviderConsentStatus
|
||||
? $providerConnection->consent_status->value
|
||||
: (string) $providerConnection->consent_status;
|
||||
|
||||
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||
return match ($consentState) {
|
||||
ProviderConsentStatus::Revoked->value => 'Provider consent revoked',
|
||||
ProviderConsentStatus::Failed->value => 'Provider consent failed',
|
||||
default => 'Provider consent required',
|
||||
};
|
||||
}
|
||||
|
||||
if ($connectionRecentlyUpdated || $verificationMismatch) {
|
||||
return 'Verification needs refresh';
|
||||
}
|
||||
|
||||
if (! $verificationRun instanceof OperationRun) {
|
||||
return 'Verification has not run yet';
|
||||
}
|
||||
|
||||
if ($verificationStatus === 'in_progress') {
|
||||
return 'Verification running';
|
||||
}
|
||||
|
||||
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||
|
||||
if ($verificationStatus === 'blocked' || $permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||
return 'Permission or consent blocker needs attention';
|
||||
}
|
||||
|
||||
if ($permissionOverall === VerificationReportOverall::NeedsAttention->value || (bool) ($permissions['freshness']['is_stale'] ?? false)) {
|
||||
return 'Readiness needs attention';
|
||||
}
|
||||
|
||||
return match ($lifecycleState) {
|
||||
OnboardingLifecycleState::Verifying => 'Verification running',
|
||||
OnboardingLifecycleState::Bootstrapping => 'Bootstrap running',
|
||||
OnboardingLifecycleState::ActionRequired => 'Onboarding needs attention',
|
||||
OnboardingLifecycleState::ReadyForActivation => 'Ready for activation',
|
||||
OnboardingLifecycleState::Completed => 'Completed',
|
||||
OnboardingLifecycleState::Cancelled => 'Cancelled',
|
||||
default => 'Continue onboarding',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $permissions
|
||||
* @param list<array{label: string, url: string}> $supportingLinks
|
||||
* @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}
|
||||
*/
|
||||
private function readinessNextAction(
|
||||
TenantOnboardingSession $draft,
|
||||
OnboardingLifecycleState $lifecycleState,
|
||||
?ProviderConnection $providerConnection,
|
||||
?OperationRun $verificationRun,
|
||||
string $verificationStatus,
|
||||
?array $permissions,
|
||||
bool $connectionRecentlyUpdated,
|
||||
bool $verificationMismatch,
|
||||
array $supportingLinks,
|
||||
): array {
|
||||
if (! $this->readinessDraftHasTenantIdentity($draft)) {
|
||||
return $this->readinessAction('Identify tenant', 'start_onboarding');
|
||||
}
|
||||
|
||||
if (! $providerConnection instanceof ProviderConnection) {
|
||||
return $this->readinessAction('Connect provider', 'resume_draft');
|
||||
}
|
||||
|
||||
$consentState = $providerConnection->consent_status instanceof ProviderConsentStatus
|
||||
? $providerConnection->consent_status->value
|
||||
: (string) $providerConnection->consent_status;
|
||||
|
||||
if (! (bool) $providerConnection->is_enabled) {
|
||||
return $this->readinessAction(
|
||||
label: 'Review provider connection',
|
||||
kind: 'review_provider_connection',
|
||||
);
|
||||
}
|
||||
|
||||
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||
return $this->readinessAction(
|
||||
label: 'Grant consent',
|
||||
kind: 'grant_consent',
|
||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||
|
||||
if ($permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||
return $this->readinessAction(
|
||||
label: 'Review permissions',
|
||||
kind: 'review_permissions',
|
||||
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $verificationRun instanceof OperationRun) {
|
||||
return $this->readinessAction(
|
||||
label: 'Start verification',
|
||||
kind: 'start_verification',
|
||||
actionName: 'wizardStartVerification',
|
||||
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
);
|
||||
}
|
||||
|
||||
if ($connectionRecentlyUpdated || $verificationMismatch || (bool) ($permissions['freshness']['is_stale'] ?? false)) {
|
||||
return $this->readinessAction(
|
||||
label: 'Rerun verification',
|
||||
kind: 'rerun_verification',
|
||||
actionName: 'wizardStartVerification',
|
||||
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||
);
|
||||
}
|
||||
|
||||
if ($verificationStatus === 'in_progress' && $supportingLinks !== []) {
|
||||
return $this->readinessAction(
|
||||
label: OperationRunLinks::openLabel(),
|
||||
kind: 'open_operation',
|
||||
url: $supportingLinks[0]['url'],
|
||||
);
|
||||
}
|
||||
|
||||
if ($lifecycleState === OnboardingLifecycleState::Bootstrapping || $lifecycleState === OnboardingLifecycleState::ActionRequired) {
|
||||
return $this->readinessAction('Review bootstrap', 'review_bootstrap');
|
||||
}
|
||||
|
||||
if ($lifecycleState === OnboardingLifecycleState::ReadyForActivation) {
|
||||
return $this->readinessAction(
|
||||
label: 'Complete onboarding',
|
||||
kind: 'complete_onboarding',
|
||||
actionName: 'wizardCompleteOnboarding',
|
||||
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->readinessAction('Continue onboarding', 'resume_draft');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}
|
||||
*/
|
||||
private function readinessAction(
|
||||
string $label,
|
||||
string $kind,
|
||||
?string $url = null,
|
||||
?string $actionName = null,
|
||||
?string $requiredCapability = null,
|
||||
): array {
|
||||
return [
|
||||
'label' => $label,
|
||||
'kind' => $kind,
|
||||
'url' => $url,
|
||||
'action_name' => $actionName,
|
||||
'required_capability' => $requiredCapability,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label: string, url: string}>
|
||||
*/
|
||||
private function readinessSupportingLinks(?OperationRun $verificationRun, TenantOnboardingSession $draft, ?int $selectedProviderConnectionId): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
if ($verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun)) {
|
||||
$links[] = [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => OperationRunLinks::tenantlessView($verificationRun),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->lifecycleService()->bootstrapRunSummaries($draft, $selectedProviderConnectionId) as $summary) {
|
||||
$runId = $this->normalizeReadinessInteger($summary['run_id'] ?? null);
|
||||
|
||||
if ($runId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$run = OperationRun::query()
|
||||
->whereKey($runId)
|
||||
->where('workspace_id', (int) $draft->workspace_id)
|
||||
->first();
|
||||
|
||||
if (! $run instanceof OperationRun || ! $this->canInspectOperationRun($run)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$links[] = [
|
||||
'label' => OperationRunLinks::openLabel(),
|
||||
'url' => OperationRunLinks::tenantlessView($run),
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(array_unique($links, SORT_REGULAR));
|
||||
}
|
||||
|
||||
private function readinessVerificationOverall(OperationRun $run, ?array $report = null): ?string
|
||||
{
|
||||
$report ??= VerificationReportViewer::report($run);
|
||||
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||||
$overall = $summary['overall'] ?? null;
|
||||
|
||||
return is_string($overall) && in_array($overall, VerificationReportOverall::values(), true)
|
||||
? $overall
|
||||
: null;
|
||||
}
|
||||
|
||||
private function readinessRunMatchesSelectedConnection(OperationRun $run, ?int $selectedProviderConnectionId): bool
|
||||
{
|
||||
if ($selectedProviderConnectionId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||
$runProviderConnectionId = $this->normalizeReadinessInteger($context['provider_connection_id'] ?? null);
|
||||
|
||||
return $runProviderConnectionId !== null && $runProviderConnectionId === $selectedProviderConnectionId;
|
||||
}
|
||||
|
||||
private function readinessConnectionRecentlyUpdated(TenantOnboardingSession $draft): bool
|
||||
{
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
|
||||
return (bool) ($state['connection_recently_updated'] ?? false);
|
||||
}
|
||||
|
||||
private function readinessDraftHasTenantIdentity(TenantOnboardingSession $draft): bool
|
||||
{
|
||||
if ($draft->tenant_id !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
|
||||
|
||||
return is_string($entraTenantId) && trim($entraTenantId) !== '';
|
||||
}
|
||||
|
||||
private function normalizeReadinessInteger(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value) && $value > 0) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && ctype_digit(trim($value))) {
|
||||
return (int) trim($value);
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$normalized = (int) $value;
|
||||
|
||||
return $normalized > 0 ? $normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, \Filament\Schemas\Components\Component>
|
||||
*/
|
||||
|
||||
@ -6,17 +6,18 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
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\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions;
|
||||
@ -107,83 +108,76 @@ protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if ((bool) config('tenantpilot.allow_admin_maintenance_actions', false)) {
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('backfill_lifecycle')
|
||||
->label('Backfill findings lifecycle')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Backfill findings lifecycle')
|
||||
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||
->action(function (OperationRunService $operationRuns): void {
|
||||
$user = auth()->user();
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('backfill_lifecycle')
|
||||
->label('Backfill findings lifecycle')
|
||||
->icon('heroicon-o-wrench-screwdriver')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Backfill findings lifecycle')
|
||||
->modalDescription('This will backfill legacy Findings data (lifecycle fields, SLA due dates, and drift duplicate consolidation) for the current tenant. The operation runs in the background.')
|
||||
->action(function (FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$opRun = $operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'findings.lifecycle.backfill',
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'trigger' => 'backfill',
|
||||
],
|
||||
context: [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiator_user_id' => (int) $user->getKey(),
|
||||
],
|
||||
try {
|
||||
$opRun = $runbookService->start(
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
initiator: $user,
|
||||
reason: null,
|
||||
source: 'tenant_ui',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
throw new \Filament\Support\Exceptions\Halt;
|
||||
}
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->dispatchOrFail($opRun, function () use ($tenant, $user): void {
|
||||
BackfillFindingLifecycleJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
initiatorUserId: (int) $user->getKey(),
|
||||
);
|
||||
});
|
||||
$runUrl = OperationRunLinks::view($opRun, $tenant);
|
||||
|
||||
if ($opRun->wasRecentlyCreated === false) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->body('The backfill will run in the background. You can continue working while it completes.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('Open operation')
|
||||
->url($runUrl),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
|
||||
$actions[] = UiEnforcement::forAction(
|
||||
Actions\Action::make('triage_all_matching')
|
||||
|
||||
@ -18,7 +18,9 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -31,14 +33,18 @@
|
||||
use App\Services\Providers\ProviderOperationStartResult;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupQuality\BackupQualityResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
@ -1921,16 +1927,26 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
->executionSafetySnapshot($tenant, $user, $data)
|
||||
->toArray();
|
||||
|
||||
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
try {
|
||||
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
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)
|
||||
->notification(
|
||||
@ -1978,6 +1994,13 @@ private static function startQueuedRestoreExecution(
|
||||
$initiator = auth()->user();
|
||||
$initiator = $initiator instanceof User ? $initiator : null;
|
||||
|
||||
static::guardRestoreExecutionOperationalControl(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
$queuedRestoreRun = null;
|
||||
|
||||
$dispatcher = function (OperationRun $run) use (
|
||||
@ -2097,6 +2120,58 @@ private static function startQueuedRestoreExecution(
|
||||
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
|
||||
*/
|
||||
@ -2529,16 +2604,26 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
|
||||
$metadata['rerun_of_restore_run_id'] = $record->id;
|
||||
|
||||
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
try {
|
||||
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
preview: $preview,
|
||||
metadata: $metadata,
|
||||
groupMapping: $groupMapping,
|
||||
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)) {
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
660
apps/platform/app/Filament/System/Pages/Ops/Controls.php
Normal file
660
apps/platform/app/Filament/System/Pages/Ops/Controls.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Radio;
|
||||
@ -168,12 +169,22 @@ protected function getHeaderActions(): array
|
||||
'reason_text' => $data['reason_text'] ?? null,
|
||||
]);
|
||||
|
||||
$run = $runbookService->start(
|
||||
scope: $scope,
|
||||
initiator: $user,
|
||||
reason: $reason,
|
||||
source: 'system_ui',
|
||||
);
|
||||
try {
|
||||
$run = $runbookService->start(
|
||||
scope: $scope,
|
||||
initiator: $user,
|
||||
reason: $reason,
|
||||
source: 'system_ui',
|
||||
);
|
||||
} catch (OperationalControlBlockedException $exception) {
|
||||
Notification::make()
|
||||
->title($exception->title())
|
||||
->body($exception->getMessage())
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
throw new \Filament\Support\Exceptions\Halt;
|
||||
}
|
||||
|
||||
$viewUrl = SystemOperationRunLinks::view($run);
|
||||
|
||||
|
||||
73
apps/platform/app/Models/OperationalControlActivation.php
Normal file
73
apps/platform/app/Models/OperationalControlActivation.php
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,8 @@
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
@ -23,7 +25,7 @@ public function log(
|
||||
Workspace $workspace,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
User|PlatformUser|null $actor = null,
|
||||
string $status = 'success',
|
||||
?string $resourceType = null,
|
||||
?string $resourceId = null,
|
||||
@ -36,14 +38,16 @@ public function log(
|
||||
?int $operationRunId = null,
|
||||
?Tenant $tenant = null,
|
||||
): \App\Models\AuditLog {
|
||||
$resolvedActor = $actor instanceof User
|
||||
? AuditActorSnapshot::human($actor)
|
||||
: AuditActorSnapshot::fromLegacy(
|
||||
$resolvedActor = match (true) {
|
||||
$actor instanceof User => AuditActorSnapshot::human($actor),
|
||||
$actor instanceof PlatformUser => AuditActorSnapshot::platform($actor),
|
||||
default => AuditActorSnapshot::fromLegacy(
|
||||
type: $actorType ?? AuditActorType::infer($action instanceof AuditActionId ? $action->value : $action, $actorId, $actorEmail, $actorName, $context),
|
||||
id: $actorId,
|
||||
email: $actorEmail,
|
||||
label: $actorName,
|
||||
);
|
||||
),
|
||||
};
|
||||
|
||||
return $this->auditRecorder->record(
|
||||
action: $action,
|
||||
@ -70,7 +74,7 @@ public function logTenantLifecycleAction(
|
||||
Tenant $tenant,
|
||||
string|AuditActionId $action,
|
||||
array $context = [],
|
||||
?User $actor = null,
|
||||
User|PlatformUser|null $actor = null,
|
||||
string $status = 'success',
|
||||
?string $summary = null,
|
||||
): \App\Models\AuditLog {
|
||||
@ -87,4 +91,49 @@ public function logTenantLifecycleAction(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_DELETE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -63,6 +64,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_MANAGE,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -103,6 +105,7 @@ class RoleCapabilityMap
|
||||
TenantRole::Operator->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_SYNC,
|
||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
|
||||
@ -10,15 +10,24 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\BreakGlassSession;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\System\AllowedTenantUniverse;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationalControls\OperationalControlBlockedException;
|
||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -35,6 +44,9 @@ public function __construct(
|
||||
private readonly OperationRunService $operationRunService,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
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',
|
||||
scope: $scope,
|
||||
operationRunId: null,
|
||||
initiator: null,
|
||||
context: [
|
||||
'preflight' => $result,
|
||||
],
|
||||
@ -58,7 +71,7 @@ public function preflight(FindingsLifecycleBackfillScope $scope): array
|
||||
|
||||
public function start(
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
?PlatformUser $initiator,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
string $source,
|
||||
): OperationRun {
|
||||
@ -88,13 +101,41 @@ public function start(
|
||||
]);
|
||||
}
|
||||
|
||||
$platformTenant = $this->platformTenant();
|
||||
$workspace = $platformTenant->workspace;
|
||||
$workspace = null;
|
||||
$tenant = null;
|
||||
|
||||
if ($scope->isSingleTenant()) {
|
||||
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->firstOrFail();
|
||||
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||
|
||||
$workspace = $tenant->workspace;
|
||||
} else {
|
||||
$platformTenant = $this->platformTenant();
|
||||
$workspace = $platformTenant->workspace;
|
||||
}
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
throw new \RuntimeException('Platform tenant is missing its workspace.');
|
||||
}
|
||||
|
||||
$decision = $this->operationalControls->evaluate(self::RUNBOOK_KEY, $workspace);
|
||||
|
||||
if ($decision->isPaused()) {
|
||||
$this->auditBlockedStart(
|
||||
decision: $decision,
|
||||
scope: $scope,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
initiator: $initiator,
|
||||
source: $source,
|
||||
);
|
||||
|
||||
throw OperationalControlBlockedException::forDecision(
|
||||
decision: $decision,
|
||||
actionLabel: OperationCatalog::label(self::RUNBOOK_KEY),
|
||||
);
|
||||
}
|
||||
|
||||
if ($scope->isAllTenants()) {
|
||||
$lockKey = sprintf('tenantpilot:runbooks:%s:workspace:%d', self::RUNBOOK_KEY, (int) $workspace->getKey());
|
||||
$lock = Cache::lock($lockKey, 900);
|
||||
@ -120,7 +161,7 @@ public function start(
|
||||
}
|
||||
|
||||
return $this->startSingleTenant(
|
||||
tenantId: (int) $scope->tenantId,
|
||||
tenant: $tenant,
|
||||
initiator: $initiator,
|
||||
reason: $reason,
|
||||
preflight: $preflight,
|
||||
@ -327,7 +368,7 @@ private function countDriftDuplicateConsolidations(Tenant $tenant): int
|
||||
|
||||
private function startAllTenants(
|
||||
Workspace $workspace,
|
||||
?PlatformUser $initiator,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
@ -349,7 +390,7 @@ private function startAllTenants(
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
),
|
||||
initiator: null,
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||
@ -361,6 +402,7 @@ private function startAllTenants(
|
||||
action: 'platform.ops.runbooks.start',
|
||||
scope: FindingsLifecycleBackfillScope::allTenants(),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
initiator: $initiator,
|
||||
context: [
|
||||
'preflight' => $preflight,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
@ -382,15 +424,16 @@ private function startAllTenants(
|
||||
}
|
||||
|
||||
private function startSingleTenant(
|
||||
int $tenantId,
|
||||
?PlatformUser $initiator,
|
||||
?Tenant $tenant,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
bool $isBreakGlassActive,
|
||||
): OperationRun {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->firstOrFail();
|
||||
$this->allowedTenantUniverse->ensureAllowed($tenant);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Target tenant is required for single-tenant runs.');
|
||||
}
|
||||
|
||||
$run = $this->operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
@ -408,7 +451,7 @@ private function startSingleTenant(
|
||||
source: $source,
|
||||
isBreakGlassActive: $isBreakGlassActive,
|
||||
),
|
||||
initiator: null,
|
||||
initiator: $initiator instanceof User ? $initiator : null,
|
||||
);
|
||||
|
||||
if ($initiator instanceof PlatformUser && $run->wasRecentlyCreated) {
|
||||
@ -420,6 +463,7 @@ private function startSingleTenant(
|
||||
action: 'platform.ops.runbooks.start',
|
||||
scope: FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey()),
|
||||
operationRunId: (int) $run->getKey(),
|
||||
initiator: $initiator,
|
||||
context: [
|
||||
'preflight' => $preflight,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
@ -458,7 +502,7 @@ private function platformTenant(): Tenant
|
||||
private function buildRunContext(
|
||||
int $workspaceId,
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
?PlatformUser $initiator,
|
||||
User|PlatformUser|null $initiator,
|
||||
?RunbookReason $reason,
|
||||
array $preflight,
|
||||
string $source,
|
||||
@ -490,6 +534,12 @@ private function buildRunContext(
|
||||
'name' => (string) $initiator->name,
|
||||
'is_break_glass' => $isBreakGlassActive,
|
||||
];
|
||||
} elseif ($initiator instanceof User) {
|
||||
$context['tenant_initiator'] = [
|
||||
'user_id' => (int) $initiator->getKey(),
|
||||
'email' => (string) $initiator->email,
|
||||
'name' => (string) $initiator->name,
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
@ -514,23 +564,10 @@ private function auditSafely(
|
||||
string $action,
|
||||
FindingsLifecycleBackfillScope $scope,
|
||||
?int $operationRunId,
|
||||
User|PlatformUser|null $initiator,
|
||||
array $context = [],
|
||||
): void {
|
||||
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 = [
|
||||
'runbook_key' => self::RUNBOOK_KEY,
|
||||
'scope' => $scope->mode,
|
||||
@ -540,6 +577,37 @@ private function auditSafely(
|
||||
'user_agent' => request()->userAgent(),
|
||||
];
|
||||
|
||||
if ($initiator instanceof User && $scope->isSingleTenant()) {
|
||||
$tenant = Tenant::query()->whereKey((int) $scope->tenantId)->first();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => array_filter($metadata, static fn (mixed $value): bool => $value !== null),
|
||||
] + $context,
|
||||
actorId: (int) $initiator->getKey(),
|
||||
actorEmail: (string) $initiator->email,
|
||||
actorName: (string) $initiator->name,
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: $operationRunId !== null ? (string) $operationRunId : null,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$platformTenant = $this->platformTenant();
|
||||
$platformActor = $initiator instanceof PlatformUser
|
||||
? $initiator
|
||||
: auth('platform')->user();
|
||||
|
||||
$actorId = $platformActor instanceof PlatformUser ? (int) $platformActor->getKey() : null;
|
||||
$actorEmail = $platformActor instanceof PlatformUser ? (string) $platformActor->email : null;
|
||||
$actorName = $platformActor instanceof PlatformUser ? (string) $platformActor->name : null;
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $platformTenant,
|
||||
action: $action,
|
||||
@ -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
|
||||
{
|
||||
try {
|
||||
|
||||
@ -99,6 +99,12 @@ enum AuditActionId: string
|
||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||
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).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
case WorkspaceSelected = 'workspace.selected';
|
||||
@ -234,6 +240,11 @@ private static function labels(): array
|
||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||
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.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
@ -315,6 +326,11 @@ private static function summaries(): array
|
||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||
self::TenantReviewExported->value => 'Tenant review exported',
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -69,6 +69,9 @@ class Capabilities
|
||||
|
||||
public const TENANT_SYNC = 'tenant.sync';
|
||||
|
||||
// Support diagnostics
|
||||
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
||||
|
||||
// Inventory
|
||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||
|
||||
|
||||
@ -30,6 +30,8 @@ class PlatformCapabilities
|
||||
|
||||
public const RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL = 'platform.runbooks.findings.lifecycle_backfill';
|
||||
|
||||
public const OPS_CONTROLS_MANAGE = 'platform.ops.controls.manage';
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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'];
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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.';
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if (self::fingerprintCount($version->secret_fingerprints) > 0) {
|
||||
|
||||
@ -0,0 +1,942 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\SupportDiagnostics;
|
||||
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\ProviderConnectionResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\AuditLog;
|
||||
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\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
||||
use App\Support\Providers\ProviderReasonTranslator;
|
||||
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class SupportDiagnosticBundleBuilder
|
||||
{
|
||||
private const SECTION_ORDER = [
|
||||
'overview',
|
||||
'provider_connection',
|
||||
'operation_context',
|
||||
'findings',
|
||||
'stored_reports',
|
||||
'tenant_review',
|
||||
'review_pack',
|
||||
'audit_history',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly GovernanceRunDiagnosticSummaryBuilder $runSummaryBuilder,
|
||||
private readonly ProviderReasonTranslator $providerReasonTranslator,
|
||||
private readonly RelatedNavigationResolver $relatedNavigationResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forTenant(Tenant $tenant, ?User $actor = null): array
|
||||
{
|
||||
$tenant->loadMissing('workspace');
|
||||
|
||||
$workspace = $tenant->workspace;
|
||||
$providerConnection = $this->tenantProviderConnection($tenant);
|
||||
$operationRun = $this->tenantOperationRun($tenant);
|
||||
$findings = $this->tenantFindings($tenant);
|
||||
$storedReports = $this->tenantStoredReports($tenant);
|
||||
$tenantReview = $this->tenantReview($tenant);
|
||||
$reviewPack = $this->tenantReviewPack($tenant, $tenantReview);
|
||||
$auditLogs = $this->tenantAuditLogs($tenant);
|
||||
|
||||
return $this->bundle(
|
||||
contextType: 'tenant',
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
operationRun: $operationRun,
|
||||
headline: 'Support diagnostics for '.$tenant->name,
|
||||
dominantIssue: $this->tenantDominantIssue($providerConnection, $operationRun, $findings),
|
||||
sections: [
|
||||
$this->overviewSection($workspace, $tenant, $operationRun),
|
||||
$this->providerConnectionSection($providerConnection, $tenant),
|
||||
$this->operationContextSection($operationRun, $tenant),
|
||||
$this->findingsSection($findings, $tenant),
|
||||
$this->storedReportsSection($storedReports),
|
||||
$this->tenantReviewSection($tenantReview, $tenant),
|
||||
$this->reviewPackSection($reviewPack, $tenant),
|
||||
$this->auditHistorySection($auditLogs),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function forOperationRun(OperationRun $run, ?User $actor = null): array
|
||||
{
|
||||
$run->loadMissing(['workspace', 'tenant']);
|
||||
|
||||
$workspace = $run->workspace;
|
||||
$tenant = $run->tenant;
|
||||
$providerConnection = $tenant instanceof Tenant
|
||||
? $this->operationProviderConnection($run, $tenant)
|
||||
: null;
|
||||
$findings = $tenant instanceof Tenant ? $this->operationFindings($run, $tenant) : collect();
|
||||
$storedReports = $tenant instanceof Tenant ? $this->tenantStoredReports($tenant) : collect();
|
||||
$tenantReview = $tenant instanceof Tenant ? $this->operationTenantReview($run, $tenant) : null;
|
||||
$reviewPack = $tenant instanceof Tenant ? $this->operationReviewPack($run, $tenant, $tenantReview) : null;
|
||||
$auditLogs = $this->operationAuditLogs($run);
|
||||
$runSummary = $this->runSummaryBuilder->build($run);
|
||||
$runSummaryArray = $runSummary?->toArray();
|
||||
|
||||
return $this->bundle(
|
||||
contextType: 'operation_run',
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
operationRun: $run,
|
||||
headline: $runSummaryArray['headline'] ?? OperationRunLinks::identifier($run).' support diagnostics',
|
||||
dominantIssue: (string) data_get(
|
||||
$runSummaryArray,
|
||||
'dominantCause.explanation',
|
||||
$this->operationDominantIssue($run),
|
||||
),
|
||||
sections: [
|
||||
$this->overviewSection($workspace, $tenant, $run),
|
||||
$this->providerConnectionSection($providerConnection, $tenant, $run),
|
||||
$this->operationContextSection($run, $tenant, $runSummaryArray),
|
||||
$this->findingsSection($findings, $tenant),
|
||||
$this->storedReportsSection($storedReports),
|
||||
$this->tenantReviewSection($tenantReview, $tenant),
|
||||
$this->reviewPackSection($reviewPack, $tenant),
|
||||
$this->auditHistorySection($auditLogs),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function bundle(
|
||||
string $contextType,
|
||||
?Workspace $workspace,
|
||||
?Tenant $tenant,
|
||||
?OperationRun $operationRun,
|
||||
string $headline,
|
||||
string $dominantIssue,
|
||||
array $sections,
|
||||
): array {
|
||||
$sections = $this->sortSections($sections);
|
||||
$redactionMarkers = RedactionIntegrity::supportDiagnosticsMarkers();
|
||||
|
||||
return [
|
||||
'context_type' => $contextType,
|
||||
'context' => [
|
||||
'type' => $contextType,
|
||||
'workspace_id' => $workspace instanceof Workspace ? (int) $workspace->getKey() : null,
|
||||
'tenant_id' => $tenant instanceof Tenant ? (int) $tenant->getKey() : null,
|
||||
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||
'tenant_label' => $tenant?->name,
|
||||
'workspace_label' => $workspace?->name,
|
||||
],
|
||||
'workspace' => $workspace instanceof Workspace ? [
|
||||
'record_id' => (string) $workspace->getKey(),
|
||||
'label' => $workspace->name,
|
||||
] : null,
|
||||
'tenant' => $tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
|
||||
'operation_run' => $operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
|
||||
'headline' => $headline,
|
||||
'dominant_issue' => $dominantIssue,
|
||||
'freshness_state' => $this->freshnessState($sections),
|
||||
'redaction_mode' => 'default_redacted',
|
||||
'summary' => [
|
||||
'headline' => $headline,
|
||||
'dominant_issue' => $dominantIssue,
|
||||
'freshness_state' => $this->freshnessState($sections),
|
||||
'completeness_note' => $this->completenessNote($sections),
|
||||
'redaction_note' => RedactionIntegrity::supportDiagnosticsNote(),
|
||||
'generated_from' => 'derived_existing_truth',
|
||||
],
|
||||
'sections' => $sections,
|
||||
'redaction' => [
|
||||
'mode' => 'default_redacted',
|
||||
'markers' => $redactionMarkers,
|
||||
],
|
||||
'notes' => array_values(array_filter([
|
||||
RedactionIntegrity::supportDiagnosticsNote(),
|
||||
$this->completenessNote($sections),
|
||||
])),
|
||||
];
|
||||
}
|
||||
|
||||
private function tenantProviderConnection(Tenant $tenant): ?ProviderConnection
|
||||
{
|
||||
return ProviderConnection::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('is_default')
|
||||
->orderByDesc('last_health_check_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationProviderConnection(OperationRun $run, Tenant $tenant): ?ProviderConnection
|
||||
{
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$providerConnectionId = data_get($context, 'provider_connection_id');
|
||||
|
||||
if (is_numeric($providerConnectionId)) {
|
||||
$connection = ProviderConnection::query()
|
||||
->whereKey((int) $providerConnectionId)
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->first();
|
||||
|
||||
if ($connection instanceof ProviderConnection) {
|
||||
return $connection;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->tenantProviderConnection($tenant);
|
||||
}
|
||||
|
||||
private function tenantOperationRun(Tenant $tenant): ?OperationRun
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByRaw('completed_at IS NULL')
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
private function tenantFindings(Tenant $tenant): Collection
|
||||
{
|
||||
return Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('last_seen_at')
|
||||
->orderBy('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
private function operationFindings(OperationRun $run, Tenant $tenant): Collection
|
||||
{
|
||||
$runBound = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where(function (Builder $query) use ($run): void {
|
||||
$query
|
||||
->where('current_operation_run_id', (int) $run->getKey())
|
||||
->orWhere('baseline_operation_run_id', (int) $run->getKey());
|
||||
})
|
||||
->orderByRaw("CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END")
|
||||
->orderByDesc('last_seen_at')
|
||||
->orderBy('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
return $runBound->isNotEmpty() ? $runBound : $this->tenantFindings($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StoredReport>
|
||||
*/
|
||||
private function tenantStoredReports(Tenant $tenant): Collection
|
||||
{
|
||||
return StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('updated_at')
|
||||
->orderByDesc('id')
|
||||
->limit(3)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function tenantReview(Tenant $tenant): ?TenantReview
|
||||
{
|
||||
return TenantReview::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationTenantReview(OperationRun $run, Tenant $tenant): ?TenantReview
|
||||
{
|
||||
$review = TenantReview::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $review instanceof TenantReview ? $review : $this->tenantReview($tenant);
|
||||
}
|
||||
|
||||
private function tenantReviewPack(Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
|
||||
{
|
||||
if ($tenantReview instanceof TenantReview && is_numeric($tenantReview->current_export_review_pack_id)) {
|
||||
$pack = ReviewPack::query()
|
||||
->whereKey((int) $tenantReview->current_export_review_pack_id)
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->first();
|
||||
|
||||
if ($pack instanceof ReviewPack) {
|
||||
return $pack;
|
||||
}
|
||||
}
|
||||
|
||||
return ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function operationReviewPack(OperationRun $run, Tenant $tenant, ?TenantReview $tenantReview): ?ReviewPack
|
||||
{
|
||||
$pack = ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $pack instanceof ReviewPack ? $pack : $this->tenantReviewPack($tenant, $tenantReview);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AuditLog>
|
||||
*/
|
||||
private function tenantAuditLogs(Tenant $tenant): Collection
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->latestFirst()
|
||||
->limit(5)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, AuditLog>
|
||||
*/
|
||||
private function operationAuditLogs(OperationRun $run): Collection
|
||||
{
|
||||
return AuditLog::query()
|
||||
->where('workspace_id', (int) $run->workspace_id)
|
||||
->where(function (Builder $query) use ($run): void {
|
||||
$query
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->orWhere(function (Builder $targetQuery) use ($run): void {
|
||||
$targetQuery
|
||||
->where('resource_type', 'operation_run')
|
||||
->where('resource_id', (string) $run->getKey());
|
||||
});
|
||||
})
|
||||
->latestFirst()
|
||||
->limit(5)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function tenantDominantIssue(?ProviderConnection $providerConnection, ?OperationRun $operationRun, Collection $findings): string
|
||||
{
|
||||
if ($providerConnection instanceof ProviderConnection) {
|
||||
$providerIssue = $this->providerIssue($providerConnection);
|
||||
|
||||
if ($providerIssue !== null) {
|
||||
return $providerIssue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($operationRun instanceof OperationRun && in_array((string) $operationRun->outcome, ['failed', 'blocked', 'partially_succeeded'], true)) {
|
||||
return $this->operationDominantIssue($operationRun);
|
||||
}
|
||||
|
||||
if ($findings->isNotEmpty()) {
|
||||
return 'Open findings need review before support can treat this tenant as quiet.';
|
||||
}
|
||||
|
||||
return 'No dominant support blocker is currently visible from the selected tenant context.';
|
||||
}
|
||||
|
||||
private function operationDominantIssue(OperationRun $run): string
|
||||
{
|
||||
$failure = collect(is_array($run->failure_summary) ? $run->failure_summary : [])
|
||||
->first(static fn (mixed $item): bool => is_array($item) && trim((string) ($item['message'] ?? '')) !== '');
|
||||
|
||||
if (is_array($failure)) {
|
||||
return trim((string) $failure['message']);
|
||||
}
|
||||
|
||||
return match ((string) $run->outcome) {
|
||||
'failed' => 'The operation failed and needs follow-up.',
|
||||
'blocked' => 'The operation was blocked by a prerequisite or policy condition.',
|
||||
'partially_succeeded' => 'The operation completed with degraded or partial results.',
|
||||
default => 'The operation is available for support review.',
|
||||
};
|
||||
}
|
||||
|
||||
private function providerIssue(ProviderConnection $connection): ?string
|
||||
{
|
||||
$reasonCode = trim((string) $connection->last_error_reason_code);
|
||||
|
||||
if ($reasonCode !== '') {
|
||||
$envelope = $this->providerReasonTranslator->translate($reasonCode, 'support_diagnostics', [
|
||||
'tenant' => $connection->tenant,
|
||||
'connection' => $connection,
|
||||
]);
|
||||
|
||||
if ($envelope !== null) {
|
||||
return $envelope->operatorLabel.': '.$envelope->shortExplanation;
|
||||
}
|
||||
}
|
||||
|
||||
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
|
||||
if ($surface->readinessSummary !== 'Ready') {
|
||||
return 'Provider connection '.$surface->readinessSummary.'.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function overviewSection(?Workspace $workspace, ?Tenant $tenant, ?OperationRun $operationRun): array
|
||||
{
|
||||
$references = array_values(array_filter([
|
||||
$tenant instanceof Tenant ? $this->tenantReference($tenant) : null,
|
||||
$operationRun instanceof OperationRun ? $this->operationReference($operationRun, $tenant) : null,
|
||||
]));
|
||||
|
||||
return $this->section(
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
availability: $references === [] ? 'missing' : 'available',
|
||||
summary: $tenant instanceof Tenant
|
||||
? 'Workspace and tenant scope resolved before support diagnostics were composed.'
|
||||
: 'Workspace scope resolved; no tenant context is attached to this operation.',
|
||||
references: $references,
|
||||
);
|
||||
}
|
||||
|
||||
private function providerConnectionSection(?ProviderConnection $connection, ?Tenant $tenant, ?OperationRun $run = null): array
|
||||
{
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return $this->section(
|
||||
key: 'provider_connection',
|
||||
label: 'Provider connection',
|
||||
availability: 'missing',
|
||||
summary: 'No provider connection was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('provider_connection', 'Provider connection not observed', 'Open provider connection'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'provider_connection.credential',
|
||||
'reason' => 'credential',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$surface = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||
$providerIssue = $this->providerIssue($connection);
|
||||
|
||||
return $this->section(
|
||||
key: 'provider_connection',
|
||||
label: 'Provider connection',
|
||||
availability: $surface->readinessSummary === 'Ready' ? 'available' : 'stale',
|
||||
summary: $providerIssue ?? sprintf(
|
||||
'%s provider connection is %s.',
|
||||
ucfirst($surface->provider),
|
||||
strtolower($surface->readinessSummary),
|
||||
),
|
||||
freshnessNote: $this->freshnessNote($connection->last_health_check_at, 'Last health check'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'provider_connection',
|
||||
record: $connection,
|
||||
label: $connection->display_name ?: 'Provider connection #'.$connection->getKey(),
|
||||
actionLabel: 'Open provider connection',
|
||||
url: ProviderConnectionResource::getUrl('view', ['record' => $connection], panel: 'admin'),
|
||||
freshnessAt: $connection->last_health_check_at,
|
||||
),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'provider_connection.credential',
|
||||
'reason' => 'credential',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function operationContextSection(?OperationRun $operationRun, ?Tenant $tenant, ?array $runSummary = null): array
|
||||
{
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return $this->section(
|
||||
key: 'operation_context',
|
||||
label: 'Operation context',
|
||||
availability: 'missing',
|
||||
summary: 'No recent operation context was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('operation_run', 'Operation not yet observed', OperationRunLinks::openLabel()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$runSummary ??= $this->runSummaryBuilder->build($operationRun)?->toArray();
|
||||
|
||||
return $this->section(
|
||||
key: 'operation_context',
|
||||
label: 'Operation context',
|
||||
availability: 'available',
|
||||
summary: (string) ($runSummary['headline'] ?? $this->operationDominantIssue($operationRun)),
|
||||
freshnessNote: $this->freshnessNote($operationRun->completed_at ?? $operationRun->updated_at, 'Last run update'),
|
||||
references: [
|
||||
$this->operationReference($operationRun, $tenant),
|
||||
...$this->operationRelatedReferences($operationRun, $tenant),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function findingsSection(Collection $findings, ?Tenant $tenant): array
|
||||
{
|
||||
if ($findings->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'findings',
|
||||
label: 'Findings',
|
||||
availability: 'missing',
|
||||
summary: 'No open or run-related findings were found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('finding', 'No relevant finding observed', 'Open finding'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'findings',
|
||||
label: 'Findings',
|
||||
availability: 'available',
|
||||
summary: sprintf('%d finding reference(s) are included for support triage.', $findings->count()),
|
||||
freshnessNote: $this->freshnessNote($findings->max('last_seen_at'), 'Latest finding'),
|
||||
references: $findings
|
||||
->map(fn (Finding $finding): array => $this->modelReference(
|
||||
type: 'finding',
|
||||
record: $finding,
|
||||
label: sprintf('%s finding #%d', ucfirst(str_replace('_', ' ', (string) $finding->severity)), (int) $finding->getKey()),
|
||||
actionLabel: 'Open finding',
|
||||
url: $tenant instanceof Tenant
|
||||
? FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant)
|
||||
: null,
|
||||
freshnessAt: $finding->last_seen_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
|
||||
private function storedReportsSection(Collection $reports): array
|
||||
{
|
||||
if ($reports->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'stored_reports',
|
||||
label: 'Stored reports',
|
||||
availability: 'missing',
|
||||
summary: 'No stored report identity was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('stored_report', 'Stored report not yet observed', 'Review stored report identity'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'stored_reports.payload',
|
||||
'reason' => 'raw_payload',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'stored_reports',
|
||||
label: 'Stored reports',
|
||||
availability: 'redacted',
|
||||
summary: sprintf('%d stored report identity reference(s) are included without raw report payloads.', $reports->count()),
|
||||
freshnessNote: $this->freshnessNote($reports->max('updated_at'), 'Latest stored report'),
|
||||
references: $reports
|
||||
->map(fn (StoredReport $report): array => $this->modelReference(
|
||||
type: 'stored_report',
|
||||
record: $report,
|
||||
label: str_replace('_', ' ', (string) $report->report_type).' report #'.$report->getKey(),
|
||||
actionLabel: 'Review stored report identity',
|
||||
url: null,
|
||||
freshnessAt: $report->updated_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'stored_reports.payload',
|
||||
'reason' => 'raw_payload',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function tenantReviewSection(?TenantReview $review, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $review instanceof TenantReview) {
|
||||
return $this->section(
|
||||
key: 'tenant_review',
|
||||
label: 'Tenant review',
|
||||
availability: 'missing',
|
||||
summary: 'No tenant review was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('tenant_review', 'Tenant review not yet observed', 'Open tenant review'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'tenant_review',
|
||||
label: 'Tenant review',
|
||||
availability: 'available',
|
||||
summary: sprintf('Latest tenant review is %s with %s completeness.', (string) $review->status, (string) $review->completeness_state),
|
||||
freshnessNote: $this->freshnessNote($review->generated_at, 'Generated'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'tenant_review',
|
||||
record: $review,
|
||||
label: 'Tenant review #'.$review->getKey(),
|
||||
actionLabel: 'Open tenant review',
|
||||
url: $tenant instanceof Tenant
|
||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)
|
||||
: null,
|
||||
freshnessAt: $review->generated_at,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function reviewPackSection(?ReviewPack $pack, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $pack instanceof ReviewPack) {
|
||||
return $this->section(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
availability: 'missing',
|
||||
summary: 'No review pack was found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('review_pack', 'Review pack not yet observed', 'Open review pack'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'review_pack',
|
||||
label: 'Review pack',
|
||||
availability: $pack->isExpired() ? 'stale' : 'available',
|
||||
summary: sprintf('Review pack #%d is %s.', (int) $pack->getKey(), (string) $pack->status),
|
||||
freshnessNote: $this->freshnessNote($pack->generated_at, 'Generated'),
|
||||
references: [
|
||||
$this->modelReference(
|
||||
type: 'review_pack',
|
||||
record: $pack,
|
||||
label: 'Review pack #'.$pack->getKey(),
|
||||
actionLabel: 'Open review pack',
|
||||
url: $tenant instanceof Tenant
|
||||
? ReviewPackResource::getUrl('view', ['record' => $pack], panel: 'tenant', tenant: $tenant)
|
||||
: null,
|
||||
freshnessAt: $pack->generated_at,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function auditHistorySection(Collection $auditLogs): array
|
||||
{
|
||||
if ($auditLogs->isEmpty()) {
|
||||
return $this->section(
|
||||
key: 'audit_history',
|
||||
label: 'Audit history',
|
||||
availability: 'missing',
|
||||
summary: 'No audit references were found for this support context.',
|
||||
references: [
|
||||
$this->missingReference('audit_log', 'Audit event not yet observed', 'Inspect event'),
|
||||
],
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'audit_logs.metadata.raw',
|
||||
'reason' => 'restricted_log_excerpt',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return $this->section(
|
||||
key: 'audit_history',
|
||||
label: 'Audit history',
|
||||
availability: 'redacted',
|
||||
summary: sprintf('%d audit reference(s) are included with redacted metadata only.', $auditLogs->count()),
|
||||
freshnessNote: $this->freshnessNote($auditLogs->max('recorded_at'), 'Latest audit event'),
|
||||
references: $auditLogs
|
||||
->map(fn (AuditLog $auditLog): array => $this->modelReference(
|
||||
type: 'audit_log',
|
||||
record: $auditLog,
|
||||
label: $auditLog->summaryText(),
|
||||
actionLabel: 'Inspect event',
|
||||
url: route('admin.monitoring.audit-log', ['event' => (int) $auditLog->getKey()]),
|
||||
freshnessAt: $auditLog->recorded_at,
|
||||
))
|
||||
->values()
|
||||
->all(),
|
||||
redactionMarkers: [
|
||||
[
|
||||
'path' => 'audit_logs.metadata.raw',
|
||||
'reason' => 'restricted_log_excerpt',
|
||||
'replacement_text' => '[REDACTED]',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $references
|
||||
* @param list<array{path: ?string, reason: string, replacement_text: string}> $redactionMarkers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function section(
|
||||
string $key,
|
||||
string $label,
|
||||
string $availability,
|
||||
string $summary,
|
||||
?string $freshnessNote = null,
|
||||
array $references = [],
|
||||
array $redactionMarkers = [],
|
||||
): array {
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'availability' => $availability,
|
||||
'summary' => $summary,
|
||||
'freshness_note' => $freshnessNote,
|
||||
'references' => $this->sortReferences($references),
|
||||
'redaction_markers' => $redactionMarkers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortSections(array $sections): array
|
||||
{
|
||||
$order = array_flip(self::SECTION_ORDER);
|
||||
|
||||
usort($sections, static fn (array $left, array $right): int => ($order[$left['key']] ?? 999) <=> ($order[$right['key']] ?? 999));
|
||||
|
||||
return array_values($sections);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $references
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function sortReferences(array $references): array
|
||||
{
|
||||
usort($references, function (array $left, array $right): int {
|
||||
$leftAvailability = $left['availability'] === 'available' ? 0 : 1;
|
||||
$rightAvailability = $right['availability'] === 'available' ? 0 : 1;
|
||||
|
||||
if ($leftAvailability !== $rightAvailability) {
|
||||
return $leftAvailability <=> $rightAvailability;
|
||||
}
|
||||
|
||||
return [
|
||||
(string) ($left['type'] ?? ''),
|
||||
(string) ($left['record_id'] ?? ''),
|
||||
(string) ($left['label'] ?? ''),
|
||||
] <=> [
|
||||
(string) ($right['type'] ?? ''),
|
||||
(string) ($right['record_id'] ?? ''),
|
||||
(string) ($right['label'] ?? ''),
|
||||
];
|
||||
});
|
||||
|
||||
return array_values($references);
|
||||
}
|
||||
|
||||
private function tenantReference(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'type' => 'tenant',
|
||||
'record_id' => (string) $tenant->getKey(),
|
||||
'label' => $tenant->name,
|
||||
'action_label' => 'Open tenant',
|
||||
'url' => TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant),
|
||||
'availability' => 'available',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function operationReference(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
return $this->modelReference(
|
||||
type: 'operation_run',
|
||||
record: $run,
|
||||
label: OperationRunLinks::identifier($run),
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
url: OperationRunLinks::tenantlessView($run),
|
||||
freshnessAt: $run->completed_at ?? $run->updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function operationRelatedReferences(OperationRun $run, ?Tenant $tenant): array
|
||||
{
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($this->relatedNavigationResolver->operationLinks($run, $tenant))
|
||||
->reject(static fn (string $url, string $label): bool => $label === OperationRunLinks::collectionLabel())
|
||||
->map(fn (string $url, string $label): array => [
|
||||
'type' => 'operation_run',
|
||||
'record_id' => (string) $run->getKey(),
|
||||
'label' => $label,
|
||||
'action_label' => $label,
|
||||
'url' => $url,
|
||||
'availability' => 'available',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function modelReference(
|
||||
string $type,
|
||||
Model $record,
|
||||
string $label,
|
||||
string $actionLabel,
|
||||
?string $url,
|
||||
mixed $freshnessAt = null,
|
||||
): array {
|
||||
return [
|
||||
'type' => $type,
|
||||
'record_id' => (string) $record->getKey(),
|
||||
'label' => $label,
|
||||
'action_label' => $actionLabel,
|
||||
'url' => $url,
|
||||
'availability' => $url === null && $type !== 'stored_report' ? 'inaccessible' : 'available',
|
||||
'freshness_note' => $this->freshnessNote($freshnessAt),
|
||||
'access_reason' => $url === null && $type !== 'stored_report' ? 'Canonical destination is not available from this context.' : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function missingReference(string $type, string $label, string $actionLabel): array
|
||||
{
|
||||
return [
|
||||
'type' => $type,
|
||||
'record_id' => null,
|
||||
'label' => $label,
|
||||
'action_label' => $actionLabel,
|
||||
'url' => null,
|
||||
'availability' => 'missing',
|
||||
'freshness_note' => null,
|
||||
'access_reason' => 'No authorized record is available for this support context.',
|
||||
];
|
||||
}
|
||||
|
||||
private function freshnessNote(mixed $value, string $prefix = 'Observed'): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $prefix.': '.$value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $prefix.': '.trim($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
*/
|
||||
private function freshnessState(array $sections): string
|
||||
{
|
||||
$availableCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'available'));
|
||||
$missingCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'missing'));
|
||||
$staleCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'stale'));
|
||||
$redactedCount = count(array_filter($sections, static fn (array $section): bool => $section['availability'] === 'redacted'));
|
||||
|
||||
if ($availableCount === 0 && $redactedCount === 0) {
|
||||
return 'missing_context';
|
||||
}
|
||||
|
||||
if ($missingCount > 0 || $staleCount > 0) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $sections
|
||||
*/
|
||||
private function completenessNote(array $sections): ?string
|
||||
{
|
||||
$missing = collect($sections)
|
||||
->filter(static fn (array $section): bool => $section['availability'] === 'missing')
|
||||
->pluck('label')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($missing === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Missing context: '.implode(', ', $missing).'.';
|
||||
}
|
||||
}
|
||||
@ -149,9 +149,6 @@
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'allow_admin_maintenance_actions' => (bool) env('ALLOW_ADMIN_MAINTENANCE_ACTIONS', false),
|
||||
|
||||
'supported_policy_types' => [
|
||||
[
|
||||
'type' => 'deviceConfiguration',
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -42,6 +42,7 @@ public function run(): void
|
||||
PlatformCapabilities::RUNBOOKS_VIEW,
|
||||
PlatformCapabilities::RUNBOOKS_RUN,
|
||||
PlatformCapabilities::RUNBOOKS_FINDINGS_LIFECYCLE_BACKFILL,
|
||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
||||
],
|
||||
'is_active' => true,
|
||||
],
|
||||
|
||||
@ -0,0 +1,162 @@
|
||||
@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 ($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>
|
||||
@ -136,9 +136,9 @@
|
||||
|
||||
@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="font-semibold">Keine Daten verfügbar</div>
|
||||
<div class="font-semibold">No data available</div>
|
||||
<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>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
@ -0,0 +1,6 @@
|
||||
<x-filament-panels::header
|
||||
:actions="[]"
|
||||
:breadcrumbs="$breadcrumbs"
|
||||
:heading="$heading"
|
||||
:subheading="$subheading"
|
||||
/>
|
||||
@ -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>
|
||||
@ -9,12 +9,13 @@
|
||||
|
||||
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');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->assertActionDoesNotExist('backfill_lifecycle');
|
||||
->assertActionExists('backfill_lifecycle')
|
||||
->assertActionEnabled('backfill_lifecycle');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -14,12 +15,157 @@
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderConsentStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use App\Support\Providers\ProviderConnectionType;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
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 {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -869,6 +1015,349 @@
|
||||
->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('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 consent'],
|
||||
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant 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 diagnostics provider-owned while top-level readiness stays neutral', 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')
|
||||
->assertSee('Permission diagnostics')
|
||||
->assertSee('Missing application permissions')
|
||||
->assertSee('Review permissions');
|
||||
|
||||
if (is_string($missingKey) && $missingKey !== '') {
|
||||
$response->assertSee($missingKey);
|
||||
}
|
||||
|
||||
$response->assertDontSee('Microsoft Graph readiness');
|
||||
});
|
||||
|
||||
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 {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -1171,6 +1660,10 @@
|
||||
$component->call('startVerification');
|
||||
$component->call('startVerification');
|
||||
|
||||
$component
|
||||
->assertSee('Onboarding readiness')
|
||||
->assertSee('Open operation');
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('type', 'provider.connection.check')
|
||||
|
||||
@ -259,6 +259,53 @@
|
||||
->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 {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
@ -320,6 +367,71 @@
|
||||
->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 {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
|
||||
@ -2,11 +2,47 @@
|
||||
|
||||
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\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Verification\VerificationReportWriter;
|
||||
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 {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
@ -124,3 +160,273 @@
|
||||
->assertDontSee('Completed 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()]));
|
||||
});
|
||||
|
||||
@ -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');
|
||||
@ -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');
|
||||
});
|
||||
@ -12,7 +12,7 @@
|
||||
$this->actingAs($user)
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertSuccessful()
|
||||
->assertSee('Keine Daten verfügbar')
|
||||
->assertSee('No data available')
|
||||
->assertSee($expectedUrl, false)
|
||||
->assertSee('Start verification');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -48,6 +48,41 @@
|
||||
->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 {
|
||||
$tenant = Tenant::factory()->onboarding()->create();
|
||||
$readonlyUser = User::factory()->create();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
@ -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.');
|
||||
});
|
||||
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
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\RedactionIntegrity;
|
||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('includes translated provider reasons and explicit support diagnostic redaction markers', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Redaction Tenant']);
|
||||
|
||||
ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'display_name' => 'Redaction connection',
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'provider-secret-message',
|
||||
'last_health_check_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$bundle = app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant);
|
||||
$providerSection = collect($bundle['sections'])->firstWhere('key', 'provider_connection');
|
||||
|
||||
expect($bundle['redaction_mode'])->toBe('default_redacted')
|
||||
->and($bundle['summary']['redaction_note'])->toBe(RedactionIntegrity::supportDiagnosticsNote())
|
||||
->and($providerSection['summary'])->toContain('provider app is missing required Microsoft Graph permissions')
|
||||
->and(collect($bundle['redaction']['markers'])->pluck('path')->all())
|
||||
->toContain('provider_connection.credential', 'stored_reports.payload', 'audit_logs.metadata.raw')
|
||||
->and(collect($providerSection['redaction_markers'])->pluck('path')->all())
|
||||
->toContain('provider_connection.credential');
|
||||
});
|
||||
|
||||
it('excludes raw provider payloads and unrestricted log excerpts from support bundles', function (): void {
|
||||
$tenant = Tenant::factory()->create(['name' => 'Secret-Free Tenant']);
|
||||
|
||||
$connection = ProviderConnection::factory()
|
||||
->withCredential()
|
||||
->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||
'last_error_message' => 'raw-provider-secret-message',
|
||||
]);
|
||||
|
||||
$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',
|
||||
],
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
],
|
||||
'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),
|
||||
]);
|
||||
|
||||
$bundleJson = json_encode(app(SupportDiagnosticBundleBuilder::class)->forOperationRun($run), JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($bundleJson)
|
||||
->not->toContain('raw-provider-secret-message')
|
||||
->not->toContain('secret-provider-body')
|
||||
->not->toContain('stored-report-secret-body')
|
||||
->not->toContain('audit-secret-body')
|
||||
->toContain('default_redacted')
|
||||
->toContain('[REDACTED]');
|
||||
});
|
||||
@ -32,3 +32,19 @@
|
||||
|
||||
expect($freshness['is_stale'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the thirty day freshness boundary exact to the second', function (): void {
|
||||
$reference = CarbonImmutable::parse('2026-02-08 12:00:00');
|
||||
|
||||
$insideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||
CarbonImmutable::parse('2026-01-09 12:00:01'),
|
||||
$reference,
|
||||
);
|
||||
$outsideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||
CarbonImmutable::parse('2026-01-09 11:59:59'),
|
||||
$reference,
|
||||
);
|
||||
|
||||
expect($insideWindow['is_stale'])->toBeFalse()
|
||||
->and($outsideWindow['is_stale'])->toBeTrue();
|
||||
});
|
||||
|
||||
@ -77,6 +77,7 @@ ### Product Scalability & Self-Service Foundation
|
||||
- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes
|
||||
- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate
|
||||
- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features
|
||||
- Private AI readiness hooks: support, review, diagnostic, and decision surfaces should be designed so later AI assistance can use governed context builders, data classification, usage budgets, local/private model policies, cache fingerprints, and human approval gates instead of direct feature-level AI calls
|
||||
|
||||
**Active specs**: — (not yet specced)
|
||||
|
||||
@ -147,6 +148,7 @@ ### Solo-Founder SaaS Automation & Operating Readiness
|
||||
- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup
|
||||
- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation
|
||||
- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs
|
||||
- Private AI Operating Model: default no customer/tenant data to public AI APIs, local/private-first AI processing, explicit customer/workspace opt-in for non-local providers, AI disclosure wording, and operating rules for model hosting, retention, cost, and approval
|
||||
- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support
|
||||
- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates
|
||||
- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist
|
||||
@ -180,12 +182,38 @@ ### Product Usage, Customer Health & Operational Controls
|
||||
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
|
||||
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
|
||||
|
||||
### Private AI Execution & Usage Governance Foundation
|
||||
Strategic AI platform foundation for using AI inside TenantPilot without hard-coding public cloud AI calls, leaking tenant data, losing cost control, or forcing later rewrites.
|
||||
**Goal**: Make AI local/private-first, explicitly governed, budgeted, cacheable, auditable, and human-approved. External public AI providers are disabled by default and only usable through workspace-level opt-in, data classification, redaction, usage limits, and approval gates.
|
||||
**Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI cannot be bolted on through direct feature-level API calls. The platform needs a reusable execution boundary so support summaries, finding explanations, review packs, decision packs, and customer communications can use AI later without rebuilding privacy, cost, provider, approval, and audit controls each time.
|
||||
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, Product Usage & Adoption Telemetry, Plans / Entitlements & Billing Readiness, Operational Controls & Feature Flags, Security Trust Pack Light, audit log foundation, and workspace/RBAC isolation.
|
||||
**Scope direction**: Build the foundation before broad AI features: AI use case registry, AI provider registry, workspace AI policy, AI data classification, AI context builders, AI policy gate, AI budget gate, AI result store/cache, AI usage ledger, and AI audit trail. Start with local/private and customer-hosted model compatibility; keep external provider support optional and explicit.
|
||||
|
||||
**Core principles**:
|
||||
- AI is never called directly from feature code; every AI action goes through governed use cases, policy gates, budget gates, context builders, provider adapters, cache/result storage, and audit trails
|
||||
- Default posture: no customer or tenant data is sent to external public AI APIs
|
||||
- Local/private/customer-hosted/EU-private model execution is the preferred path for tenant/customer data
|
||||
- External public AI providers require explicit workspace opt-in, allowed data classes, redaction, budgets, disclosure, and human approval where needed
|
||||
- Raw provider payloads, personal data, and customer-confidential context are never sent to external models by default
|
||||
- Customer-facing, tenant-changing, risk-accepting, legal, or compliance-relevant AI outputs require human approval
|
||||
- AI outputs should be fingerprinted, cacheable, source-linked, and reproducible enough for governance review
|
||||
|
||||
**Foundation components**:
|
||||
- AI Use Case Registry: allowed data classes, model classes, approval requirements, output visibility, retention, cacheability, cost ceiling, and context-size limits per use case
|
||||
- AI Provider Registry: disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external provider adapters with trust-boundary metadata
|
||||
- Workspace AI Policy: disabled, local-only, private-only, EU-only, external-allowed-with-redaction, or explicit external-allowed modes
|
||||
- AI Data Classification: product knowledge, operational metadata, tenant config summary, redacted provider payload, raw provider payload, personal data, customer-confidential context, and legal/compliance statements
|
||||
- AI Context Builders: sanitized, purpose-specific context assembly instead of sending raw tenant/provider data to models
|
||||
- AI Usage Budgeting: credits, monthly caps, model-tier routing, queue priority, cost estimates, and cache/fingerprint reuse
|
||||
- AI Result Store & Cache: fingerprinted outputs with provenance, freshness, invalidation, source references, and reuse controls
|
||||
- AI Audit Trail: use case, provider, model class, data class, purpose, context hash, output hash, cost estimate, cache hit/miss, approval state, and result usage
|
||||
|
||||
### AI-Assisted Customer Operations
|
||||
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability.
|
||||
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by private AI execution policy, human approval, and product auditability.
|
||||
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
|
||||
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation.
|
||||
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
||||
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
||||
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation or uncontrolled public-model data processing.
|
||||
**Depends on**: Private AI Execution & Usage Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
||||
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Prefer local/private execution for tenant/customer data. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
||||
|
||||
### Decision-Based Operating Foundations
|
||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||
@ -309,6 +337,9 @@ ## Infrastructure & Platform Debt
|
||||
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution & Usage Governance Foundation |
|
||||
| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution & Usage Governance Foundation |
|
||||
| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution & Usage Governance Foundation |
|
||||
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
|
||||
| No `.env.example` in repo | Onboarding friction | Open |
|
||||
@ -325,15 +356,16 @@ ## Priority Ranking (from Product Brainstorming)
|
||||
|
||||
1. Product Scalability & Self-Service Foundation
|
||||
2. Product Usage, Customer Health & Operational Controls
|
||||
3. Decision-Based Operating / Governance Inbox
|
||||
4. MSP Portfolio + Alerting
|
||||
5. Drift + Approval Workflows
|
||||
6. Evidence / Review Packs + Customer Review Workspace
|
||||
7. Standardization / Linting
|
||||
8. Promotion DEV→PROD
|
||||
9. Recovery Confidence
|
||||
10. Solo-Founder SaaS Automation & Operating Readiness
|
||||
11. Additional Solo-Founder Scale Guardrails
|
||||
3. Private AI Execution & Usage Governance Foundation
|
||||
4. Decision-Based Operating / Governance Inbox
|
||||
5. MSP Portfolio + Alerting
|
||||
6. Drift + Approval Workflows
|
||||
7. Evidence / Review Packs + Customer Review Workspace
|
||||
8. Standardization / Linting
|
||||
9. Promotion DEV→PROD
|
||||
10. Recovery Confidence
|
||||
11. Solo-Founder SaaS Automation & Operating Readiness
|
||||
12. Additional Solo-Founder Scale Guardrails
|
||||
|
||||
---
|
||||
|
||||
@ -344,5 +376,6 @@ ## How to use this file
|
||||
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
|
||||
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
|
||||
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.
|
||||
- **AI positioning is local/private-first and provider-adapter-based**: roadmap language should avoid direct feature-level public AI calls and instead route AI through use-case registries, data classification, context builders, policy gates, budget gates, provider adapters, audit trails, and human approval workflows.
|
||||
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
||||
- **Product principles** → see [principles.md](principles.md)
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates, Additional Solo-Founder Scale Guardrails candidates, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||
|
||||
---
|
||||
|
||||
@ -62,6 +62,7 @@ ## Promoted to Spec
|
||||
- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`)
|
||||
- Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`)
|
||||
- Provider Boundary Hardening → Spec 237 (`provider-boundary-hardening`)
|
||||
- Support Diagnostic Pack → Spec 241 (`support-diagnostic-pack`)
|
||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||
@ -80,16 +81,19 @@ ## Qualified
|
||||
>
|
||||
> Recommended next sequence:
|
||||
>
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness**
|
||||
> 2. **Support Diagnostic Pack**
|
||||
> 3. **Product Usage & Adoption Telemetry**
|
||||
> 4. **Operational Controls & Feature Flags**
|
||||
> 5. **Provider Identity & Target Scope Neutrality**
|
||||
> 6. **Canonical Operation Type Source of Truth**
|
||||
> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 8. **Customer Review Workspace v1**
|
||||
> 1. **Self-Service Tenant Onboarding & Connection Readiness** (already promoted as Spec 240 on its feature branch)
|
||||
> 2. **Product Usage & Adoption Telemetry**
|
||||
> 3. **Operational Controls & Feature Flags**
|
||||
> 4. **Private AI Execution & Policy Foundation**
|
||||
> 5. **AI Usage Budgeting, Context & Result Governance**
|
||||
> 6. **Decision-Based Governance Inbox v1**
|
||||
> 7. **Decision Pack Contract & Approval Workflow**
|
||||
> 8. **Provider Identity & Target Scope Neutrality**
|
||||
> 9. **Canonical Operation Type Source of Truth**
|
||||
> 10. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||
> 11. **Customer Review Workspace v1**
|
||||
>
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount.
|
||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. With self-service onboarding already promoted as Spec 240 and Support Diagnostic Pack now promoted as Spec 241, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox become the next open priorities so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces.
|
||||
|
||||
|
||||
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
||||
@ -124,36 +128,6 @@ ### Self-Service Tenant Onboarding & Connection Readiness
|
||||
- **Strategic sequencing**: First item in this product-scalability cluster because it directly reduces manual onboarding and supports trials, demos, support, and customer transparency.
|
||||
- **Priority**: high
|
||||
|
||||
### Support Diagnostic Pack
|
||||
- **Type**: product scalability / supportability foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
- **Problem**: Support cases currently risk requiring manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, Report, Evidence, and audit surfaces. Without a reusable diagnostic bundle, every support request becomes an investigation task before the actual issue can be addressed.
|
||||
- **Why it matters**: A low-headcount SaaS needs support context to be captured by the product, not reconstructed by the founder. Diagnostic packs also create the safe input layer for later AI-assisted support summaries and triage without granting an AI or support user broad ad-hoc access to everything.
|
||||
- **Proposed direction**:
|
||||
- define a support diagnostic bundle contract for workspace, tenant, OperationRun, Finding, ProviderConnection, StoredReport, and review-pack contexts
|
||||
- include relevant health state, latest operation links, failure reason codes, permission/connection state, freshness, artifact references, audit references, and redacted operator summaries
|
||||
- provide an AI-readable but customer-safe summary shape that can be attached to support requests
|
||||
- keep raw sensitive payloads out of the default pack unless explicitly authorized
|
||||
- model redaction and access checks as first-class behavior
|
||||
- allow diagnostic packs to be referenced from in-app support requests and internal support workflows
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: diagnostic pack contract, context collectors, redaction rules, support-safe summary generation, access policy, references to runs/findings/reports/evidence, and tests
|
||||
- **Out of scope**: external ticket-system integration, support desk implementation, AI chat bot, broad log export, customer-visible trust center, or unrestricted raw payload download
|
||||
- **Acceptance points**:
|
||||
- a diagnostic pack can be generated for at least tenant and OperationRun contexts
|
||||
- pack contents are deterministic, scoped, and redacted according to caller capability
|
||||
- the pack links to canonical OperationRun/report/finding/evidence records instead of duplicating truth
|
||||
- sensitive raw provider payloads are excluded by default
|
||||
- tests prove unauthorized users cannot generate packs for unrelated workspaces/tenants
|
||||
- **Risks / open questions**:
|
||||
- Over-including raw context could create data-leak or compliance risk
|
||||
- Under-including context would make the pack less useful and push operators back to manual investigation
|
||||
- The product needs a clear capability boundary, likely related to `platform.support_diagnostics.view` and tenant/workspace support permissions
|
||||
- **Dependencies**: OperationRun link contract, StoredReports / EvidenceItems, Findings workflow, ProviderConnection health, audit log foundation, System Panel least-privilege model
|
||||
- **Related specs / candidates**: In-App Support Request with Context, AI-Assisted Customer Operations, System Panel Least-Privilege Capability Model, OperationRun Start UX Contract
|
||||
- **Strategic sequencing**: Second item after Self-Service Tenant Onboarding; it should land before support volume grows and before AI support triage is introduced.
|
||||
- **Priority**: high
|
||||
|
||||
### In-App Support Request with Context
|
||||
- **Type**: product scalability / support workflow
|
||||
- **Source**: roadmap update 2026-04-25 — Product Scalability & Self-Service Foundation
|
||||
@ -535,7 +509,84 @@ ### Data Retention, Export & Deletion Self-Service
|
||||
|
||||
|
||||
|
||||
> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist.
|
||||
|
||||
|
||||
> Private AI Execution & Usage Governance Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make AI a governed platform capability, not a set of direct feature-level public API calls. TenantPilot should be local/private-first for tenant/customer data, provider-adapter-based, budgeted, cacheable, auditable, and human-approved where risk matters. External public AI providers must be disabled by default and only usable through explicit workspace policy, data classification, redaction, budget limits, and approval gates.
|
||||
|
||||
### Private AI Execution & Policy Foundation
|
||||
- **Type**: AI platform foundation / privacy boundary / provider abstraction
|
||||
- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation
|
||||
- **Problem**: Future AI-assisted summaries, diagnostics, review packs, decision packs, support responses, and customer communications will be risky if individual features call model providers directly. Direct calls would make it hard to support local/private models, enforce data boundaries, audit usage, control costs, or answer German enterprise customers' privacy and compliance questions.
|
||||
- **Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI must therefore be governed like a platform capability: use-case registered, data-classified, policy-gated, budget-gated, provider-adapted, audited, and human-approved where needed. The architecture must support local/private/customer-hosted/EU-private models without later rewrites.
|
||||
- **Proposed direction**:
|
||||
- introduce an AI Use Case Registry for approved AI use cases such as finding summaries, operation summaries, support diagnostic summaries, review-pack executive summaries, decision-pack recommendations, and release/customer communication drafts
|
||||
- introduce an AI Provider Registry with provider classes such as disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external public provider adapters
|
||||
- introduce Workspace AI Policy modes such as disabled, local-only, private-only, EU-only, external-allowed-with-redaction, and explicit external-allowed
|
||||
- introduce AI Data Classification for product knowledge, operational metadata, tenant config summaries, redacted provider payloads, raw provider payloads, personal data, customer-confidential context, and legal/compliance statements
|
||||
- ensure AI execution is only possible through a central policy gate and provider adapter, never direct feature-level model calls
|
||||
- default external public AI providers to disabled for customer/tenant data
|
||||
- define capability/RBAC boundaries for managing AI settings and viewing AI execution metadata
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: AI use-case registry, AI provider registry, workspace AI policy, AI data classification, policy evaluation service, provider adapter interface, initial disabled/local/private-compatible provider seam, RBAC/capability checks, and audit metadata shape
|
||||
- **Out of scope**: building a full AI chatbot, implementing every provider, model benchmarking, autonomous remediation, legal final approval of AI disclosures, customer-facing AI UI for all use cases, or sending real tenant data to external providers
|
||||
- **Acceptance points**:
|
||||
- feature code cannot invoke AI without going through the central AI execution boundary
|
||||
- every AI request declares use case, workspace, data class, model/provider class, purpose, and output visibility
|
||||
- external public providers are disabled by default for tenant/customer data
|
||||
- workspace AI policy can block or allow AI execution modes predictably
|
||||
- raw provider payload and personal/customer-confidential data classes are rejected for external public providers by default
|
||||
- AI policy decisions are auditable with actor/system actor, workspace, use case, provider class, data class, and decision outcome
|
||||
- tests prove a disallowed provider/data-class combination cannot execute
|
||||
- **Risks / open questions**:
|
||||
- The first version must avoid overbuilding a provider marketplace before real AI use cases exist
|
||||
- Local/private model support may initially be an adapter seam rather than a fully operated inference stack
|
||||
- Workspace AI policy must be simple enough for operators but precise enough for enterprise trust conversations
|
||||
- Data classification must align with Security Trust Pack Light and actual stored product data
|
||||
- **Dependencies**: Security Trust Pack Light, Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, audit log foundation, workspace/RBAC isolation, Operational Controls & Feature Flags
|
||||
- **Related specs / candidates**: AI Usage Budgeting, Context & Result Governance, AI-Assisted Customer Operations, Decision Pack Contract & Approval Workflow, Support Diagnostic Pack, Security Trust Pack Light
|
||||
- **Strategic sequencing**: Should land before broad AI-assisted customer operations or decision recommendations. This is the safety and provider boundary for all later AI features.
|
||||
- **Priority**: high
|
||||
|
||||
### AI Usage Budgeting, Context & Result Governance
|
||||
- **Type**: AI cost governance / context governance / result lifecycle foundation
|
||||
- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation
|
||||
- **Problem**: Even with local/private models, AI usage consumes compute, queue capacity, latency budget, and potentially paid provider credits. Without usage budgeting, context builders, redaction, fingerprinting, result caching, and output governance, AI-assisted support, reviews, decision packs, and summaries can become expensive, slow, inconsistent, or unsafe.
|
||||
- **Why it matters**: AI-native SaaS margins depend on treating AI calls as metered, prioritized, cacheable product operations. TenantPilot also needs to avoid sending raw provider payloads or excessive customer context to models. Structured context builders and result governance make AI outputs safer, cheaper, more stable, and easier to audit.
|
||||
- **Proposed direction**:
|
||||
- introduce an AI Usage Ledger for use case, workspace, tenant/reference, provider class, model class, data class, token/compute estimate, credit/cost estimate, queue priority, cache hit/miss, status, and purpose
|
||||
- introduce AI credits or budget counters at workspace/plan level, with monthly caps, soft/hard limits, and operator override semantics
|
||||
- introduce model-tier routing so low-risk summarization can use cheaper/local models while high-value decision recommendations can require stronger reasoning models and approval
|
||||
- introduce purpose-specific AI Context Builders for finding, drift, operation run, support diagnostic, review pack, and decision pack use cases
|
||||
- introduce redaction and minimization rules so context is sanitized, referenced, or summarized instead of passing raw tenant/provider data
|
||||
- introduce AI Result Store & Cache keyed by fingerprints such as finding fingerprint, drift fingerprint, operation-run context hash, report fingerprint, evidence bundle fingerprint, and decision-pack fingerprint
|
||||
- introduce approval gates and lifecycle states for customer-facing, legal/compliance, risk-accepting, or tenant-changing AI outputs
|
||||
- expose basic operator visibility into AI usage, budget status, cache reuse, and blocked/failed AI jobs
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: usage ledger, budget/credit service, context-builder contracts for first use cases, redaction hooks, result cache/store, fingerprinting, basic model-tier routing, queue priority metadata, approval state for sensitive outputs, and tests
|
||||
- **Out of scope**: full billing integration, public customer AI usage dashboard, complex cost accounting, prompt marketplace, model fine-tuning, autonomous execution, or broad AI observability suite
|
||||
- **Acceptance points**:
|
||||
- AI jobs are recorded in a ledger with use case, workspace, provider/model class, data class, status, cache hit/miss, and cost/credit estimate
|
||||
- workspace/plan AI budgets can block or degrade non-critical AI jobs when limits are exceeded
|
||||
- at least one AI use case uses a context builder instead of raw model input from feature code
|
||||
- result cache/fingerprint reuse prevents repeated generation for unchanged inputs
|
||||
- customer-facing or risk-relevant AI outputs can remain draft/pending approval before use
|
||||
- tests prove budget enforcement, cache reuse, redaction boundary, and approval-required output behavior
|
||||
- **Risks / open questions**:
|
||||
- Cost estimation may be approximate for local/private models; the system should support both token-cost and compute-credit abstractions
|
||||
- Over-caching could reuse stale summaries if invalidation rules are weak
|
||||
- Under-caching could make AI features too expensive and inconsistent
|
||||
- Approval gates should not create UX friction for low-risk internal summaries
|
||||
- **Dependencies**: Private AI Execution & Policy Foundation, Plans / Entitlements & Billing Readiness, Product Usage & Adoption Telemetry, Support Diagnostic Pack, StoredReports / EvidenceItems, Decision Pack Contract & Approval Workflow, OperationRun truth
|
||||
- **Related specs / candidates**: AI-Assisted Customer Operations, Customer Lifecycle Communication, Product Knowledge & Contextual Help, Operational Controls & Feature Flags, Decision-Based Governance Inbox v1
|
||||
- **Strategic sequencing**: Should follow or pair with Private AI Execution & Policy Foundation. It should land before AI is used at scale for reviews, support, decision packs, or customer communication.
|
||||
- **Priority**: high
|
||||
|
||||
> Recommended sequence for this cluster:
|
||||
> 1. **Private AI Execution & Policy Foundation**
|
||||
> 2. **AI Usage Budgeting, Context & Result Governance**
|
||||
> 3. **AI-Assisted Customer Operations**
|
||||
>
|
||||
> Why this order: first establish the trust boundary and provider/data policy, then add cost/context/result controls, and only then scale AI-assisted customer operations on top of governed inputs, budgets, caches, audits, and approvals.
|
||||
|
||||
### Decision-Based Governance Inbox v1
|
||||
- **Type**: product strategy / workflow automation / operator UX
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Quality pass completed on 2026-04-25.
|
||||
- The spec stays intentionally narrow: it reuses existing onboarding session, provider connection, verification, and checkpoint truth and does not introduce new onboarding persistence.
|
||||
- Provider-specific Microsoft details remain contextual inside diagnostics instead of becoming new platform-core truth.
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
@ -0,0 +1,266 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin — Onboarding Readiness Workflow (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual HTTP contract for the operator-facing onboarding readiness workflow.
|
||||
|
||||
NOTE: These routes are implemented as Filament (Livewire) pages and existing
|
||||
actions. The exact Livewire payload shape is not part of this contract; this
|
||||
file captures the user-visible routes, authorization semantics, and logical
|
||||
view-model expectations.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/onboarding:
|
||||
get:
|
||||
summary: View onboarding landing or draft picker
|
||||
description: |
|
||||
Workspace-scoped onboarding entry point.
|
||||
|
||||
Behavior:
|
||||
- No workspace selected: redirect to `/admin/choose-workspace`
|
||||
- Non-member or wrong workspace: 404
|
||||
- Workspace member without onboarding capability: 403
|
||||
- One resumable draft: redirect to `/admin/onboarding/{onboardingDraft}`
|
||||
- Multiple resumable drafts: render the draft picker with compact readiness snippets
|
||||
responses:
|
||||
'200':
|
||||
description: Landing picker rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/OnboardingLandingView'
|
||||
'302':
|
||||
description: Redirect to choose-workspace or the single resumable draft
|
||||
'403':
|
||||
description: Forbidden (workspace member lacks onboarding capability)
|
||||
'404':
|
||||
description: Not found (non-member or wrong workspace)
|
||||
/onboarding/{onboardingDraft}:
|
||||
get:
|
||||
summary: View onboarding draft readiness workflow
|
||||
description: |
|
||||
Renders the existing managed-tenant onboarding wizard with a derived
|
||||
readiness summary, freshness cues, and one primary next action.
|
||||
|
||||
Authorization:
|
||||
- Non-member or wrong workspace: 404
|
||||
- Missing linked-tenant entitlement: 404
|
||||
- Workspace member without onboarding capability: 403
|
||||
parameters:
|
||||
- name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Internal `managed_tenant_onboarding_sessions.id`
|
||||
responses:
|
||||
'200':
|
||||
description: Onboarding draft workflow rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/OnboardingReadinessView'
|
||||
'403':
|
||||
description: Forbidden (workspace member lacks onboarding capability)
|
||||
'404':
|
||||
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||
/onboarding/{onboardingDraft}/actions/start-verification:
|
||||
post:
|
||||
summary: Start or rerun verification from the onboarding readiness workflow
|
||||
description: |
|
||||
Conceptual contract for the existing wizard verification action.
|
||||
This feature must preserve current authorization, audit, dedupe, and
|
||||
shared OperationRun start UX semantics.
|
||||
parameters:
|
||||
- name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'202':
|
||||
description: Verification accepted/queued
|
||||
'403':
|
||||
description: Forbidden (member lacks verification-start capability)
|
||||
'404':
|
||||
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||
/onboarding/{onboardingDraft}/actions/complete:
|
||||
post:
|
||||
summary: Complete onboarding when readiness allows activation
|
||||
description: |
|
||||
Conceptual contract for the existing owner-gated completion action.
|
||||
The action remains confirmation-protected and audited.
|
||||
parameters:
|
||||
- name: onboardingDraft
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'204':
|
||||
description: Onboarding completed
|
||||
'403':
|
||||
description: Forbidden (member lacks activation capability)
|
||||
'404':
|
||||
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||
/operations/{run}:
|
||||
get:
|
||||
summary: Open canonical supporting operation from onboarding readiness
|
||||
description: |
|
||||
Existing canonical tenantless operation-detail route linked from the
|
||||
onboarding readiness workflow when supporting verification or bootstrap
|
||||
evidence exists.
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Internal `operation_runs.id`
|
||||
responses:
|
||||
'200':
|
||||
description: Operation detail rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Forbidden (member lacks permission for an action on the page)
|
||||
'404':
|
||||
description: Not found (run inaccessible under current workspace/tenant scope)
|
||||
components:
|
||||
schemas:
|
||||
OnboardingLandingView:
|
||||
type: object
|
||||
required:
|
||||
- mode
|
||||
- drafts
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [start_state, single_redirect, draft_picker]
|
||||
drafts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OnboardingDraftCard'
|
||||
primary_action:
|
||||
$ref: '#/components/schemas/NextAction'
|
||||
nullable: true
|
||||
OnboardingDraftCard:
|
||||
type: object
|
||||
required:
|
||||
- draft_id
|
||||
- tenant_name
|
||||
- current_stage
|
||||
- readiness_summary
|
||||
- next_action
|
||||
properties:
|
||||
draft_id:
|
||||
type: integer
|
||||
tenant_name:
|
||||
type: string
|
||||
current_stage:
|
||||
type: string
|
||||
readiness_summary:
|
||||
type: string
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
next_action:
|
||||
$ref: '#/components/schemas/NextAction'
|
||||
OnboardingReadinessView:
|
||||
type: object
|
||||
required:
|
||||
- draft
|
||||
- readiness
|
||||
- next_action
|
||||
properties:
|
||||
draft:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- tenant_name
|
||||
- current_stage
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tenant_name:
|
||||
type: string
|
||||
current_stage:
|
||||
type: string
|
||||
started_by:
|
||||
type: string
|
||||
nullable: true
|
||||
updated_by:
|
||||
type: string
|
||||
nullable: true
|
||||
readiness:
|
||||
type: object
|
||||
required:
|
||||
- lifecycle_state
|
||||
- summary
|
||||
properties:
|
||||
lifecycle_state:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
checkpoint:
|
||||
type: string
|
||||
nullable: true
|
||||
provider_summary:
|
||||
type: string
|
||||
nullable: true
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
blocker_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
next_action:
|
||||
$ref: '#/components/schemas/NextAction'
|
||||
supporting_links:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LinkAction'
|
||||
NextAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- kind
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- start_onboarding
|
||||
- resume_draft
|
||||
- grant_consent
|
||||
- review_permissions
|
||||
- start_verification
|
||||
- rerun_verification
|
||||
- open_operation
|
||||
- review_bootstrap
|
||||
- complete_onboarding
|
||||
url:
|
||||
type: string
|
||||
nullable: true
|
||||
action_name:
|
||||
type: string
|
||||
nullable: true
|
||||
LinkAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- url
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
140
specs/240-tenant-onboarding-readiness/data-model.md
Normal file
140
specs/240-tenant-onboarding-readiness/data-model.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Data Model — Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
No new persistent tables are required for this slice. Readiness is computed at render time from existing onboarding, provider connection, verification, and permission-posture truth.
|
||||
|
||||
## Entities
|
||||
|
||||
### TenantOnboardingSession (`managed_tenant_onboarding_sessions`)
|
||||
|
||||
**Purpose**: Workspace-scoped onboarding workflow record that owns resumability, checkpoint progression, and links to the managed tenant once identified.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `workspace_id` (required FK)
|
||||
- `tenant_id` (nullable FK to `tenants.id` until the tenant is linked)
|
||||
- `entra_tenant_id`
|
||||
- `current_step`
|
||||
- `version`
|
||||
- `lifecycle_state`
|
||||
- `current_checkpoint`
|
||||
- `last_completed_checkpoint`
|
||||
- `reason_code`
|
||||
- `blocking_reason_code`
|
||||
- `completed_at`, `cancelled_at`
|
||||
- `state` JSON, constrained by `TenantOnboardingSession::STATE_ALLOWED_KEYS`
|
||||
|
||||
**Relevant `state` keys (existing)**:
|
||||
- `tenant_name`
|
||||
- `primary_domain`
|
||||
- `provider_connection_id`
|
||||
- `selected_provider_connection_id`
|
||||
- `verification_operation_run_id`
|
||||
- `bootstrap_operation_types`
|
||||
- `bootstrap_operation_runs`
|
||||
- `connection_recently_updated`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- Belongs to `Workspace`
|
||||
- May belong to `Tenant`
|
||||
- Belongs to `startedByUser`
|
||||
- Belongs to `updatedByUser`
|
||||
|
||||
### ProviderConnection (`provider_connections`)
|
||||
|
||||
**Purpose**: Tenant-owned provider access record whose consent, verification, and target-scope state inform onboarding readiness.
|
||||
|
||||
**Key fields (existing, relevant)**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `provider`
|
||||
- `display_name`
|
||||
- `connection_type`
|
||||
- `is_default`
|
||||
- `is_enabled`
|
||||
- `consent_status`
|
||||
- `verification_status`
|
||||
- target-scope identity fields consumed by `ProviderConnectionTargetScopeNormalizer`
|
||||
|
||||
**Relationships / invariants (existing)**:
|
||||
- The selected provider connection must belong to the same workspace and tenant as the onboarding draft.
|
||||
- `ProviderConnectionSurfaceSummary::forConnection()` is the shared source for provider summary wording and contextual identity detail.
|
||||
|
||||
### VerificationRunEvidence (`operation_runs`, existing subset)
|
||||
|
||||
**Purpose**: Existing supporting evidence for verification and bootstrap readiness, including canonical operation detail links.
|
||||
|
||||
**Key fields (existing, relevant)**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context.provider_connection_id`
|
||||
- `context.verification_report`
|
||||
- `summary_counts`
|
||||
|
||||
**Constraints / invariants (existing)**:
|
||||
- The verification run must belong to the same workspace and tenant as the onboarding draft.
|
||||
- Verification evidence is only trustworthy for readiness when `context.provider_connection_id` matches the draft’s selected provider connection.
|
||||
- Canonical evidence links must continue to flow through `OperationRunLinks` / tenantless operation helpers.
|
||||
|
||||
### PermissionPostureOverview (derived from existing permission posture data)
|
||||
|
||||
**Purpose**: Existing stored permission comparison summary used by onboarding verification assist and readiness freshness cues.
|
||||
|
||||
**Source (existing)**:
|
||||
- `TenantPermissionService::compare(...)`
|
||||
- `TenantRequiredPermissionsViewModelBuilder::build(...)`
|
||||
|
||||
**Relevant derived fields (existing)**:
|
||||
- `overview.overall`
|
||||
- `overview.counts.missing_application`
|
||||
- `overview.counts.missing_delegated`
|
||||
- `overview.counts.error`
|
||||
- `overview.freshness.last_refreshed_at`
|
||||
- `overview.freshness.is_stale`
|
||||
|
||||
**Invariant (existing)**:
|
||||
- Permission freshness is stale when no refresh exists or `last_refreshed_at` is older than 30 days (`TenantRequiredPermissionsViewModelBuilder::deriveFreshness`).
|
||||
|
||||
### OnboardingReadinessSummary (computed, not persisted)
|
||||
|
||||
**Purpose**: Operator-facing derived summary rendered on the onboarding landing picker and route-bound draft view.
|
||||
|
||||
**Proposed runtime shape (presentation-only)**:
|
||||
- `draft`: `id`, `tenant_name`, `stage_label`, `draft_status_label`, `started_by`, `updated_by`, `last_updated_human`
|
||||
- `checkpoint`: `current_checkpoint`, `last_completed_checkpoint`, `lifecycle_state`
|
||||
- `provider_summary`: `readiness_summary`, `consent_state`, `verification_state`, `target_scope_summary`, `contextual_identity_line`
|
||||
- `verification`: `status`, `overall`, `run_id`, `run_url`, `is_active`, `matches_selected_connection`
|
||||
- `freshness`: `connection_recently_updated`, `verification_mismatch`, `permission_last_refreshed_at`, `permission_data_is_stale`
|
||||
- `blocker`: `reason_code`, `blocking_reason_code`, `operator_summary`
|
||||
- `next_action`: `label`, `kind`, `url_or_action`, `required_capability`
|
||||
- `supporting_links`: `operation_url`, `tenant_url`, `consent_url` when already available from existing routes/helpers
|
||||
|
||||
**Important rule**: This is a presentation shape only. It must map directly from existing onboarding lifecycle, provider connection, verification, and permission-posture truth. It is not a new domain model or persisted state family.
|
||||
|
||||
## Derived Rules / Invariants
|
||||
|
||||
- A draft without tenant identity cannot be ready; the primary action remains the identify-tenant step.
|
||||
- A draft without a selected provider connection cannot be ready; the primary action remains connect/select provider.
|
||||
- A verification run that does not match the selected provider connection is stale for readiness and must force a non-ready outcome.
|
||||
- `connection_recently_updated=true` invalidates previous verification trust until verification reruns.
|
||||
- Stale permission posture (`overview.freshness.is_stale=true`) must surface as a readiness attention cue or diagnostic freshness cue, not as ready.
|
||||
- Top-level readiness wording stays platform-neutral. Provider-specific permission names and consent instructions remain inside secondary diagnostics.
|
||||
- Supporting evidence uses canonical operation links only; no page-local run URLs are introduced.
|
||||
|
||||
## Rendering Precedence (derived, not persisted)
|
||||
|
||||
No new persisted transitions are introduced. The readiness summary should follow this rendering precedence when choosing one primary next action:
|
||||
|
||||
1. No identified tenant: `Identify tenant`
|
||||
2. No selected provider connection: `Connect provider`
|
||||
3. Consent missing or revoked: `Grant consent`
|
||||
4. Permission diagnostics blocked or incomplete: `Review permissions` / `Grant consent` as dictated by existing provider-owned diagnostics
|
||||
5. Verification missing, stale, or mismatched: `Start verification` or `Rerun verification`
|
||||
6. Verification active: `Open operation` or `Refresh`
|
||||
7. Bootstrap selected and still active/failed: `Review bootstrap`
|
||||
8. Lifecycle ready for activation: `Complete onboarding`
|
||||
|
||||
The multi-draft landing surface uses the same precedence, but only in compact form so the operator can choose the correct draft to open.
|
||||
196
specs/240-tenant-onboarding-readiness/plan.md
Normal file
196
specs/240-tenant-onboarding-readiness/plan.md
Normal file
@ -0,0 +1,196 @@
|
||||
# Implementation Plan: Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Branch**: `240-tenant-onboarding-readiness` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Add one operator-facing, derived readiness summary to the existing managed-tenant onboarding workflow: the route-bound draft at `/admin/onboarding/{onboardingDraft}` always shows full readiness, while the `/admin/onboarding` landing surface adds compact readiness only when multiple resumable drafts exist and preserves the existing single-draft redirect behavior.
|
||||
- Reuse current onboarding draft lifecycle, provider connection summary, verification diagnostics, the existing 30-day permission freshness rule, and canonical OperationRun links instead of adding new persistence, readiness enums, or provider-generic onboarding frameworks.
|
||||
- Keep the workflow DB-only at render time, workspace- and tenant-safe, and centered on one operator decision: what blocks this tenant now and what the next action is.
|
||||
- Defer numeric completion scoring and any cross-surface readiness abstraction; later consumers must reuse the same derived onboarding truth instead of broadening this slice.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||
**Storage**: PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned
|
||||
**Testing**: Pest feature + unit tests (Filament/Livewire feature coverage plus policy/unit coverage)
|
||||
**Validation Lanes**: fast-feedback
|
||||
**Target Platform**: Sail-backed Laravel admin panel under `/admin`
|
||||
**Project Type**: web
|
||||
**Performance Goals**: onboarding landing and draft routes remain DB-only at render/hydration, compose readiness in-request from existing records, and avoid outbound HTTP or new queue starts during page render
|
||||
**Constraints**: preserve workspace isolation, linked-tenant entitlement checks, existing RBAC capability boundaries, provider-boundary neutrality in top-level wording, current destructive confirmation patterns, current OperationRun start UX, and no new tables/enums/frameworks for readiness
|
||||
**Scale/Scope**: one workspace-scoped onboarding workflow surface, one derived readiness composition, and focused onboarding/policy coverage only
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + shared primitives
|
||||
- **Shared-family relevance**: status messaging, action links, badges, embedded diagnostics, navigation
|
||||
- **State layers in scope**: page + workflow-step + derived summary
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core
|
||||
- **Exception path and spread control**: existing guided-workflow exception for `ManagedTenantOnboardingWizard`; no new surface exemption
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `ManagedTenantOnboardingWizard`, onboarding landing/draft picker, `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks`
|
||||
- **Shared abstractions reused**: `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`
|
||||
- **New abstraction introduced? why?**: none; readiness stays a thin derived composition on the existing workflow surface
|
||||
- **Why the existing abstraction was sufficient or insufficient**: existing services already own checkpoint progression, provider connection wording, permission freshness, verification diagnostics, and canonical run links; the missing piece is a single operator-facing composition and action prioritization
|
||||
- **Bounded deviation / spread control**: provider-specific consent and permission names remain inside existing diagnostic detail and provider-owned next steps; top-level readiness copy stays platform-neutral, and any shared builder extension must stay additive and behavior-preserving for non-onboarding consumers while onboarding-specific prioritization remains page-local
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: shared OperationRun UX layer via `OperationRunLinks`, `OperationUxPresenter`, and the existing onboarding `tenantlessOperationRunUrl(...)` helper
|
||||
- **Delegated UX behaviors**: queued toast, dedupe-or-blocked messaging, canonical `Open operation` link labeling, tenant/workspace-safe run URL resolution, and terminal lifecycle notifications remain delegated to shared Ops-UX paths
|
||||
- **Surface-owned behavior kept local**: readiness wording, freshness explanation, and next-action prioritization inside the onboarding workflow
|
||||
- **Queued DB-notification policy**: unchanged explicit opt-in; this slice does not add queued or running DB notifications
|
||||
- **Terminal notification path**: central lifecycle mechanism already used by reused verification/bootstrap actions
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: consent diagnostics, missing-permission detail, provider-specific remediation next steps, target-scope identity detail
|
||||
- **Platform-core seams**: readiness summary, checkpoint progress, next-action labels, freshness messaging, workflow routing
|
||||
- **Neutral platform terms / contracts preserved**: `provider connection`, `readiness`, `diagnostics`, `next action`, `freshness`, `onboarding step`
|
||||
- **Retained provider-specific semantics and why**: Microsoft permission names and consent language stay inside existing verification assist and provider summary detail because the operator needs exact remediation instructions for the supported provider
|
||||
- **Bounded extraction or follow-up path**: none; if a second provider appears later, revisit only the provider-owned detail seam rather than the workflow shell
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Persisted truth / proportionality: PASS — readiness remains derived from existing onboarding, provider connection, operation-run, and permission-posture truth; no new table, enum, or state family is planned.
|
||||
- Read/write separation / destructive actions: PASS — this slice changes operator understanding on an existing wizard; existing cancel/delete/complete actions retain current confirmation, audit, and authorization rules.
|
||||
- Graph contract path / DB-only render: PASS — render and Livewire hydration stay DB-only, reusing stored provider connection, verification, and permission-posture data with no new Graph calls.
|
||||
- RBAC / isolation: PASS — existing workspace membership, linked-tenant entitlement, and `Capabilities::*` gates remain authoritative; non-members and wrong-scope actors stay 404, capability-denied members stay 403.
|
||||
- Ops-UX / OperationRun link semantics: PASS — existing verification/bootstrap starts and run links continue to use shared OperationRun UX and canonical tenantless route helpers; no new run-start path is introduced.
|
||||
- Provider boundary / shared pattern reuse: PASS — provider-neutral readiness remains top-level, Microsoft-specific details stay contextual, and existing shared builders/helpers are reused before any local deviation.
|
||||
- Test governance: PASS — proof stays in fast-feedback feature/unit coverage using existing onboarding fixtures; no new browser or heavy-governance family is required.
|
||||
- Global search / panel registration: N/A — this slice changes a Filament page, not a global-searchable resource or panel provider.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Feature (wizard + landing/picker rendering and behavior) plus targeted Unit/Policy coverage for isolation semantics and existing freshness helper behavior where directly reused
|
||||
- **Affected validation lanes**: fast-feedback
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the workflow is server-driven Filament/Livewire UI backed by existing models and policies, so feature tests can prove readiness composition, next-action precedence, and deny semantics without browser automation
|
||||
- **Narrowest proving command(s)**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing onboarding draft, workspace membership, tenant membership, provider connection, and operation-run fixtures; avoid adding browser fixtures or new provider mocks
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any new test helpers should stay onboarding-local
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament guided-workflow exception already exists; add feature assertions to cover the readiness summary on both landing and draft routes
|
||||
- **Closing validation and reviewer handoff**: rerun the targeted onboarding wizard, draft-picker/authorization, and policy/freshness tests after implementation; reviewers should verify one primary next action, correct 404 vs 403 semantics, and no render-time outbound HTTP
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
||||
- **Review-stop questions**: Does the slice stay in fast-feedback? Did any new fixture force browser/heavy coverage? Did the implementation introduce a second readiness taxonomy or persisted truth?
|
||||
- **Escalation path**: document-in-feature if a shared helper needs a tiny extension; reject-or-split if implementation tries to add new persistence/frameworks or browser-lane proof
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the planned proof extends already-existing onboarding suites and does not add a new recurring test cost center
|
||||
|
||||
### Implementation Close-Out — 2026-04-25
|
||||
|
||||
- **Guardrail result**: keep. The implementation stayed inside the existing guided onboarding page, reused current lifecycle/stage, provider summary, verification assist, permission freshness, and OperationRun link paths, and did not add persistence, enums, a readiness taxonomy, or a cross-surface framework.
|
||||
- **Fast-feedback proof**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` passed with 55 tests and 212 assertions.
|
||||
- **Provider-boundary / lane-cost decision**: keep. Provider-specific permission and consent detail remains secondary and provider-owned; top-level readiness wording stays platform-neutral. No browser, heavy-governance, new fixture family, or follow-up spec is required.
|
||||
- **Explicit defers retained**: numeric completion score and cross-surface readiness reuse remain deferred; future consumers should reuse this derived onboarding truth rather than broaden this slice.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/240-tenant-onboarding-readiness/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── onboarding-readiness.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||
│ ├── Models/TenantOnboardingSession.php
|
||||
│ ├── Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||
│ ├── Services/Onboarding/
|
||||
│ │ ├── OnboardingDraftStageResolver.php
|
||||
│ │ └── OnboardingLifecycleService.php
|
||||
│ ├── Support/OperationRunLinks.php
|
||||
│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
|
||||
│ └── Support/Verification/VerificationAssistViewModelBuilder.php
|
||||
└── tests/
|
||||
├── Feature/ManagedTenantOnboardingWizardTest.php
|
||||
├── Feature/Onboarding/OnboardingDraftAuthorizationTest.php
|
||||
├── Feature/Onboarding/OnboardingDraftPickerTest.php
|
||||
├── Unit/Policies/TenantOnboardingSessionPolicyTest.php
|
||||
└── Unit/TenantRequiredPermissionsFreshnessTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. This feature remains confined to the existing onboarding wizard/landing workflow, existing onboarding + provider support services, and focused onboarding/policy tests.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this feature. The plan explicitly avoids new persistence, abstractions, readiness enums, or cross-surface frameworks.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
N/A — the plan intentionally keeps readiness derived and local to the existing onboarding workflow. No new persisted entity, abstraction layer, enum/status family, or taxonomy is proposed.
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the narrowest existing sources of truth for onboarding readiness, draft landing metadata, provider summary, permission freshness, and canonical operation links.
|
||||
- Resolve the freshness question by reusing the existing 30-day permission freshness rule plus current connection-change and selected-connection-mismatch signals rather than introducing a new onboarding-specific threshold or persisted freshness model.
|
||||
- Confirm that landing-route behavior remains the same surface (single-draft redirect or multi-draft picker) so readiness context is added without creating a second onboarding register.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/quickstart.md`
|
||||
|
||||
Design focus:
|
||||
- Add one derived readiness composition on `ManagedTenantOnboardingWizard` and the existing draft picker using current draft lifecycle, provider connection summary, verification assist, the existing 30-day permission freshness rule, and explicit "check has not run yet" guidance where no evidence exists.
|
||||
- Keep landing-route behavior intact: a single resumable draft still redirects to the route-bound draft; a multi-draft landing view gains compact readiness snippets next to existing metadata.
|
||||
- Define next-action precedence from existing truth only: identify tenant → connect provider → grant consent/fix permissions → run or rerun verification → review bootstrap → complete onboarding.
|
||||
- Keep permission and consent detail secondary and provider-owned; keep `Open operation` and consent links canonical and shared.
|
||||
- Keep onboarding-specific readiness wording and action prioritization local to the onboarding workflow; if a shared provider or verification builder needs extension, keep the change additive and regression-safe for existing non-onboarding consumers.
|
||||
- Defer numeric completion score and any support-diagnostic or trial/demo-specific projection to later specs that reuse the same derived onboarding truth.
|
||||
- Do not add new tables, readiness enums, global resources, or provider-generic onboarding abstractions.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
- Extend onboarding landing and draft rendering to surface a derived readiness summary, freshness, and one primary next action from existing services/helpers.
|
||||
- Reuse `OnboardingLifecycleService` snapshot truth, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to map existing records into operator-facing copy.
|
||||
- Add compact readiness metadata to the multi-draft picker and route-bound resume context without creating a new onboarding index resource.
|
||||
- Preserve existing consent, verification, bootstrap, operation-link, and destructive header actions; only change prioritization, grouping emphasis, and explanatory copy.
|
||||
- Expand targeted feature and policy tests for readiness states, freshness/mismatch handling, landing summary behavior, and 404 vs 403 regressions.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. Design artifacts keep readiness derived, DB-only at render time, workspace/tenant safe, Ops-UX compliant, and constrained to existing onboarding + provider foundations.
|
||||
39
specs/240-tenant-onboarding-readiness/quickstart.md
Normal file
39
specs/240-tenant-onboarding-readiness/quickstart.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Quickstart — Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
- Current feature branch checked out: `240-tenant-onboarding-readiness`
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Run targeted validation after implementation:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||
- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual smoke (after implementation)
|
||||
|
||||
1. Select a workspace and open `/admin/onboarding`.
|
||||
2. With multiple resumable drafts, confirm each draft card shows current stage, readiness summary, freshness cue, and one primary next action.
|
||||
3. Open a draft with missing consent and confirm the workflow points to consent or permission remediation instead of generic incomplete-state copy.
|
||||
4. Open a draft with a changed provider connection or mismatched verification run and confirm readiness falls back to needs-attention with a canonical `Open operation` link when evidence exists.
|
||||
5. Open the workflow as a wrong-workspace actor or actor without linked-tenant entitlement and confirm 404; open as a workspace member without onboarding capability and confirm 403 on protected actions.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 requires Livewire v4.0+; this repo already satisfies that requirement.
|
||||
- Laravel 11+ panel providers are registered in `bootstrap/providers.php`; this feature does not add or change panel providers.
|
||||
- No new Filament Resource or Global Search surface is planned, so global search behavior is unchanged.
|
||||
- No new assets are registered. Deployment keeps the existing Filament asset step (`cd apps/platform && php artisan filament:assets`) when other asset-bearing changes require it.
|
||||
- Readiness freshness for this slice reuses existing signals only: connection-change / selected-connection mismatch from onboarding lifecycle state plus stored permission freshness from `TenantRequiredPermissionsViewModelBuilder`.
|
||||
|
||||
## Implementation proof — 2026-04-25
|
||||
|
||||
- Targeted fast-feedback validation passed: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` — 55 tests, 212 assertions.
|
||||
- Formatting passed: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- Guardrail close-out: keep. No provider-boundary drift, no lane-cost escalation, no new assets, no new persistence, and no follow-up spec required.
|
||||
- Explicit defers retained: numeric completion score and cross-surface readiness reuse.
|
||||
72
specs/240-tenant-onboarding-readiness/research.md
Normal file
72
specs/240-tenant-onboarding-readiness/research.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Research: Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Branch**: `240-tenant-onboarding-readiness`
|
||||
**Date**: 2026-04-25
|
||||
**Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||
**Plan**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/plan.md`
|
||||
|
||||
## Decisions
|
||||
|
||||
### D-001 — Keep readiness derived inside the existing onboarding workflow
|
||||
|
||||
**Decision**: Compose onboarding readiness inside the existing `ManagedTenantOnboardingWizard` and related landing/draft-picker rendering, using current onboarding, provider connection, verification, and permission-posture truth. Do not add an onboarding-readiness table, persisted projection, or new readiness enum.
|
||||
|
||||
**Rationale**: The repo already stores the durable workflow truth in `TenantOnboardingSession`, `ProviderConnection`, `OperationRun`, and existing permission-posture data. `OnboardingLifecycleService` already computes checkpoint and action-required state, while `ProviderConnectionSurfaceSummary` and verification-assist builders already expose the provider and permissions detail needed for an operator-facing summary.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a dedicated onboarding-readiness model or table: rejected because the summary is derived and has no independent lifecycle.
|
||||
- Introduce a reusable cross-provider onboarding framework: rejected because the current release has one provider and already has sufficient provider-owned seams.
|
||||
|
||||
### D-002 — Reuse the current landing route behavior instead of creating a new onboarding register
|
||||
|
||||
**Decision**: Keep `/admin/onboarding` as the workspace-scoped entry point that either redirects to the single resumable draft or renders the existing multi-draft picker. Add compact readiness snippets to that same picker rather than introducing a second onboarding dashboard or register.
|
||||
|
||||
**Rationale**: `ManagedTenantOnboardingWizard::resolveLandingState()` already treats the landing route and the route-bound draft as one workflow surface. `OnboardingDraftPickerTest` proves that the picker already carries stage, attribution, and resume/view actions. Adding readiness context there keeps the operator in the same workflow instead of splitting decision-making across multiple pages.
|
||||
|
||||
**Alternatives considered**:
|
||||
- New onboarding-list resource or canonical register: rejected because it would duplicate draft-selection semantics and enlarge scope.
|
||||
- Landing-only summary without draft-picker enrichment: rejected because operators with multiple drafts still need draft-level readiness to choose the correct draft.
|
||||
|
||||
### D-003 — Reuse existing freshness signals; do not invent a new onboarding freshness policy
|
||||
|
||||
**Decision**: Treat readiness freshness as a composition of existing signals only:
|
||||
- `OnboardingLifecycleService` connection-change and selected-connection mismatch signals (`connection_recently_updated`, `verification_result_stale`), and
|
||||
- `TenantRequiredPermissionsViewModelBuilder::deriveFreshness()` for stored permission posture freshness (current repo rule: stale when absent or older than 30 days).
|
||||
|
||||
Do not introduce a second onboarding-specific freshness threshold, new config key, or persisted freshness state in this slice.
|
||||
|
||||
**Rationale**: The spec’s “freshness” requirement can be satisfied by existing repo truth without creating new semantics. The onboarding workflow already downgrades mismatched or changed verification evidence to action-required, while permission posture already carries a timestamp-based freshness rule used by verification-assist detail.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a new configurable verification-run age threshold for onboarding: rejected because there is no existing shared onboarding policy for it, and this slice should not create one.
|
||||
- Persist freshness state on the onboarding draft: rejected because freshness is a derived presentation concern from existing evidence timestamps and mismatch flags.
|
||||
|
||||
### D-004 — Keep permission and consent diagnostics provider-owned and secondary
|
||||
|
||||
**Decision**: Use `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to expose provider-specific consent and permission detail as secondary diagnostics beneath a platform-neutral readiness summary.
|
||||
|
||||
**Rationale**: The top-level operator question is provider-neutral: “Is this tenant ready, and what should I do next?” The exact remediation details still need Microsoft-specific wording today, and those seams already exist in the repo.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Flatten Microsoft-specific permission names into a new platform-core readiness taxonomy: rejected because it would deepen provider coupling in shared UI semantics.
|
||||
- Hide detailed permission context from onboarding entirely: rejected because the operator still needs precise remediation guidance without opening raw operation data first.
|
||||
|
||||
### D-005 — Preserve shared OperationRun link and start semantics
|
||||
|
||||
**Decision**: Any readiness CTA that opens evidence or reuses verification/bootstrap actions must stay on the current shared OperationRun and Ops-UX paths: `OperationRunLinks`, existing onboarding `tenantlessOperationRunUrl(...)`, `OperationUxPresenter`, and `ProviderOperationStartResultPresenter`.
|
||||
|
||||
**Rationale**: The workflow already starts verification as queued work and already links to canonical operation detail. This feature only changes explanation and action prioritization, not run creation semantics.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create onboarding-specific run links or custom queued messaging: rejected because it would violate the shared OperationRun UX contract.
|
||||
- Add a new readiness “refresh” operation type: rejected because the current verification and bootstrap actions already exist.
|
||||
|
||||
### D-006 — Prove the slice with fast-feedback onboarding and policy coverage only
|
||||
|
||||
**Decision**: Keep validation in fast-feedback by extending existing onboarding feature tests and policy/unit coverage. The primary proof set is `ManagedTenantOnboardingWizardTest`, `OnboardingDraftPickerTest`, `OnboardingDraftAuthorizationTest`, `TenantOnboardingSessionPolicyTest`, and `TenantRequiredPermissionsFreshnessTest`.
|
||||
|
||||
**Rationale**: The workflow is server-driven Filament/Livewire UI with existing fixtures. Feature tests can prove landing behavior, draft rendering, readiness wording, next-action precedence, and 404 vs 403 semantics without adding new browser or heavy-governance families.
|
||||
|
||||
**Alternatives considered**:
|
||||
- New browser smoke coverage as the primary proof: rejected because the feature is DB-driven and already covered by focused feature tests.
|
||||
- Unit-only coverage for a new summary builder: rejected because the main risk is integrated workflow rendering and authorization semantics, not pure transformation logic.
|
||||
297
specs/240-tenant-onboarding-readiness/spec.md
Normal file
297
specs/240-tenant-onboarding-readiness/spec.md
Normal file
@ -0,0 +1,297 @@
|
||||
# Feature Specification: Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Feature Branch**: `[240-tenant-onboarding-readiness]`
|
||||
**Created**: 2026-04-25
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Promote the roadmap-fit candidate 'Self-Service Tenant Onboarding & Connection Readiness' as a narrow, implementation-ready slice that turns the existing managed-tenant onboarding flow into an operator-facing readiness workflow. The slice should reuse existing onboarding session, provider connection, verification, and checkpoint foundations to show guided setup progress, provider connection health, permission and consent diagnostics, freshness, and concrete next actions before deeper governance workflows begin. It should explicitly reduce founder-led manual onboarding and make the current onboarding state understandable without raw run inspection. Out of scope: CRM/trial pipeline, billing, marketplace/provider expansion, autonomous remediation, or broad new onboarding persistence if existing onboarding/session/provider models are sufficient."
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Managed-tenant onboarding truth is fragmented across onboarding drafts, provider connection status, verification runs, permission posture, and checkpoint progression, so an operator cannot quickly tell whether a tenant is genuinely ready without founder guidance or raw run inspection.
|
||||
- **Today's failure**: Onboarding stalls behind generic failure or incomplete-state messaging, operators cannot reliably distinguish missing consent from missing permissions or stale verification, and founder-led walkthroughs fill the product gap.
|
||||
- **User-visible improvement**: The existing onboarding workflow becomes a guided readiness view that shows setup progress, connection health, permission and consent blockers, freshness, and one concrete next action in the same place the operator resumes onboarding.
|
||||
- **Smallest enterprise-capable version**: Reuse the current onboarding session, provider connection, verification, permission diagnostics, and checkpoint foundations to add one derived readiness summary inside the existing managed-tenant onboarding workflow, with canonical links to existing evidence and actions.
|
||||
- **Explicit non-goals**: CRM or trial pipeline work, billing or entitlement changes, provider marketplace expansion, autonomous remediation, a second onboarding persistence model, a generalized multi-provider onboarding framework, or a numeric onboarding completion score for this slice.
|
||||
- **Permanent complexity imported**: A bounded derived readiness composition for the existing onboarding surface, readiness-specific copy/state mapping on the current wizard, and focused feature tests for readiness, freshness, and authorization; no new persisted entity, capability family, or cross-domain framework.
|
||||
- **Why now**: This is the first roadmap item in the self-service foundation cluster and directly removes founder-led onboarding work while making later support diagnostic and trial flows smaller and safer.
|
||||
- **Why not local**: The pain spans onboarding checkpoint state, provider connection status, permission posture, verification freshness, and supporting evidence links; isolated copy changes in one step would preserve drift and false-green risk.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: 4 foundation-sounding theme. Defense: this slice is explicitly constrained to current onboarding routes and existing truth, with no new persistence or platform framework.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**: `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`, linked `/admin/consent/start` and `/admin/consent/callback` flows, and canonical `/admin/operations/{run}` deep links for supporting evidence
|
||||
- **Data Ownership**: `TenantOnboardingSession` remains the workspace-owned workflow record; linked `ProviderConnection`, permission/verification truth, and `OperationRun` evidence remain existing tenant-owned or run-owned records; readiness stays derived and non-persistent
|
||||
- **RBAC**: Workspace membership is mandatory. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD` gates view/update of the readiness workflow, `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL` gates destructive cancellation, linked-tenant visibility still passes tenant-bound administrative viewability checks, non-members or wrong workspace/tenant scope return 404, and members lacking capability return 403.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: N/A - this slice stays in the workspace onboarding workflow, not a canonical cross-tenant register
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership plus linked-tenant viewability checks continue to deny as not found before any readiness data or supporting operation detail is revealed
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, action links, embedded diagnostics, navigation
|
||||
- **Systems touched**: managed-tenant onboarding workflow, onboarding lifecycle snapshot/stage resolution, provider connection summary rendering, permission diagnostics, canonical operation links
|
||||
- **Existing pattern(s) to extend**: onboarding lifecycle snapshot, embedded verification-report and provider-connection summary patterns, canonical operation link helper
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Onboarding\OnboardingLifecycleService`, `App\Services\Onboarding\OnboardingDraftStageResolver`, `App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary`, and `App\Support\OperationRunLinks`
|
||||
- **Why the existing shared path is sufficient or insufficient**: These paths already compute checkpoint progression, verification state, bootstrap summaries, provider connection readiness wording, and canonical operation navigation. The slice only needs to compose them into one operator-facing readiness view.
|
||||
- **Allowed deviation and why**: Provider-specific Microsoft permission names and consent details may remain inside provider-owned diagnostic detail because exact remediation still depends on the current provider.
|
||||
- **Consistency impact**: Readiness labels, freshness cues, next-action verbs, and `Open operation`/consent link semantics must stay aligned across onboarding checkpoints and linked diagnostics.
|
||||
- **Review focus**: Verify that no second readiness taxonomy, local status palette, or page-specific operation-link language appears outside the shared paths above, and that any shared builder change stays additive and behavior-preserving for non-onboarding consumers while onboarding-specific action prioritization remains page-local.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: `App\Support\OperationRunLinks` for canonical tenantless `Open operation` links, the existing onboarding `tenantlessOperationRunUrl(...)` helper, and `App\Services\OperationRunService` as the only owner of run status/outcome transitions for any reused verification/bootstrap actions
|
||||
- **Delegated start/completion UX behaviors**: canonical `Open operation` link resolution, tenant/workspace-safe operation URL resolution, existing queued-intent behavior for reused verification starts, dedupe-or-blocked messaging for existing verification actions, and terminal lifecycle notifications through the current shared run UX path
|
||||
- **Local surface-owned behavior that remains**: readiness explanation, freshness wording, and prioritization of the one next action inside the onboarding workflow
|
||||
- **Queued DB-notification policy**: no new queued or running DB notification is introduced in this slice; any terminal notification remains explicit and central
|
||||
- **Terminal notification path**: central lifecycle mechanism already used by the underlying run flow
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: provider connection descriptors, consent and permission diagnostics, readiness copy, next-action labels, freshness messaging
|
||||
- **Neutral platform terms preserved or introduced**: provider connection, readiness, diagnostics, next action, freshness, onboarding step
|
||||
- **Provider-specific semantics retained and why**: Microsoft consent and permission names remain inside contextual diagnostic detail because the operator needs exact remediation instructions for the only supported provider.
|
||||
- **Why this does not deepen provider coupling accidentally**: No new platform-core enum, persisted state, or canonical taxonomy becomes Microsoft-shaped. Top-level readiness stays a derived composition of existing onboarding and provider connection truth.
|
||||
- **Follow-up path**: none
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
Use this section to classify UI and surface risk once. If the feature does
|
||||
not change an operator-facing surface, write `N/A - no operator-facing surface
|
||||
change` here and do not invent duplicate prose in the downstream surface tables.
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, action links, badges, embedded diagnostics | page, workflow step, derived readiness summary | no | Guided-flow surface; no new route family |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing surface,
|
||||
fill out one row per affected surface. This role is orthogonal to the
|
||||
Action Surface Class / Surface Type below. Reuse the exact surface names
|
||||
and classifications from the UI / Surface Guardrail Impact section above.
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Managed tenant onboarding workflow | Primary Decision Surface | Decide whether onboarding can proceed now or which blocker to resolve first | Current checkpoint, readiness summary, freshness, linked tenant/provider scope, and one primary next action | Full permission diff, provider-specific diagnostic detail, canonical operation detail | Primary because this is the place where the operator resumes or completes onboarding work, not a secondary diagnostic register | Follows identify → connect provider → verify access → bootstrap → activate progression inside one workflow context | Removes the need to open raw operation detail or separate provider screens just to understand what to do next |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||
fill out one row per affected surface. Declare the broad Action Surface
|
||||
Class first, then the detailed Surface Type. Keep this table in sync
|
||||
with the Decision-First Surface Role section above and avoid renaming the
|
||||
same surface a second time.
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the current blocker or continue to the next checkpoint | In-page readiness section on the current draft route | forbidden | Supporting diagnostics and collection navigation stay secondary within section reveals or footer links | Header-only destructive actions, confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant identity, provider connection summary | Onboarding / Onboarding readiness | Current checkpoint, connection health, permission/consent blocker, freshness, and next action | Guided-workflow exception; this is a wizard surface, not a CRUD resource, but it still keeps one primary decision context |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
If this feature adds a new operator-facing page or materially refactors
|
||||
one, fill out one row per affected page/surface. The contract MUST show
|
||||
how one governance case or operator task becomes decidable without
|
||||
unnecessary cross-page reconstruction.
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide whether onboarding can proceed and execute the next readiness action | Guided workflow | What is blocking this tenant from being ready, and what do I do next? | Checkpoint progress, readiness summary, provider connection health, consent/permission status, freshness of latest evidence, and the primary next action | Full permission diff, provider-specific identifiers, raw verification payloads, and canonical operation detail | workflow checkpoint, readiness outcome, freshness, execution outcome | TenantPilot only for draft progression; Microsoft tenant only when operator follows existing consent or verification actions | Continue onboarding, grant or regrant consent when needed, run or rerun verification when needed, open operation | Cancel onboarding, delete draft |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
Fill this section if the feature introduces any of the following:
|
||||
- a new source of truth
|
||||
- a new persisted entity, table, or artifact
|
||||
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||
- a new enum, status family, reason code family, or lifecycle category
|
||||
- a new cross-domain UI framework, taxonomy, or classification system
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: no
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators cannot trust or explain onboarding readiness from the current workflow without opening raw diagnostics or asking the founder.
|
||||
- **Existing structure is insufficient because**: The current truth exists, but it is spread across workflow state, provider connection state, verification evidence, and permission posture with no single operator-facing readiness composition.
|
||||
- **Narrowest correct implementation**: Compose existing onboarding lifecycle, provider connection summary, permission diagnostics, and operation evidence inside the current onboarding workflow, with no new persistence or platform-wide framework.
|
||||
- **Ownership cost**: A small amount of readiness mapping and focused feature-test coverage must be maintained, but there is no new stored truth, capability family, or cross-domain taxonomy.
|
||||
- **Alternative intentionally rejected**: A new onboarding-readiness table/state machine or generic provider-onboarding framework was rejected as overproduction for the current release.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.
|
||||
|
||||
- **Test purpose / classification**: Feature, Unit
|
||||
- **Validation lane(s)**: fast-feedback
|
||||
- **Why this classification and these lanes are sufficient**: Livewire/Filament feature tests prove readiness mapping, next-action guidance, retained action behavior, freshness truth, and deny semantics on the onboarding surface, while targeted unit and policy coverage proves reused permission-freshness and authorization seams without browser automation or heavy-governance breadth.
|
||||
- **New or expanded test families**: extend onboarding feature coverage plus targeted unit and policy coverage for onboarding authorization and reused permission-freshness behavior only
|
||||
- **Fixture / helper cost impact**: workspace membership, onboarding draft, linked tenant, provider connection, and operation-run states are required; avoid new browser harnesses, new heavy fixtures, or provider mocks beyond the current readiness scenarios
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament
|
||||
- **Standard-native relief or required special coverage**: ordinary onboarding feature coverage plus focused policy and freshness regressions only
|
||||
- **Reviewer handoff**: Confirm that negative authorization still distinguishes 404 vs 403 correctly, retained start and destructive actions keep their existing guardrails, no browser-only proof was added for a server-driven workflow, and proof commands remain limited to onboarding, onboarding-authorization, and freshness-policy coverage.
|
||||
- **Budget / baseline / trend impact**: none expected beyond ordinary feature-local runtime
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1)
|
||||
|
||||
An authorized workspace operator opens an onboarding draft and immediately sees the current setup step, whether the tenant is actually ready, what evidence is fresh or stale, and what the next action is without needing to inspect raw operations first.
|
||||
|
||||
**Why this priority**: This is the smallest slice that directly removes founder-led manual walkthroughs and turns the current onboarding state into a self-explanatory workflow.
|
||||
|
||||
**Independent Test**: Seed onboarding drafts in multiple lifecycle states with linked provider connections and verification outcomes, open the onboarding routes, and confirm the workflow shows checkpoint progress, readiness, freshness, and one primary next action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized operator opens an existing onboarding draft with a linked provider connection, **When** the workflow loads, **Then** it shows the current checkpoint, readiness summary, freshness, and one primary next action without requiring raw run inspection.
|
||||
2. **Given** an operator opens the onboarding landing page while resumable drafts already exist, **When** the landing context renders, **Then** it shows enough progress and blocker context to choose the correct draft to resume.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2)
|
||||
|
||||
An authorized operator can tell whether onboarding is blocked by missing consent, missing permissions, disabled or unhealthy provider connection state, or an unresolved verification result, and receives explicit guidance for the next corrective action.
|
||||
|
||||
**Why this priority**: Trustworthy blocker classification is the part that prevents false-green readiness and generic error handling from turning into support work.
|
||||
|
||||
**Independent Test**: Seed drafts with missing consent, permission gaps, disabled connections, and blocked verification outcomes, then confirm each scenario shows distinct blocker language and the appropriate next action.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** consent is missing or revoked, **When** the operator opens the readiness workflow, **Then** the blocker is labeled explicitly and the primary action points to the consent follow-up flow rather than generic failure copy.
|
||||
2. **Given** required permissions are missing or insufficient, **When** the operator opens the readiness workflow, **Then** the operator sees explicit permission diagnostics in provider-owned terms and a concrete remediation path.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3)
|
||||
|
||||
An authorized operator can tell whether supporting verification or bootstrap evidence is current enough to trust, and can open the canonical supporting operation when more detail is necessary.
|
||||
|
||||
**Why this priority**: Freshness and evidence linkage prevent the workflow from becoming a false-ready facade while still keeping raw diagnostics secondary.
|
||||
|
||||
**Independent Test**: Seed matching, stale, and mismatched verification runs for the same draft, then confirm the workflow surfaces freshness truth and a single canonical operation link only when evidence exists.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the latest verification run is stale or tied to a different selected provider connection, **When** the operator opens the workflow, **Then** readiness falls back to a non-ready state with explicit freshness or mismatch guidance instead of reporting ready.
|
||||
2. **Given** a supporting verification or bootstrap run exists, **When** the operator wants more detail, **Then** the workflow offers one canonical `Open operation` link that respects authorization.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- An onboarding draft exists before any tenant is linked; the workflow must show identity and connection prerequisites without implying the tenant is ready.
|
||||
- The latest verification run belongs to a different provider connection than the currently selected one; the workflow must treat it as mismatched evidence and require attention.
|
||||
- A provider connection was previously healthy but is now disabled or its consent was revoked; the workflow must not keep showing the older ready state.
|
||||
- Health or verification evidence is older than the accepted freshness window; the workflow must show stale evidence and a rerun or refresh next action.
|
||||
- An actor still has workspace membership but no longer has linked-tenant entitlement; the workflow must return 404 rather than partial readiness data.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce a new Microsoft Graph domain, a new tenant-changing workflow, or a new queued-work family. It reuses existing onboarding, consent, verification, and bootstrap actions. Any reused verification or bootstrap action continues to rely on the current contract-registry-backed provider execution, existing safety gates, tenant isolation, `OperationRun` observability, and onboarding audit events.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice is derived-only. It must not add a new persisted readiness model, a new cross-provider onboarding abstraction, or a new canonical readiness state family. Existing onboarding lifecycle, provider consent/verification states, and supporting evidence remain the source of truth.
|
||||
|
||||
**Constitution alignment (XCUT-001):** The slice extends existing onboarding lifecycle composition, provider connection summary rendering, and canonical operation-link paths. No parallel onboarding health language or page-local operation-link system may appear.
|
||||
|
||||
**Constitution alignment (PROV-001):** Top-level readiness language stays platform-neutral, while Microsoft-specific consent and permission details remain contextual and provider-owned. The feature must not turn Microsoft permission or consent semantics into new platform-core truth.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Implementation must stay in fast-feedback feature coverage, keep fixtures opt-in and onboarding-local, and avoid creating a new heavy-governance or browser family for a server-driven readiness workflow.
|
||||
|
||||
**Constitution alignment (OPS-UX):** If the workflow exposes existing verification or bootstrap start actions, those actions must continue to use the existing queued-intent feedback contract, canonical `Open operation` links, central `OperationRunService` status ownership, and no new queued or running DB notifications.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** Any `OperationRun` deep link or reused start action in this workflow must delegate URL resolution and run-link behavior to the shared operation-link path rather than page-local URLs.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** Non-members, wrong-workspace actors, and actors lacking linked-tenant entitlement continue to receive 404. Members missing onboarding capability receive 403 on protected workflow actions. Existing destructive onboarding actions remain confirmation-protected and server-authorized.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Consent and verification badges remain driven by the central badge domains already used by provider connection summaries; no page-local color or label mapping is introduced.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The workflow continues to use native Filament sections, cards, badges, and shared primitives. The slice may reorganize emphasis, but it must not replace the workflow with custom fake-native controls.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels remain grounded in onboarding vocabulary such as `Continue onboarding`, `Grant consent`, `Run verification`, `Open operation`, `Cancel onboarding`, and `Delete draft`. Implementation-first language stays in secondary diagnostics only.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The onboarding workflow remains the one primary decision surface for onboarding readiness. It must present enough truth to decide the next action without forcing cross-page reconstruction from raw operations.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This guided workflow remains a bounded exception to list/detail table rules, but it must still keep one primary next action, preserve header-action discipline, keep destructive actions separated by risk, and avoid mixed catch-all action groups.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, mutation, and destructive onboarding actions remain visibly separated. Secondary diagnostics do not compete with the primary next action.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** The default-visible content must stay operator-first, with raw provider detail and full operation diagnostics deliberately secondary. Mutation scope remains explicit when the workflow points to provider actions.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Any new readiness wording remains a thin adapter over existing onboarding, provider connection, and verification truth. Tests must prove business consequences such as blocker classification, freshness, and authorization rather than a thin presentation layer alone.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The managed-tenant onboarding page remains an explicit guided-workflow exception rather than a CRUD resource. Action inventory must still preserve one primary next action, avoid redundant inspect affordances, and keep destructive actions gated and confirmation-protected.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The onboarding page continues to use native sections and cards, puts default-visible readiness truth ahead of diagnostics, and keeps empty/start states focused on one clear CTA.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST compose onboarding readiness from the existing onboarding session, provider connection state, verification evidence, permission diagnostics, and checkpoint progression instead of introducing a second onboarding-readiness persistence model.
|
||||
- **FR-002**: The system MUST show the current checkpoint, completed progress, derived readiness summary, freshness, and one primary next action on the existing onboarding landing surfaces: the route-bound draft at `/admin/onboarding/{onboardingDraft}`, and the `/admin/onboarding` landing experience when multiple resumable drafts exist. If `/admin/onboarding` resolves directly to a single resumable draft, the existing redirect behavior remains in place.
|
||||
- **FR-003**: The workflow MUST distinguish operator-visible conditions for not started, in progress, blocked, needs attention, stale evidence, and ready-to-proceed scenarios instead of collapsing them into generic incomplete-state copy.
|
||||
- **FR-004**: When a linked provider connection exists, the workflow MUST reuse the existing provider connection summary path to show consent state, verification state, contextual identity details, and readiness wording.
|
||||
- **FR-005**: Missing or insufficient permissions MUST produce explicit provider-owned diagnostics and a concrete next action, while top-level readiness vocabulary remains platform-neutral.
|
||||
- **FR-006**: The workflow MUST display freshness for the latest verification or health evidence using the existing permission freshness threshold (currently 30 days) plus selected-provider-connection mismatch signals, and MUST mark stale or mismatched evidence as needing attention rather than ready.
|
||||
- **FR-007**: Supporting verification or bootstrap evidence MUST deep-link through the canonical tenantless operation route and MUST expose only one primary `Open operation` affordance per supporting record.
|
||||
- **FR-008**: If the workflow exposes existing start or rerun actions for consent, verification, or bootstrap follow-up, those actions MUST preserve existing authorization, audit, dedupe, and shared Ops-UX behavior and MUST NOT create new run-start paths.
|
||||
- **FR-009**: Non-members, wrong-workspace actors, and actors without linked-tenant entitlement MUST receive 404 for readiness routes and supporting evidence routes; entitled members lacking the required onboarding capability MUST receive 403 on protected actions.
|
||||
- **FR-010**: Existing destructive onboarding actions retained in the workflow, including cancel or delete draft behavior, MUST remain confirmation-protected and capability-gated, and this slice MUST NOT introduce new destructive behavior.
|
||||
- **FR-011**: The workflow MUST reuse existing onboarding lifecycle and verification truth and MUST NOT introduce a new persisted readiness enum, new onboarding table, or generalized provider-onboarding framework.
|
||||
- **FR-012**: The readiness outcome and next-action summary MUST remain structured as derived onboarding truth so later support diagnostic and trial/demo work can consume the same underlying composition, but cross-surface reuse beyond onboarding is explicitly deferred from this slice and MUST NOT introduce a new shared abstraction or second source of truth now.
|
||||
- **FR-013**: Microsoft-specific consent and permission details MUST stay contextual and secondary to the operator-first readiness summary.
|
||||
- **FR-014**: When no supporting verification or diagnostic evidence exists yet, the workflow MUST state that the check has not run yet and point to the correct next action rather than exposing empty diagnostics.
|
||||
- **FR-015**: The workflow MUST continue to use native Filament sections, cards, and shared badge semantics instead of page-local status cards or custom color logic.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Managed tenant onboarding workflow | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing `Start onboarding` and draft-selection affordances remain; readiness-specific header additions stay non-destructive | N/A - guided workflow page, not a list table | Draft-picker context may expose `Resume onboarding`; readiness panels expose one primary `Open operation` link when evidence exists | none | `Start onboarding` on empty/start state | Existing `Cancel onboarding` and `Delete draft` remain in header placements by risk; `Open operation` and `Open tenant` stay contextual in-section links when legitimate | Existing wizard next/back/save/cancel behavior remains | yes for existing draft lifecycle mutations and reused start actions | Guided-workflow exception. Action Surface Contract remains satisfied through one primary next action in-page; destructive actions stay confirmation-protected and capability-gated. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant Onboarding Session**: The existing workspace-scoped workflow record that tracks onboarding checkpoint progression, resumability, and optional linkage to a tenant.
|
||||
- **Provider Connection**: The existing tenant-owned provider access record whose consent state, verification state, target-scope summary, and readiness wording inform onboarding readiness.
|
||||
- **Onboarding Readiness Summary**: A derived, non-persistent operator view composed from onboarding lifecycle, provider connection truth, permission diagnostics, freshness, supporting operation evidence, and the next recommended action.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In each in-scope onboarding scenario, an authorized operator can identify the current blocker and next action from the onboarding workflow in 30 seconds or less without opening raw operation detail first.
|
||||
- **SC-002**: In scripted scenarios covering missing consent, permission gaps, disabled or unhealthy provider connection state, stale verification, verification blocked, and ready-for-activation, 100% of rendered states show distinct operator-readable outcomes rather than generic incomplete-state messaging.
|
||||
- **SC-003**: When supporting verification or bootstrap evidence exists, the operator can reach the canonical supporting operation from the onboarding workflow in one interaction.
|
||||
- **SC-004**: In negative authorization scenarios for wrong workspace, non-member, or non-entitled linked-tenant access, 100% of requests hide onboarding readiness data behind 404 responses, while capability-denied members receive 403 on protected actions.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing onboarding session, provider connection, verification, and permission-posture foundations are sufficient for the first readiness slice.
|
||||
- The current provider remains Microsoft-first, but provider-specific details stay contextual rather than becoming new platform-core truth.
|
||||
- Later support diagnostic and trial/demo flows are expected to consume the same derived readiness truth, but any reuse beyond onboarding is deferred from this slice and must not force a new abstraction or second onboarding state model now.
|
||||
194
specs/240-tenant-onboarding-readiness/tasks.md
Normal file
194
specs/240-tenant-onboarding-readiness/tasks.md
Normal file
@ -0,0 +1,194 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: Self-Service Tenant Onboarding & Connection Readiness
|
||||
|
||||
**Input**: Design documents from `/specs/240-tenant-onboarding-readiness/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md, checklists/requirements.md
|
||||
|
||||
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the `fast-feedback` lane using the onboarding, authorization, policy, and freshness suites listed in `specs/240-tenant-onboarding-readiness/quickstart.md`.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane assignment: `fast-feedback` is the narrowest sufficient proof for this slice.
|
||||
- No new browser or heavy-governance family should be introduced; keep any new fixtures onboarding-local and cheap by default.
|
||||
- Surface profile: `standard-native-filament` relief applies unless implementation widens the existing wizard/picker surface beyond the current plan.
|
||||
- If implementation leaves a bounded provider-boundary hotspot or widens lane cost, record that outcome in `specs/240-tenant-onboarding-readiness/plan.md` and `specs/240-tenant-onboarding-readiness/quickstart.md` before merge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Confirm the current onboarding baseline and pin the feature inputs before runtime edits begin.
|
||||
|
||||
- [X] T001 Run the baseline fast-feedback onboarding suites listed for this feature in specs/240-tenant-onboarding-readiness/quickstart.md (specs/240-tenant-onboarding-readiness/quickstart.md, apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php, apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php, apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php)
|
||||
- [X] T002 [P] Review the derived-only readiness contract, next-action precedence, and provider-boundary constraints before editing runtime files (specs/240-tenant-onboarding-readiness/spec.md, specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/research.md, specs/240-tenant-onboarding-readiness/data-model.md, specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared derived-readiness seams that every story depends on without adding persistence, enums, or a new onboarding framework.
|
||||
|
||||
**Critical**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T003 Create a presentation-only readiness payload shell on the existing onboarding page without adding new persistence or readiness enums (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T004 [P] Thread route-bound authorization and canonical operation-link seams through that readiness payload so later story work stays inside existing workspace and tenant boundaries (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php, apps/platform/app/Support/OperationRunLinks.php)
|
||||
- [X] T005 [P] Reuse the existing provider summary, verification assist, and permission freshness builders as the only readiness inputs so provider-specific detail stays secondary and contextual, and keep any shared-builder extension additive and behavior-preserving for non-onboarding consumers (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php)
|
||||
|
||||
**Checkpoint**: Foundation ready - the onboarding wizard has one derived-readiness seam and later stories can extend it without creating a second source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1)
|
||||
|
||||
**Goal**: An authorized workspace operator can open onboarding and immediately understand checkpoint progress, current readiness, freshness, and the one next action on both the landing route and the route-bound draft.
|
||||
|
||||
**Independent Test**: Seed resumable drafts in different lifecycle states, open `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}`, and confirm the workflow shows progress, readiness, freshness, and one primary next action without raw operation inspection.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T006 [P] [US1] Add route-bound readiness feature coverage for checkpoint progress, readiness summary, freshness cues, explicit "check has not run yet" guidance, and one primary next action in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||
- [X] T007 [P] [US1] Add landing-route feature coverage for compact readiness snippets on multiple drafts while preserving the existing single-draft redirect behavior in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php)
|
||||
- [X] T008 [P] [US1] Extend readiness-route authorization coverage so non-members and wrong-workspace actors stay 404 while capability-denied members stay 403 in apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php)
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T009 [US1] Render the derived readiness summary, checkpoint progress, freshness note, explicit "check has not run yet" guidance, and primary next-action slot on the route-bound onboarding draft view in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T010 [US1] Surface compact readiness context on the multi-draft picker without changing the landing route contract, resume flow, or view-summary affordances in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T011 [US1] Keep draft and landing progress labels aligned through the shared lifecycle and stage resolvers instead of page-local state maps in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php)
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when operators can choose or resume the correct draft from readiness context alone.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2)
|
||||
|
||||
**Goal**: An authorized operator can tell whether onboarding is blocked by consent, permissions, or unhealthy provider connection state, and gets one concrete remediation path without introducing provider-specific top-level taxonomy.
|
||||
|
||||
**Independent Test**: Seed drafts with missing consent, revoked consent, permission gaps, and disabled connection states, then confirm each scenario renders distinct blocker language and the correct next action.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T012 [P] [US2] Add blocker-matrix feature coverage for missing consent, revoked consent, disabled connection, and blocked verification scenarios in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||
- [X] T013 [P] [US2] Add feature coverage proving permission-gap diagnostics stay provider-owned while the top-level readiness summary stays platform-neutral in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||
- [X] T014 [P] [US2] Extend onboarding capability and linked-tenant entitlement policy coverage used by readiness blocker actions in apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php (apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php)
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T015 [US2] Reuse provider connection summary state to classify consent and connection-health blockers without page-local badge or color mappings, and preserve current non-onboarding summary behavior unless an additive onboarding field is strictly required, in apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T016 [US2] Reuse verification-assist and required-permissions builders to expose provider-owned permission diagnostics, blocked-verification guidance, and remediation links inside the onboarding workflow, keeping onboarding-specific action prioritization local and preserving existing non-onboarding outputs unless an additive field is strictly required, in apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T017 [US2] Enforce next-action precedence for consent, permission, and blocked-verification blockers while keeping top-level readiness wording provider-neutral in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when blocker classification is explicit and remediation guidance is concrete without leaking Microsoft-specific terms into the top-level readiness contract.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3)
|
||||
|
||||
**Goal**: An authorized operator can tell whether verification evidence is fresh enough to trust and can open the canonical supporting operation when deeper detail is required.
|
||||
|
||||
**Independent Test**: Seed matching, stale, and mismatched verification evidence for the same draft, then confirm readiness downgrades appropriately and only canonical `Open operation` links are rendered when evidence exists.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T018 [P] [US3] Add feature coverage for stale evidence, selected-connection mismatch, and canonical `Open operation` link rendering on the route-bound draft in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||
- [X] T019 [P] [US3] Add landing-picker feature coverage for stale or mismatched freshness cues across multiple drafts in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php)
|
||||
- [X] T020 [P] [US3] Extend freshness boundary coverage for missing and existing 30-day permission-threshold edge refresh timestamps used by readiness in apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php (apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php)
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T021 [US3] Reuse connection-change and selected-connection-mismatch signals to downgrade readiness and surface freshness guidance on the onboarding page in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T022 [US3] Route supporting verification and bootstrap evidence through canonical tenantless operation links only in apps/platform/app/Support/OperationRunLinks.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/OperationRunLinks.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
- [X] T023 [US3] Surface stale and mismatched evidence state on both the route-bound draft and the landing picker without adding persisted readiness flags in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when stale or mismatched evidence prevents false-ready UI and canonical evidence links remain available for legitimate detail views.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final validation, formatting, and feature-local close-out for this readiness slice.
|
||||
|
||||
- [X] T024 [P] Add retained-action regression coverage proving reused consent, verification, and bootstrap start or rerun affordances still preserve shared Ops-UX, dedupe, canonical `Open operation` links, and audit behavior in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||
- [X] T025 [P] Add retained destructive-action regression coverage proving cancel or delete draft affordances remain confirmation-protected and capability-gated in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php and apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php)
|
||||
- [X] T026 [P] Run Laravel Pint on touched PHP files through Sail before merge (apps/platform/vendor/bin/sail)
|
||||
- [X] T027 Run the targeted fast-feedback validation suite listed in specs/240-tenant-onboarding-readiness/quickstart.md after implementation is complete (specs/240-tenant-onboarding-readiness/quickstart.md)
|
||||
- [X] T028 Record the final guardrail close-out, fast-feedback proof result, and any `document-in-feature` versus `follow-up-spec` decision for remaining provider-boundary or lane-cost notes in specs/240-tenant-onboarding-readiness/plan.md and specs/240-tenant-onboarding-readiness/quickstart.md, including the explicit defer of numeric completion score and cross-surface readiness reuse (specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/quickstart.md)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) starts immediately.
|
||||
- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories.
|
||||
- Phase 3 (US1) depends on Phase 2 and establishes the MVP readiness surface.
|
||||
- Phase 4 (US2) depends on Phase 2 and should follow US1 in practice because both stories change the same wizard and wizard test suite.
|
||||
- Phase 5 (US3) depends on Phase 2 and is safest after US1 because freshness and evidence rendering extend the same readiness payload.
|
||||
- Phase 6 (Polish) depends on every implemented story.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP and the first independently shippable increment.
|
||||
- US2 reuses the US1 readiness surface but remains independently testable through blocker-specific scenarios.
|
||||
- US3 reuses the same readiness surface and remains independently testable through freshness and evidence scenarios.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and ensure it fails before implementation.
|
||||
- Complete service or builder changes before the final wizard rendering pass when both are required.
|
||||
- Re-run the narrowest affected fast-feedback suite after each story checkpoint before moving to the next story.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
### Phase 1
|
||||
|
||||
- T001 and T002 can run in parallel if one person captures the baseline while another cross-checks the feature artifacts.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- T004 and T005 can run in parallel after T003 defines the readiness payload shell.
|
||||
|
||||
### User Story 1
|
||||
|
||||
- T006, T007, and T008 can run in parallel.
|
||||
- T009 and T011 can overlap once the shared readiness payload from Phase 2 is in place.
|
||||
|
||||
### User Story 2
|
||||
|
||||
- T012, T013, and T014 can run in parallel.
|
||||
- T015 and T016 can overlap before T017 finalizes next-action precedence.
|
||||
|
||||
### User Story 3
|
||||
|
||||
- T018, T019, and T020 can run in parallel.
|
||||
- T021 and T022 can overlap before T023 finalizes the shared stale-evidence presentation.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1.
|
||||
2. Complete Phase 2.
|
||||
3. Complete Phase 3 (US1).
|
||||
4. Re-run the targeted US1 fast-feedback suites and stop for review.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver US1 to make onboarding state understandable on landing and draft routes.
|
||||
2. Add US2 to classify consent and permission blockers without broadening the provider boundary.
|
||||
3. Add US3 to harden freshness and evidence trust so the readiness surface cannot go false-green.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Finish Phase 2 together before splitting work.
|
||||
2. Parallelize test authoring inside each story.
|
||||
3. Sequence wizard-file merges story-by-story because all three stories converge on apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and its onboarding feature suites.
|
||||
35
specs/241-support-diagnostic-pack/checklists/requirements.md
Normal file
35
specs/241-support-diagnostic-pack/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Support Diagnostic Pack
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-25
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated on 2026-04-25 against the repo template, constitution, and spec approval rubric.
|
||||
- The first slice stays explicitly narrow: tenant context and OperationRun context only, derived references only, redaction and access checks first, no new support-pack persistence, and no external helpdesk or AI behavior.
|
||||
@ -0,0 +1,284 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin — Support Diagnostic Bundle (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual HTTP contract for the first support-diagnostics slice.
|
||||
|
||||
NOTE: These flows are implemented as Filament (Livewire) header actions on
|
||||
existing pages. The exact Livewire request payload is not part of this
|
||||
contract; this file captures the user-visible surfaces, authorization
|
||||
semantics, and the derived bundle view model.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/t/{tenant}/support-diagnostics/actions/open:
|
||||
post:
|
||||
summary: Open support diagnostics from the tenant dashboard
|
||||
description: |
|
||||
Read-only support-diagnostic action on the existing tenant dashboard.
|
||||
|
||||
Authorization:
|
||||
- Workspace non-member or non-entitled tenant actor: 404
|
||||
- Entitled tenant member without `support_diagnostics.view`: 403
|
||||
- Authorized actor: 200 with a derived, redacted support bundle
|
||||
|
||||
Behavior:
|
||||
- No persisted support pack is created
|
||||
- Rendering stays DB-only for this slice and performs no outbound HTTP
|
||||
- No provider-backed work, queue work, or new `OperationRun` is dispatched
|
||||
- No raw provider payload or unrestricted log export is returned
|
||||
- One redacted audit entry is recorded for bundle-open activity
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Filament tenancy slug (`tenants.external_id`)
|
||||
responses:
|
||||
'200':
|
||||
description: Support diagnostic preview rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SupportDiagnosticBundleView'
|
||||
'403':
|
||||
description: Forbidden (entitled tenant member lacks support-diagnostics capability)
|
||||
'404':
|
||||
description: Not found (wrong workspace, non-member, or missing tenant entitlement)
|
||||
/operations/{run}/support-diagnostics/actions/open:
|
||||
post:
|
||||
summary: Open support diagnostics from the canonical operation detail page
|
||||
description: |
|
||||
Read-only support-diagnostic action on the canonical tenantless operation
|
||||
detail viewer.
|
||||
|
||||
Authorization:
|
||||
- Inaccessible run under `OperationRunPolicy`: 404
|
||||
- Authorized member without `support_diagnostics.view`: 403
|
||||
- Authorized actor: 200 with a derived, redacted support bundle
|
||||
|
||||
Behavior:
|
||||
- The action is only available when the canonical run detail resolves to an entitled tenant scope
|
||||
- Reuses existing humanized operation explanation and canonical links
|
||||
- Rendering stays DB-only for this slice and performs no outbound HTTP
|
||||
- Does not create, update, or complete an `OperationRun`
|
||||
- Does not dispatch provider-backed work, queue work, or queued operation UX side effects
|
||||
- Records one redacted audit entry for bundle-open activity
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Internal `operation_runs.id`
|
||||
responses:
|
||||
'200':
|
||||
description: Support diagnostic preview rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
x-logical-view-model:
|
||||
$ref: '#/components/schemas/SupportDiagnosticBundleView'
|
||||
'403':
|
||||
description: Forbidden (authorized member lacks support-diagnostics capability)
|
||||
'404':
|
||||
description: Not found (run inaccessible under workspace or tenant scope)
|
||||
components:
|
||||
schemas:
|
||||
SupportDiagnosticBundleView:
|
||||
type: object
|
||||
required:
|
||||
- context
|
||||
- summary
|
||||
- sections
|
||||
- redaction
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SupportDiagnosticContext'
|
||||
summary:
|
||||
$ref: '#/components/schemas/SupportDiagnosticSummary'
|
||||
sections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SupportDiagnosticSection'
|
||||
redaction:
|
||||
$ref: '#/components/schemas/SupportDiagnosticRedaction'
|
||||
notes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
audit:
|
||||
type: object
|
||||
required:
|
||||
- action
|
||||
- outcome
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
outcome:
|
||||
type: string
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
SupportDiagnosticContext:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- workspace_id
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [tenant, operation_run]
|
||||
workspace_id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
tenant_label:
|
||||
type: string
|
||||
nullable: true
|
||||
workspace_label:
|
||||
type: string
|
||||
nullable: true
|
||||
SupportDiagnosticSummary:
|
||||
type: object
|
||||
required:
|
||||
- headline
|
||||
- dominant_issue
|
||||
- freshness_state
|
||||
- redaction_note
|
||||
properties:
|
||||
headline:
|
||||
type: string
|
||||
dominant_issue:
|
||||
type: string
|
||||
freshness_state:
|
||||
type: string
|
||||
enum: [fresh, stale, mixed, missing_context]
|
||||
completeness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
redaction_note:
|
||||
type: string
|
||||
generated_from:
|
||||
type: string
|
||||
enum: [derived_existing_truth]
|
||||
SupportDiagnosticSection:
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- label
|
||||
- availability
|
||||
- summary
|
||||
- references
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
enum:
|
||||
- overview
|
||||
- provider_connection
|
||||
- operation_context
|
||||
- findings
|
||||
- stored_reports
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- audit_history
|
||||
label:
|
||||
type: string
|
||||
availability:
|
||||
type: string
|
||||
enum: [available, missing, stale, inaccessible, redacted]
|
||||
summary:
|
||||
type: string
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
references:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SupportDiagnosticReference'
|
||||
redaction_markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedactionMarker'
|
||||
SupportDiagnosticReference:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- label
|
||||
- action_label
|
||||
- availability
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- tenant
|
||||
- operation_run
|
||||
- provider_connection
|
||||
- finding
|
||||
- stored_report
|
||||
- tenant_review
|
||||
- review_pack
|
||||
- audit_log
|
||||
record_id:
|
||||
type: string
|
||||
nullable: true
|
||||
label:
|
||||
type: string
|
||||
action_label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
nullable: true
|
||||
availability:
|
||||
type: string
|
||||
enum: [available, missing, inaccessible]
|
||||
freshness_note:
|
||||
type: string
|
||||
nullable: true
|
||||
access_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
SupportDiagnosticRedaction:
|
||||
type: object
|
||||
required:
|
||||
- mode
|
||||
- markers
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [default_redacted]
|
||||
markers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedactionMarker'
|
||||
RedactionMarker:
|
||||
type: object
|
||||
required:
|
||||
- reason
|
||||
- replacement_text
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
nullable: true
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- secret
|
||||
- credential
|
||||
- raw_payload
|
||||
- restricted_log_excerpt
|
||||
- inaccessible_record
|
||||
replacement_text:
|
||||
type: string
|
||||
294
specs/241-support-diagnostic-pack/data-model.md
Normal file
294
specs/241-support-diagnostic-pack/data-model.md
Normal file
@ -0,0 +1,294 @@
|
||||
# Data Model — Support Diagnostic Pack
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
No new persistent tables are required for the first support-diagnostics slice. The bundle is computed at request time from existing canonical records.
|
||||
|
||||
## Existing Canonical Entities Reused
|
||||
|
||||
### Workspace (`workspaces`)
|
||||
|
||||
**Purpose**: Primary admin-plane isolation boundary and audit scope for every support-diagnostic bundle.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `name`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the workspace scope label.
|
||||
- Anchors workspace-membership checks.
|
||||
- Owns audit-log scope for bundle-open activity.
|
||||
|
||||
### Tenant (`tenants`)
|
||||
|
||||
**Purpose**: Tenant-plane scope boundary and the canonical subject for tenant-context support diagnostics.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `external_id`
|
||||
- `name`
|
||||
- `status`
|
||||
|
||||
**Bundle use**:
|
||||
- Acts as the primary subject for tenant-context bundles.
|
||||
- Supplies tenant identity and tenant authorization scope.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
|
||||
**Purpose**: Canonical execution truth and the primary subject for operation-context support diagnostics.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id` (nullable)
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `summary_counts`
|
||||
- `context`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `user()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the primary execution summary.
|
||||
- Carries run-bound reference ids such as `provider_connection_id` and artifact references in `context`.
|
||||
- Reuses existing humanized run explanation and canonical run URLs.
|
||||
|
||||
### ProviderConnection (`provider_connections`)
|
||||
|
||||
**Purpose**: Canonical provider readiness and connection state for the tenant or run context.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `provider`
|
||||
- `connection_type`
|
||||
- `consent_status`
|
||||
- `verification_status`
|
||||
- `last_error_reason_code`
|
||||
- `last_error_message`
|
||||
- `is_default`
|
||||
- `is_enabled`
|
||||
- `last_health_check_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `credential()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies provider readiness summary, translated provider failure reasons, and target-scope detail.
|
||||
- Never contributes raw credential payloads or secrets to the bundle.
|
||||
|
||||
### Finding (`findings`)
|
||||
|
||||
**Purpose**: Canonical drift or permission posture issues that may explain current support pressure.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `baseline_operation_run_id`
|
||||
- `current_operation_run_id`
|
||||
- `due_at`
|
||||
- `last_seen_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `baselineRun()`
|
||||
- `currentRun()`
|
||||
- `findingException()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies prioritized open or recent findings relevant to the current tenant or run.
|
||||
- Contributes summary and freshness cues only; finding detail remains on canonical pages.
|
||||
|
||||
### StoredReport (`stored_reports`)
|
||||
|
||||
**Purpose**: Canonical report/evidence truth for report identity and freshness.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `report_type`
|
||||
- `fingerprint`
|
||||
- `previous_fingerprint`
|
||||
- `payload`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies report identity, report type, and freshness/continuity cues.
|
||||
- The bundle must not expose the full stored report payload by default.
|
||||
|
||||
### TenantReview (`tenant_reviews`)
|
||||
|
||||
**Purpose**: Canonical tenant review state and review-level summary for governance follow-up.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `operation_run_id`
|
||||
- `status`
|
||||
- `completeness_state`
|
||||
- `summary`
|
||||
- `generated_at`
|
||||
- `current_export_review_pack_id`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
- `operationRun()`
|
||||
- `reviewPacks()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies current review status, completeness, blockers, and canonical review references when review truth is relevant.
|
||||
|
||||
### ReviewPack (`review_packs`)
|
||||
|
||||
**Purpose**: Canonical review export/package truth when a tenant review already has a pack.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `operation_run_id`
|
||||
- `tenant_review_id`
|
||||
- `status`
|
||||
- `summary`
|
||||
- `generated_at`
|
||||
- `expires_at`
|
||||
- `file_size`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `workspace()`
|
||||
- `tenant()`
|
||||
- `operationRun()`
|
||||
- `tenantReview()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies pack availability, readiness, and expiry cues.
|
||||
- The bundle links to the canonical pack viewer instead of reproducing pack content.
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
|
||||
**Purpose**: Canonical audit trail for workspace-, tenant-, and operation-related events.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `action`
|
||||
- `resource_type`
|
||||
- `resource_id`
|
||||
- `target_label`
|
||||
- `metadata`
|
||||
- `outcome`
|
||||
- `operation_run_id`
|
||||
- `recorded_at`
|
||||
|
||||
**Relationships (existing)**:
|
||||
- `tenant()`
|
||||
- `workspace()`
|
||||
- `operationRun()`
|
||||
|
||||
**Bundle use**:
|
||||
- Supplies the most relevant authorized audit references for the current tenant or run.
|
||||
- Also records bundle-open activity with redacted metadata only.
|
||||
|
||||
## Derived Runtime Entities
|
||||
|
||||
### SupportDiagnosticBundle (computed, not persisted)
|
||||
|
||||
**Purpose**: One machine-readable, redacted support-safe envelope for either a tenant context or an operation-run context.
|
||||
|
||||
**Proposed shape (runtime array / view-model)**:
|
||||
- `context_type` — `tenant` or `operation_run`
|
||||
- `workspace` — workspace reference and label
|
||||
- `tenant` — tenant reference when applicable
|
||||
- `operation_run` — primary run reference when applicable
|
||||
- `headline` — dominant support summary
|
||||
- `dominant_issue` — translated blocker or issue statement
|
||||
- `freshness_state` — derived cue such as `fresh`, `stale`, `mixed`, or `missing_context`
|
||||
- `redaction_mode` — fixed first-slice mode: default-redacted
|
||||
- `sections` — ordered list of section payloads
|
||||
- `notes` — explicit redaction, completeness, or degradation notes
|
||||
|
||||
**Relationships**:
|
||||
- 1 workspace
|
||||
- 0..1 tenant
|
||||
- 0..1 primary operation run
|
||||
- 0..1 provider connection section
|
||||
- 0..n finding references
|
||||
- 0..n stored report references
|
||||
- 0..1 tenant review reference
|
||||
- 0..1 review pack reference
|
||||
- 0..n audit references
|
||||
|
||||
### SupportDiagnosticSection (computed, not persisted)
|
||||
|
||||
**Purpose**: One deterministic section inside the bundle.
|
||||
|
||||
**Proposed shape**:
|
||||
- `key` — fixed section key such as `provider_connection`, `operation_context`, `findings`, `stored_reports`, `tenant_review`, `review_pack`, `audit_history`
|
||||
- `label`
|
||||
- `availability` — derived local status (`available`, `missing`, `stale`, `inaccessible`, `redacted`)
|
||||
- `summary`
|
||||
- `freshness_at` or `freshness_note`
|
||||
- `references` — ordered support references for that section
|
||||
- `redaction_markers` — explicit markers when detail is intentionally excluded
|
||||
|
||||
**Note**: these are presentation-contract values only, not new persisted domain state.
|
||||
|
||||
### SupportDiagnosticReference (computed, not persisted)
|
||||
|
||||
**Purpose**: Stable canonical link/reference metadata for one related record.
|
||||
|
||||
**Proposed shape**:
|
||||
- `type` — `tenant`, `operation_run`, `provider_connection`, `finding`, `stored_report`, `tenant_review`, `review_pack`, or `audit_log`
|
||||
- `record_id`
|
||||
- `label`
|
||||
- `action_label`
|
||||
- `url` (nullable when inaccessible or no viewer exists)
|
||||
- `availability`
|
||||
- `freshness_note` (nullable)
|
||||
- `access_reason` or `missing_reason` (nullable)
|
||||
|
||||
### RedactionMarker (computed, not persisted)
|
||||
|
||||
**Purpose**: Make exclusion deterministic and explicit.
|
||||
|
||||
**Proposed shape**:
|
||||
- `path` (nullable)
|
||||
- `reason` — `secret`, `credential`, `raw_payload`, `restricted_log_excerpt`, or `inaccessible_record`
|
||||
- `replacement_text`
|
||||
|
||||
## Derived Rules and Validation
|
||||
|
||||
- Bundle generation requires established workspace membership first.
|
||||
- Tenant-context bundle generation requires tenant entitlement before any tenant-owned section resolves.
|
||||
- Operation-context bundle generation must first pass `OperationRunPolicy::view(...)`; if the run points at a tenant, tenant entitlement still applies before tenant-owned related records resolve.
|
||||
- After membership/entitlement is established, missing `support_diagnostics.view` is a `403` capability denial.
|
||||
- The bundle must never include secrets, tokens, credentials, unrestricted provider response bodies, or unrestricted stored-report payloads.
|
||||
- Missing or inaccessible related records must degrade into explicit placeholders or unavailable references, not hard-fail the whole bundle.
|
||||
- For unchanged authorized input, section order, reference order, and redaction output must remain stable.
|
||||
|
||||
## Lifecycle and Audit Behavior
|
||||
|
||||
- `SupportDiagnosticBundle` has no persisted lifecycle.
|
||||
- Opening the bundle writes one audit event with redacted metadata only.
|
||||
- Opening linked canonical records follows their own existing authorization and audit behavior.
|
||||
214
specs/241-support-diagnostic-pack/plan.md
Normal file
214
specs/241-support-diagnostic-pack/plan.md
Normal file
@ -0,0 +1,214 @@
|
||||
# Implementation Plan: Support Diagnostic Pack
|
||||
|
||||
**Branch**: `241-support-diagnostic-pack` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Add one derived, support-safe diagnostic bundle contract that can be opened from exactly two existing admin-plane surfaces: the tenant dashboard and the canonical tenantless operation detail viewer.
|
||||
- Reuse current canonical truth and shared paths instead of inventing support-local persistence or navigation: `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, and the existing audit recorder.
|
||||
- Keep the first slice strictly read-only and deterministic: no support-desk workflow, no external ticketing, no raw payload export, no persisted support-pack entity, no new `OperationRun` type, no notification changes, and no AI runtime behavior.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||
**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned
|
||||
**Testing**: Pest unit + feature tests only
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Sail-backed Laravel admin panel under `/admin` and `/admin/t/{tenant}`
|
||||
**Project Type**: web
|
||||
**Performance Goals**: bundle collection and preview remain DB-only at render/action time, perform no outbound HTTP, create no new `OperationRun`, and deterministically compose the same result for unchanged authorized input
|
||||
**Constraints**: derive-before-persist, provider-neutral top-level language, deterministic redaction, no raw payload/body export, no support-desk product, no external ticketing, no AI runtime features, no new run lifecycle semantics, no browser/heavy-governance drift, and RBAC non-member `404` versus capability `403` boundaries must remain explicit
|
||||
**Scale/Scope**: one bundle contract, two entry contexts (`TenantDashboard` and `TenantlessOperationRunViewer`), existing canonical record references only, and focused unit + feature coverage
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + shared read-only preview composition
|
||||
- **Shared-family relevance**: header actions, diagnostic summaries, related links, audit drill-throughs
|
||||
- **State layers in scope**: page, detail, action preview
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core
|
||||
- **Exception path and spread control**: the existing tenant dashboard action-surface exemption remains the only dashboard exception; no new support page, queue, or custom interaction family is introduced
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `App\Filament\Pages\TenantDashboard`, `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, `App\Support\OperationRunLinks`, `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder`, `App\Support\Providers\ProviderReasonTranslator`, `App\Support\Navigation\RelatedNavigationResolver`, `App\Support\RedactionIntegrity`, canonical review/provider/finding/audit viewers, and `App\Services\Audit\WorkspaceAuditLogger`
|
||||
- **Shared abstractions reused**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`
|
||||
- **New abstraction introduced? why?**: one narrow `SupportDiagnosticBundleBuilder` under `App\Support\SupportDiagnostics` is justified because there are now two concrete contexts that must emit the same machine-readable, redacted bundle contract. It should return one documented array shape, not a registry, strategy layer, or persisted model.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: existing helpers already own canonical links, run explanation language, provider reason translation, and redaction-note wording; what is missing is a deterministic cross-record composition layer that assembles those existing truths into one support-safe bundle.
|
||||
- **Bounded deviation / spread control**: preview stays on the two existing pages only, reuses current labels and URLs, and explicitly forbids a standalone support resource, export pipeline, or page-local link dialect.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only
|
||||
- **Central contract reused**: `OperationRunLinks` and the existing canonical tenantless run viewer, plus `GovernanceRunDiagnosticSummaryBuilder` for run explanation reuse
|
||||
- **Delegated UX behaviors**: canonical `Open operation` labeling, tenant/workspace-safe run URL resolution, shared run explanation language, and existing related-record link labels
|
||||
- **Surface-owned behavior kept local**: one read-only `Open support diagnostics` action plus preview/detail composition on the tenant dashboard and run-detail surfaces
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: provider connection descriptors, consent/permission failure excerpts, translated provider error detail
|
||||
- **Platform-core seams**: bundle shell, context labels, section ordering, freshness cues, redaction semantics, and canonical-reference vocabulary
|
||||
- **Neutral platform terms / contracts preserved**: `support diagnostic bundle`, `tenant context`, `operation context`, `provider connection`, `related record`, `audit reference`, `redaction reason`
|
||||
- **Retained provider-specific semantics and why**: Microsoft-specific permission, consent, and provider-failure wording stays only inside translated provider-owned summaries produced by `ProviderReasonTranslator`, because the current operator still needs exact remediation context when provider readiness is the blocker
|
||||
- **Bounded extraction or follow-up path**: none in this slice; any future System Panel least-privilege support surface remains a separate follow-up spec instead of widening this tenant/admin-plane bundle contract
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Derive-before-persist / `PERSIST-001`: PASS — no `support_diagnostic_packs` table or persisted entity is introduced; the bundle is composed at request time from existing workspace, tenant, run, provider, finding, report, review, and audit truth.
|
||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS — one narrow builder is justified by exactly two real contexts (tenant and run) that must share one deterministic contract; no registry, resolver lattice, or support framework is planned.
|
||||
- Provider boundary / `PROV-001`: PASS — top-level bundle fields stay provider-neutral, while provider-specific semantics remain in provider-owned translated detail.
|
||||
- Shared path reuse / `XCUT-001`: PASS — the feature reuses `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating a second support vocabulary.
|
||||
- RBAC, workspace isolation, tenant isolation / `RBAC-UX`: PASS — tenant dashboard access still requires established workspace + tenant scope, `support_diagnostics.view` is scoped through the canonical tenant capability registry and tenant role map, the run-detail action only applies when `OperationRunPolicy` has already resolved an entitled tenant scope, non-members or non-entitled actors stay `404`, and an entitled actor lacking the new capability receives `403` on the action.
|
||||
- Read/write separation and auditability: PASS — the bundle is read-only, writes no Graph or product state, and logs bundle-open activity via the existing audit recorder with redacted metadata only.
|
||||
- Graph contract path: PASS — no new Graph calls are added; render and action hydration remain DB-only.
|
||||
- OperationRun lifecycle / Ops-UX: PASS — the feature does not create, update, or complete runs; it only reuses existing run explanation and link semantics.
|
||||
- Global search / Filament resource requirements: `N/A` — no new global-searchable Filament resource is introduced.
|
||||
- Panel/provider registration: `N/A` — no panel or provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`.
|
||||
- Test governance / `TEST-GOV-001`: PASS — proof stays in focused unit + feature lanes, with no browser or heavy-governance family growth.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for bundle assembly, stable ordering, redaction, and related-reference shaping; Feature for tenant dashboard action behavior, operation-detail action behavior, audit logging, authorization boundaries, and no-side-effect execution proof
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the feature is a server-driven Filament/Livewire read-only action over existing database truth. Unit tests can prove determinism and redaction logic directly, while Feature tests can prove action registration, `404`/`403` boundaries, rendered canonical links, audit logging, and the absence of provider-backed or run-creating side effects without browser duplication.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing `Workspace`, `Tenant`, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` factories; add only one opt-in support-diagnostic seeding helper local to these tests so browser or full provider fixtures do not become defaults.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; support-diagnostic test helpers must stay local and opt-in.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament plus monitoring-state-page coverage is sufficient; assert the action and the resulting preview content rather than browser-level modal choreography.
|
||||
- **Closing validation and reviewer handoff**: rerun the targeted unit and feature commands after implementation; reviewers should confirm `404` versus `403`, explicit redaction markers, deterministic section ordering, canonical link reuse, absence of raw payload or token leakage, and that opening diagnostics creates no new `OperationRun`, Ops UX side effect, or provider-backed work.
|
||||
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
||||
- **Review-stop questions**: did the implementation introduce a standalone support page/resource, a persisted pack, a raw export path, or browser-only proof? did it bypass existing link/explanation helpers? did any test silently widen into a heavy family?
|
||||
- **Escalation path**: `reject-or-split` if implementation adds persistence, export, support-desk workflow, or system-plane capability expansion; `document-in-feature` for small shared-helper extensions that remain local to this slice
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the planned unit and feature tests extend existing fixture families and do not create a new recurring governance or browser cost center.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/241-support-diagnostic-pack/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── support-diagnostics.openapi.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/Pages/TenantDashboard.php
|
||||
│ ├── Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
│ ├── Policies/OperationRunPolicy.php
|
||||
│ ├── Services/Audit/WorkspaceAuditLogger.php
|
||||
│ ├── Support/Audit/AuditActionId.php
|
||||
│ ├── Support/Auth/Capabilities.php
|
||||
│ ├── Support/Navigation/RelatedNavigationResolver.php
|
||||
│ ├── Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php
|
||||
│ ├── Support/OperationRunLinks.php
|
||||
│ ├── Support/Providers/ProviderReasonTranslator.php
|
||||
│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
|
||||
│ ├── Support/RedactionIntegrity.php
|
||||
│ └── Support/SupportDiagnostics/
|
||||
│ └── SupportDiagnosticBundleBuilder.php
|
||||
└── tests/
|
||||
├── Feature/SupportDiagnostics/
|
||||
│ ├── TenantSupportDiagnosticActionTest.php
|
||||
│ ├── OperationRunSupportDiagnosticActionTest.php
|
||||
│ ├── SupportDiagnosticAuthorizationTest.php
|
||||
│ └── SupportDiagnosticAuditTest.php
|
||||
└── Unit/Support/SupportDiagnostics/
|
||||
├── SupportDiagnosticBundleBuilderTest.php
|
||||
└── SupportDiagnosticBundleRedactionTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. The feature stays inside existing tenant and monitoring detail pages plus one narrowly scoped support-diagnostics builder; no new resource, panel, route group, or persistence layer is introduced.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this plan. The only new structural element is one bounded derived bundle builder, justified below and kept within current-release truth.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: founder-led support still starts with manual cross-page reconstruction across tenant, provider, run, finding, report, review, and audit truth before the real issue can be understood.
|
||||
- **Existing structure is insufficient because**: current pages explain their own local truth, but there is no support-safe, deterministic composition layer that can gather those existing truths into one reusable bundle without page-local drift.
|
||||
- **Narrowest correct implementation**: one read-only bundle builder plus one read-only action on each of the two existing surfaces, reusing existing helpers, canonical links, and audit logging. No new entity, no export pipeline, no support queue, and no additional notification behavior.
|
||||
- **Ownership cost created**: one array-shaped contract, one capability constant and role mapping entry, one audit action identifier, and focused unit + feature coverage.
|
||||
- **Alternative intentionally rejected**: a persisted `SupportDiagnosticPack` model, a standalone support page/resource, and raw payload export/download were rejected as premature persistence and over-broad workflow expansion; page-local copy helpers were rejected as too narrow and drift-prone.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the narrowest existing sources of truth for tenant, run, provider, finding, report, review-pack, and audit references.
|
||||
- Resolve the capability and product-candidate open question by keeping support diagnostics on already-authorized tenant/admin surfaces only, with no new System Panel visibility bypass in this slice.
|
||||
- Confirm that redaction, related navigation, and run explanation can stay on existing shared helpers instead of introducing a second support-local layer.
|
||||
- Define deterministic ordering and graceful degradation rules for missing, stale, or inaccessible related records.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/241-support-diagnostic-pack/quickstart.md`
|
||||
|
||||
Design focus:
|
||||
- Add one read-only `Open support diagnostics` header action to `TenantDashboard` and `TenantlessOperationRunViewer` only.
|
||||
- Compose a tenant-context bundle from existing tenant truth, current relevant provider connection state, the most relevant recent operation context, open or recent findings, latest stored-report freshness, latest tenant-review or review-pack reference when present, and authorized audit references.
|
||||
- Compose an operation-context bundle from the existing humanized run summary plus related provider, finding, report, review, and audit references without changing `OperationRun` lifecycle semantics.
|
||||
- Keep section order and reference ordering deterministic, with explicit `missing`, `stale`, `redacted`, or `inaccessible` cues instead of silent omission.
|
||||
- Reuse canonical navigation and explanation helpers so every link, label, and run explanation means the same thing elsewhere in the product.
|
||||
- Add one tenant-plane capability in the canonical registry and tenant role map for bundle access, keep system-plane support diagnostics out of scope, only expose the run-detail action when the referenced run resolves to an entitled tenant scope, and record bundle-open activity with redacted metadata only.
|
||||
- Keep preview composition native to Filament read-only actions or infolist-style rendering on existing pages. No standalone support page or export/download action is planned.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
- Introduce `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder` that exposes explicit `forTenant(...)` and `forOperationRun(...)` entry points and returns one documented derived bundle shape.
|
||||
- Wire a read-only `Open support diagnostics` action into `App\Filament\Pages\TenantDashboard` and populate the preview from authorized tenant truth only.
|
||||
- Wire the same read-only action into `App\Filament\Pages\Operations\TenantlessOperationRunViewer`, reusing `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunLinks`, and existing related-navigation helpers.
|
||||
- Add the dedicated support-diagnostics capability to the canonical tenant capability registry and tenant role map, then enforce it server-side on both actions after membership/entitlement is established and after the run-detail surface has resolved an entitled tenant scope.
|
||||
- Add an audit action identifier and record bundle-open activity with redacted metadata through the existing audit recorder, without storing excluded payload content.
|
||||
- Add focused unit tests for deterministic ordering and redaction plus focused feature tests for tenant/run action behavior, `404`/`403` boundaries, canonical links, and audit logging.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS. The design remains derived, provider-neutral at the shared boundary, anchored on existing shared link and explanation paths, explicit about `404` versus `403`, bounded to two read-only surfaces, and contained to focused unit + feature proof with no browser or support-desk drift.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Guardrail close-out: PASS. The implementation remained read-only, DB-only at render/action time, and did not create new `OperationRun` records or dispatch provider-backed or queued operation work.
|
||||
- Validation lanes passed: targeted unit coverage for deterministic ordering and redaction plus targeted feature coverage for tenant action, run action, authorization boundaries, and audit/no-side-effect guarantees.
|
||||
- Shared-helper note: no follow-up spec was required. The final slice stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` seams with one bounded `SupportDiagnosticBundleBuilder` abstraction.
|
||||
46
specs/241-support-diagnostic-pack/quickstart.md
Normal file
46
specs/241-support-diagnostic-pack/quickstart.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Quickstart — Support Diagnostic Pack
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
- Existing workspace, tenant, run, provider, finding, report, review, and audit factories available for tests
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Run targeted tests after implementation:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual smoke after implementation
|
||||
|
||||
1. Sign in to `/admin` as a workspace member with tenant entitlement and the new tenant-plane `support_diagnostics.view` capability.
|
||||
2. Open one tenant at `/admin/t/{tenant}` and trigger `Open support diagnostics`.
|
||||
3. Confirm the preview shows a deterministic summary, redaction note, canonical related links, and no raw provider payload or credential detail.
|
||||
4. Open one canonical operation detail at `/admin/operations/{run}` for a run that resolves to the same entitled tenant scope and trigger the same action.
|
||||
5. Confirm the run summary reuses the existing operation explanation language and that related links still open canonical provider, finding, review, review-pack, and audit surfaces.
|
||||
6. Verify a non-member or non-entitled actor receives `404`, while an entitled actor without the support-diagnostics capability sees the action disabled in UI and receives `403` on direct action execution.
|
||||
7. Verify an audit entry is recorded for bundle-open activity with redacted metadata only.
|
||||
8. Verify opening diagnostics stays DB-only in this slice: no new `OperationRun` is created, no provider-backed work is dispatched, and no queued operation UX side effect appears.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 remains on Livewire v4.0+ in this repo; the feature stays within native Filament page actions and read-only preview composition.
|
||||
- No panel provider changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php`.
|
||||
- No global-search behavior changes are involved because this slice does not add a new Filament resource.
|
||||
- No destructive actions are introduced; `Open support diagnostics` remains a read-only action.
|
||||
- The new `support_diagnostics.view` gate is tenant-role scoped on tenant-admin surfaces; workspace-owned and system-plane runs remain out of scope for this first slice.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Guardrail result: PASS
|
||||
- Latest targeted validation passed:
|
||||
- `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`
|
||||
- `tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`
|
||||
- `tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- Shared-helper note: no follow-up spec is required for this slice; the implementation stayed on existing `OperationRunPolicy`, `OperationRunLinks`, `RelatedNavigationResolver`, `GovernanceRunDiagnosticSummaryBuilder`, `RedactionIntegrity`, and `WorkspaceAuditLogger` paths.
|
||||
140
specs/241-support-diagnostic-pack/research.md
Normal file
140
specs/241-support-diagnostic-pack/research.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Research — Support Diagnostic Pack
|
||||
|
||||
**Date**: 2026-04-25
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This document captures design decisions and supporting rationale for the first support-diagnostics slice. All decisions are grounded in current repository truth and the TenantPilot Constitution.
|
||||
|
||||
## Decision 1 — The support diagnostic bundle stays derived and read-only
|
||||
|
||||
**Decision**: Keep the first slice as a derived bundle assembled at request time from existing records. Do not introduce a persisted `SupportDiagnosticPack` model, table, queue, or export artifact.
|
||||
|
||||
**Rationale**:
|
||||
- The operator problem is fast support-safe context gathering, not a new long-lived domain object.
|
||||
- Constitution `PERSIST-001` and `PROP-001` require derive-before-persist when current-release truth does not need an independent lifecycle.
|
||||
- The bundle needs deterministic structure and auditability, but neither requires a new stored entity in this slice.
|
||||
|
||||
**Evidence**:
|
||||
- Existing canonical truth already exists on `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog`.
|
||||
- The spec explicitly forbids a persisted support-pack entity and raw export pipeline.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Persist a generated support pack with its own lifecycle.
|
||||
- Rejected: introduces new truth, retention concerns, and export expectations the first slice does not need.
|
||||
- Add page-local copy/export helpers only.
|
||||
- Rejected: too narrow and drift-prone, and fails the cross-surface reuse goal.
|
||||
|
||||
## Decision 2 — Reuse the two existing entry surfaces instead of creating a new support page
|
||||
|
||||
**Decision**: Add read-only `Open support diagnostics` actions to `TenantDashboard` and `TenantlessOperationRunViewer` only. Do not create a standalone Filament resource or route.
|
||||
|
||||
**Rationale**:
|
||||
- The user already reaches support context from an existing tenant or run workflow.
|
||||
- Constitution `DECIDE-001`, `UI-FIL-001`, and the spec’s surface contract require support diagnostics to stay secondary or tertiary, not become a new queue or product area.
|
||||
- Existing surfaces already establish the correct workspace/tenant/run authorization context.
|
||||
|
||||
**Evidence**:
|
||||
- Tenant entry surface: `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||
- Canonical run detail surface: `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- The run detail page already owns scope, back navigation, refresh, and grouped related links.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create `/admin/support-diagnostics/...` pages.
|
||||
- Rejected: wider surface area, new navigation semantics, and unnecessary support-product expansion.
|
||||
|
||||
## Decision 3 — Add one tenant/admin-plane support capability, but do not widen tenant visibility
|
||||
|
||||
**Decision**: Introduce one tenant/admin-plane capability, `support_diagnostics.view`, in the canonical tenant capability registry and role mapping. Keep support diagnostics available only on already-authorized tenant and run surfaces. Do not add any System Panel support-diagnostics capability in this slice.
|
||||
|
||||
**Rationale**:
|
||||
- The spec requires a dedicated support-diagnostics capability and strict `404` vs `403` boundaries.
|
||||
- The roadmap’s open question about support diagnostics revealing tenant metadata without tenant-directory access is resolved narrowly here: this slice does not create a new metadata visibility path. It only augments already-authorized admin-plane surfaces.
|
||||
- That keeps current tenant/workspace isolation intact and defers platform-plane least-privilege splitting to the separate system-panel RBAC work.
|
||||
|
||||
**Evidence**:
|
||||
- Current tenant/admin capability registry: `apps/platform/app/Support/Auth/Capabilities.php`
|
||||
- Current run authorization semantics: `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- Product candidate open question on tenant metadata visibility: `docs/product/spec-candidates.md`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse an existing broad capability such as `tenant.view` or `audit.view`.
|
||||
- Rejected: too coarse for a support-safe bundle that aggregates multiple record types.
|
||||
- Add a platform/system-plane support capability now.
|
||||
- Rejected: outside the current admin-plane slice and would broaden scope into least-privilege platform RBAC.
|
||||
|
||||
## Decision 4 — Existing shared helpers remain authoritative for labels, links, and run explanation
|
||||
|
||||
**Decision**: The bundle builder must reuse `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `ProviderConnectionSurfaceSummary`, `RelatedNavigationResolver`, and `RedactionIntegrity` instead of creating support-local link builders or explanation text.
|
||||
|
||||
**Rationale**:
|
||||
- Constitution `XCUT-001` requires reuse of the existing shared path for cross-cutting interaction classes.
|
||||
- The support bundle should not create a second vocabulary for operation wording, provider readiness, or related-record labels.
|
||||
- Reuse reduces drift and makes future AI-safe consumers rely on the same canonical operator phrasing.
|
||||
|
||||
**Evidence**:
|
||||
- Canonical run link helpers: `apps/platform/app/Support/OperationRunLinks.php`
|
||||
- Humanized run diagnostic summaries: `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- Provider reason translation: `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`
|
||||
- Related-record navigation and audit target linking: `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
|
||||
- Existing redaction note wording: `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Build support-specific strings and URLs inside each page action.
|
||||
- Rejected: duplicated semantics, harder review, and immediate drift risk.
|
||||
|
||||
## Decision 5 — Deterministic ordering is part of the bundle contract, not a UI afterthought
|
||||
|
||||
**Decision**: Fix one section order for every bundle and sort references by stable business signals first, then stable record identity. Prefer run-bound references when the current run explicitly identifies them; otherwise fall back to the current tenant’s latest authorized canonical records.
|
||||
|
||||
**Rationale**:
|
||||
- The spec requires that the same authorized input and unchanged source truth produce the same section order, reference order, and redaction output.
|
||||
- Incidental query order would make later AI consumption and regression testing unreliable.
|
||||
- This decision can stay local to the bundle builder and documented contract without adding a new enum or persistence layer.
|
||||
|
||||
**Expected ordering**:
|
||||
- Section order: overview, provider connection, operation context, findings, stored reports, tenant review, review pack, audit history.
|
||||
- Reference ordering: prefer run-bound reference first; otherwise order by the relevant lifecycle timestamp (`recorded_at`, `generated_at`, `last_seen_at`, `completed_at`, `id`) with deterministic tie-breakers.
|
||||
|
||||
**Evidence**:
|
||||
- `AuditLog` already defines a canonical latest-first ordering via `scopeLatestFirst()`.
|
||||
- `ReviewPack`, `TenantReview`, and `OperationRun` expose stable lifecycle timestamps and latest-first retrieval patterns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Let each section use its default Eloquent query order.
|
||||
- Rejected: not deterministic enough for the contract promised by the spec.
|
||||
|
||||
## Decision 6 — Audit bundle-open activity through the existing recorder with redacted metadata only
|
||||
|
||||
**Decision**: Record bundle-open activity through `WorkspaceAuditLogger` with a new audit action identifier. Include actor, workspace, tenant when present, primary context type/id, redaction mode, and section/reference counts. Exclude raw provider payloads, secrets, tokens, and unrestricted log excerpts.
|
||||
|
||||
**Rationale**:
|
||||
- The feature is read-only, but support-diagnostics access is still security- and audit-relevant.
|
||||
- `WorkspaceAuditLogger` already supports workspace-scoped and tenant-scoped audit entries without introducing a support-specific persistence path.
|
||||
- Redacted metadata is sufficient for evidence and review.
|
||||
|
||||
**Evidence**:
|
||||
- Workspace-scoped audit writer: `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
- Canonical audit action IDs: `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||
|
||||
**Alternatives considered**:
|
||||
- Skip auditing because the action is read-only.
|
||||
- Rejected: contradicts the spec’s auditability requirement.
|
||||
- Persist the full generated bundle in the audit log.
|
||||
- Rejected: leaks excluded data and violates the derive-before-persist goal.
|
||||
|
||||
## Decision 7 — Proof stays in Unit + Feature lanes only
|
||||
|
||||
**Decision**: Keep proof in focused unit and feature suites. Do not introduce browser coverage, heavy-governance flows, or new lane families for this slice.
|
||||
|
||||
**Rationale**:
|
||||
- The business truth is deterministic bundle composition plus server-side authorization, redaction, canonical link reuse, and audit logging.
|
||||
- Browser tests would mostly duplicate modal/preview rendering and slow down the narrow support slice.
|
||||
- Constitution `TEST-GOV-001` requires the narrowest proving lane mix.
|
||||
|
||||
**Evidence**:
|
||||
- Existing feature coverage already exercises tenant dashboard, canonical run detail, monitoring navigation, audit visibility, and operation-link contracts.
|
||||
- Existing unit coverage already protects shared helpers such as run summaries and navigation resolvers.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add browser smoke tests for modal interaction.
|
||||
- Rejected: browser proof is not necessary to establish the core business truth of this first slice.
|
||||
297
specs/241-support-diagnostic-pack/spec.md
Normal file
297
specs/241-support-diagnostic-pack/spec.md
Normal file
@ -0,0 +1,297 @@
|
||||
# Feature Specification: Support Diagnostic Pack
|
||||
|
||||
**Feature Branch**: `241-support-diagnostic-pack`
|
||||
**Created**: 2026-04-25
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Support Diagnostic Pack"
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: Support and troubleshooting work still requires manual context gathering across workspace, tenant, ProviderConnection, OperationRun, Finding, StoredReport, TenantReview, review artifacts, and audit history before the real issue can be addressed.
|
||||
- **Today's failure**: A founder or support-capable operator must reconstruct the case by hand, which slows first response, increases the chance of oversharing sensitive provider context, and leaves later AI-assisted support without a safe canonical input layer.
|
||||
- **User-visible improvement**: An entitled operator can open one deterministic, redacted diagnostic bundle for a tenant or one operation run and immediately see the current issue, freshness, and the right canonical records to inspect next.
|
||||
- **Smallest enterprise-capable version**: A read-only, derived diagnostic bundle contract for tenant context and OperationRun context that references existing canonical records, applies first-class redaction and access checks, and writes audit entries for bundle generation without creating a new persisted support-pack entity.
|
||||
- **Explicit non-goals**: No external ticketing or helpdesk integration, no support-desk product, no unrestricted raw payload export or download, no broad log export pipeline, no AI chatbot or autonomous AI support behavior, and no application implementation in this preparation artifact.
|
||||
- **Permanent complexity imported**: One derived support-diagnostic bundle contract, one deterministic ordering and redaction policy, two bounded action entry points, and focused unit and feature coverage for authorization, redaction, and reference continuity.
|
||||
- **Why now**: Self-Service Tenant Onboarding & Connection Readiness already exists as Spec 240, so the next supportability bottleneck is founder-led troubleshooting. This slice is the next recommended candidate, directly reduces manual support work, and reuses strong existing foundations that are already in the repo.
|
||||
- **Why not local**: Page-local exports or copy helpers on one tenant or run page would duplicate truth, drift labels and redaction behavior, and still fail to provide one reusable support-safe contract across support workflows.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: Two red flags apply: it sounds like a foundation/support layer, and it touches more than one surface. Defense: the first slice is tightly limited to tenant context and OperationRun context, introduces no persistence, composes existing record truth instead of inventing a helpdesk framework, and explicitly defers broader support product work.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}` as the first tenant-context entry point for a tenant-scoped support diagnostic bundle
|
||||
- `/admin/operations/{run}` as the first OperationRun-context entry point for a run-scoped support diagnostic bundle
|
||||
- Existing related destinations reached from the bundle, including tenant findings, provider connection detail, tenant review detail, review-pack detail when present, and audit-log event detail
|
||||
- **Data Ownership**: No new `support_diagnostic_packs` truth is introduced. The bundle is derived from existing canonical records. Source truth remains on workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references tied to the same authorized scope.
|
||||
- **RBAC**: Workspace membership is required, and both entry surfaces stay in the tenant-admin `/admin` plane. Tenant entitlement is required before any tenant-owned record is resolved. A dedicated tenant-role capability `support_diagnostics.view` in the canonical capability registry and tenant role map gates bundle generation and bundle viewing on both entry surfaces. The tenantless operation viewer only offers the action when the referenced run resolves to an entitled tenant scope in this slice; workspace-owned or system-plane runs remain out of scope. Existing per-record permissions still govern whether linked canonical records may be opened after the bundle is shown.
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- Not applicable as a primary scope because the first slice adds support-diagnostic actions to existing tenant and operation-detail surfaces rather than introducing a new canonical collection page.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: header actions, diagnostic summaries, related-record links, evidence and report references, audit drill-throughs
|
||||
- **Systems touched**: canonical operation related links, governance run explanation summaries, provider reason translation, existing resource detail routes, and audit-log detail resolution
|
||||
- **Existing pattern(s) to extend**: existing operation related-link behavior, existing humanized operation explanation behavior, existing tenant-safe related-record routing, and existing audit-log navigation
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, and existing tenant-safe record viewers for findings, provider connections, tenant reviews, review packs, and audit-log events
|
||||
- **Why the existing shared path is sufficient or insufficient**: The existing paths are sufficient for labels, links, and run explanation language, but they do not yet assemble one support-safe cross-record bundle with deterministic ordering and redaction.
|
||||
- **Allowed deviation and why**: One new derived bundle assembler is allowed, but it may only compose existing truth and existing shared helpers. Parallel page-local link builders, raw payload embedding, or a second support-summary dialect are not allowed.
|
||||
- **Consistency impact**: Operation labels, related-record labels, provider explanation wording, redaction messaging, and audit-reference semantics must stay aligned with existing shared helpers so support workflows do not develop a local vocabulary.
|
||||
- **Review focus**: Reviewers must verify that the bundle reuses shared link and explanation paths, does not duplicate record truth, and never embeds raw provider payloads or bundle-local provider-specific semantics by default.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, for deep-link and explanation reuse only
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunLinks` for tenant-safe operation routing and existing governance-run summary builders for run explanation language
|
||||
- **Delegated start/completion UX behaviors**: tenant-safe `Open operation` and related-record link semantics are reused; queued toast, browser event, dedupe-or-blocked messaging, artifact-link creation beyond existing links, and queued DB notifications are `N/A` in this slice
|
||||
- **Local surface-owned behavior that remains**: a read-only `Open support diagnostics` action from the tenant dashboard or operation detail surface
|
||||
- **Queued DB-notification policy**: `N/A`
|
||||
- **Terminal notification path**: `N/A`
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: provider connection descriptors, provider reason translation, translated provider error excerpts, and redaction boundaries around provider-owned diagnostics
|
||||
- **Neutral platform terms preserved or introduced**: support diagnostic bundle, tenant context, operation context, provider connection, related record, support summary, redaction reason, audit reference
|
||||
- **Provider-specific semantics retained and why**: Microsoft-specific permission, consent, or provider failure reasons may appear only as translated excerpts inside provider-owned sub-sections when they are genuinely needed to explain the current issue.
|
||||
- **Why this does not deepen provider coupling accidentally**: The bundle contract remains provider-neutral and references provider specifics only through existing provider-owned descriptors and reason translators. It does not introduce provider-shaped primary fields as platform-core truth.
|
||||
- **Follow-up path**: none in this slice; later support or AI features can build on the same neutral bundle contract
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | yes | Native Filament + shared primitives | header actions, diagnostic summaries, related links | page, action, preview | yes | Existing tenant dashboard action-surface exemption remains; this slice adds one bounded support action rather than reclassifying the dashboard as a queue |
|
||||
| Monitoring operation detail support diagnostic action | yes | Native Filament + shared diagnostics primitives | operation detail, related links, audit drill-through | detail, action, preview | no | Extends the existing diagnostic surface instead of creating a second operation-detail dialect |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Secondary Context Surface | An operator decides they need help or escalation for one tenant and wants a support-safe case summary before leaving the tenant workflow | Tenant identity, bundle freshness, provider connection state, latest relevant run or finding pressure, and a visible redaction boundary | Redacted section detail plus canonical links to runs, findings, reviews, reports, and audit events | Not primary because support is follow-up to tenant work, not the tenant’s daily decision queue | Follows tenant troubleshooting and escalation flow | Removes manual search across operations, provider, findings, reviews, and audit pages |
|
||||
| Monitoring operation detail support diagnostic action | Tertiary Evidence / Diagnostics Surface | An operator is already inspecting one run and needs a support-safe summary they can act on or escalate | Run outcome, dominant issue, related record references, freshness, and a visible redaction boundary | Redacted section detail plus canonical links to provider, findings, review artifacts, and audit history | Not primary because operation detail is already a drill-in evidence surface | Follows monitoring drill-in workflow | Removes cross-page reconstruction from a single failed or degraded run |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Dashboard / Overview / Actions | Tenant troubleshooting support entry point | Open the redacted tenant diagnostic bundle | Explicit header action opens a read-only support-diagnostic preview | forbidden | Existing tenant links remain secondary; bundle links live inside the preview | none | /admin/t/{tenant} | /admin/t/{tenant} | Active workspace, active tenant, bundle freshness, redaction notice | Support diagnostics / Support diagnostic bundle | Current issue summary, top related records, and redaction boundary | dashboard_exception - tenant dashboard already has a documented action-surface exemption; this action remains bounded and read-only |
|
||||
| Monitoring operation detail support diagnostic action | Record / Detail / Actions | Canonical diagnostic detail support entry point | Open the redacted run diagnostic bundle | Existing operation detail page plus one explicit support-diagnostic action | forbidden | Existing related links remain secondary and continue to use the canonical operation surface | none | /admin/operations | /admin/operations/{run} | Workspace context, active tenant context when present, operation identifier, redaction notice | Support diagnostics / Support diagnostic bundle | Dominant issue, related records, freshness, and redaction boundary | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostic action | Workspace manager or support-capable tenant operator | Decide whether the tenant case can be escalated or troubleshot with one support-safe bundle | Dashboard action + read-only preview | Do I have enough support-safe context for this tenant without opening raw provider diagnostics? | Tenant identity, workspace context, provider connection state, latest relevant run summary, active finding pressure, latest report or review references, and redaction notice | Full related records remain on their native pages; raw payloads and unrestricted logs stay excluded | connection health, run freshness, finding pressure, report freshness, review availability | None | Open support diagnostics, open canonical related record links | none |
|
||||
| Monitoring operation detail support diagnostic action | Workspace manager or support-capable operator | Decide whether one operation case has enough support-safe context for follow-up or escalation | Detail action + read-only preview | What is the current operation issue, what related records matter, and what is safe to share or reuse for support? | Humanized run summary, related provider and tenant references, current finding or review references, audit references, and redaction notice | Full diagnostics remain on canonical run, finding, provider, review, or audit pages; raw payloads stay excluded | execution outcome, provider connection state, artifact freshness, related finding pressure | None | Open support diagnostics, open canonical related record links | none |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Support work starts too late because someone must first gather the relevant tenant, provider, run, finding, report, review, and audit context by hand.
|
||||
- **Existing structure is insufficient because**: Existing pages explain local truth, but no current path assembles one support-safe, deterministic bundle across tenant and OperationRun context while enforcing redaction and deny-as-not-found behavior first.
|
||||
- **Narrowest correct implementation**: Add one derived bundle contract for tenant and OperationRun contexts only, with references to existing canonical records, deterministic ordering, redaction, and audit logging. Do not persist the bundle, build a helpdesk product, or add a generic export framework.
|
||||
- **Ownership cost**: Maintain one derived bundle schema, one deterministic redaction policy, one audit event family for bundle usage, and focused unit and feature coverage for authorization and reference continuity.
|
||||
- **Alternative intentionally rejected**: A persisted `SupportDiagnosticPack` entity and broad raw-export pipeline were rejected as too heavy, and page-local copy/export helpers were rejected as too narrow and drift-prone.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: Unit coverage proves deterministic section ordering, redaction behavior, and canonical reference shaping. Feature coverage proves tenant and OperationRun entry points, deny-as-not-found isolation, capability enforcement, and canonical related-link continuity without introducing browser or heavy-governance breadth.
|
||||
- **New or expanded test families**: A focused support-diagnostics unit family plus targeted feature coverage for tenant-context and operation-context actions and authorization boundaries
|
||||
- **Fixture / helper cost impact**: Moderate. Tests can reuse existing workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack`, and `AuditLog` fixtures, but must add explicit redaction and inaccessible-reference cases.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament + monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for redaction markers, stable ordering, 404 versus 403 semantics, canonical related-link reuse, and the absence of new run-creating or provider-backed side effects when diagnostics are opened.
|
||||
- **Reviewer handoff**: Reviewers must confirm that no raw provider payloads or secrets appear in the bundle, that unauthorized cross-workspace or cross-tenant access is treated as not found, that entitled-but-uncapable users receive authorization failure, that canonical links and labels stay aligned with existing support helpers, and that opening diagnostics creates no new `OperationRun` or provider-backed side effect.
|
||||
- **Budget / baseline / trend impact**: Low-to-moderate increase in narrow unit and feature coverage only; no new browser or heavy-governance baseline is expected.
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Open a tenant support-safe bundle (Priority: P1)
|
||||
|
||||
As a workspace manager or support-capable operator, I want one tenant-scoped diagnostic bundle so I can start support without manually gathering records across multiple pages.
|
||||
|
||||
**Why this priority**: This is the first support workflow compression win. If tenant context still has to be rebuilt by hand, the feature has not reduced founder-led support work.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening a tenant-scoped support diagnostic bundle from the tenant dashboard with seeded provider, finding, report, review, and audit records and verifying that the bundle is redacted, deterministic, and linked to canonical records only.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled operator opens support diagnostics for a tenant with an unhealthy provider connection, recent failed run, open findings, and a recent tenant review, **When** the bundle renders, **Then** it shows one redacted tenant summary with canonical references to the provider connection, most relevant operation run, related findings, latest stored reports, tenant review or review pack when present, and relevant audit references.
|
||||
2. **Given** the current user is not a member of the workspace or is not entitled to the tenant, **When** they try to open the tenant bundle, **Then** the system responds as not found and does not reveal whether the tenant has provider issues, findings, or support history.
|
||||
3. **Given** the current user is entitled to the tenant but lacks the support-diagnostics capability, **When** they try to open the tenant bundle, **Then** the system denies the action as an authorization failure without revealing additional diagnostic detail.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Open a run-centered support-safe bundle (Priority: P1)
|
||||
|
||||
As a support-capable operator already inspecting a run, I want a run-centered diagnostic bundle that uses the same operation explanation language as Monitoring so I can diagnose or escalate one case quickly.
|
||||
|
||||
**Why this priority**: A large share of support work begins with one suspicious run. If run context still requires cross-page reconstruction, the first-response workflow remains too slow.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening an operation-scoped support diagnostic bundle from the canonical operation detail surface and verifying that the bundle reuses existing humanized run summaries, canonical related links, and redaction rules.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled operator opens support diagnostics for a failed or degraded operation run, **When** the bundle renders, **Then** it reuses the existing humanized operation explanation, includes the related provider connection, related finding, related stored report, related tenant review or review pack when present, and relevant audit references, and keeps those references canonical rather than duplicating record truth.
|
||||
2. **Given** the operation context contains sensitive provider response data or raw payload excerpts, **When** the bundle renders, **Then** those values are excluded by default and replaced with explicit redaction markers or translated high-level reasons.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Rely on deterministic, redacted support summaries (Priority: P2)
|
||||
|
||||
As a product owner preparing later AI-assisted support, I want support diagnostic bundles to be deterministic and machine-readable so later support tooling can reuse them without widening access or depending on ad-hoc summaries.
|
||||
|
||||
**Why this priority**: Deterministic structure is what makes the first slice reusable later without adding a second translation layer or unsafe copy-and-paste support flow.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating the same authorized bundle repeatedly against unchanged source truth and verifying stable section order, stable reference order, and stable redaction behavior.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the same authorized tenant or run input and unchanged source truth, **When** the bundle is generated multiple times, **Then** the same sections, reference order, and redaction outcomes appear in the same order every time.
|
||||
2. **Given** a related record becomes missing or inaccessible after the first generation, **When** the bundle is generated again, **Then** the summary marks that record as missing or inaccessible without leaking details from the inaccessible record.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant may have no provider connection, no recent operation run, or no current tenant review; the bundle must still render a truthful support summary with explicit `missing`, `not yet observed`, or `not available` states.
|
||||
- A run may reference a stored report that has no dedicated viewer surface. The bundle must reference the canonical record identity and freshness without inventing a new report page.
|
||||
- A run may reference a review pack or tenant review that has expired, been deleted, or is no longer accessible; the bundle must degrade gracefully and preserve deny-as-not-found boundaries.
|
||||
- Provider error detail may exist only in raw payload context. The bundle must prefer translated high-level reason semantics and a redaction marker instead of echoing raw payload content.
|
||||
- Workspace-level audit events may exist without a tenant-bound detail record. The bundle must include only audit references that are valid for the authorized scope and primary context.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new destructive workflow, and no new queued support-processing pipeline. Bundle generation is a read-only supportability action. If implementation writes no `OperationRun`, it must still write `AuditLog` entries for bundle generation and any explicit copy or share action using redacted metadata only.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded derived bundle contract because current tenant and operation pages still force manual reconstruction for support. No new persistence, status family, or support-desk domain entity is added. The design stays aligned with derive-before-persist and explicit-before-generic by composing existing records instead of creating a generalized support framework.
|
||||
|
||||
**Constitution alignment (XCUT-001):** This feature is cross-cutting across operation links, explanation summaries, provider reason translation, evidence or review viewers, and audit drill-through. It must extend the existing shared paths instead of creating page-local support-link or support-summary dialects.
|
||||
|
||||
**Constitution alignment (PROV-001):** The support diagnostic bundle contract stays provider-neutral. Provider-specific content remains contextual inside provider-owned translated sections and must not become new platform-core fields in the bundle.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays in focused unit and feature coverage. No heavy-governance or browser lane is justified for the first slice. Fixture cost must remain explicit and limited to real support contexts using existing models.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing `OperationRun` lifecycle rules, `summary_counts`, and notification rules remain unchanged. The feature reuses `OperationRun` summaries and links only; it does not add a new operation type or change run-state ownership.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** The feature reuses `OperationRunLinks` for tenant-safe URL resolution and existing operation labels. It does not add queued toasts, dedupe messaging, lifecycle notifications, or run-enqueued browser events.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** The affected authorization plane is the tenant-admin `/admin` plane, including tenant-context routes and the canonical tenantless operation detail viewer. Non-members or non-entitled users must receive 404. The new `support_diagnostics.view` gate is tenant-role scoped through the canonical tenant capability registry and role map, and the run-context action is only in scope when the referenced run resolves to an entitled tenant. Entitled members lacking that capability must receive 403. Server-side authorization must run before any bundle section resolves a tenant-owned record. Linked canonical destinations continue to enforce their own authorization rules.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** No new badge family is required. Existing status and reason semantics remain authoritative.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The feature must use native Filament page or header actions and read-only summary sections or infolist-style presentation. Local replacement markup for status semantics or redaction language is intentionally avoided.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the support diagnostic bundle. Primary operator-facing verbs remain `Open support diagnostics`, `Open operation`, `Open finding`, `Open provider connection`, and related canonical record labels. Implementation-first terms such as payload blob, Graph response, or raw JSON must stay out of primary labels.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The affected surfaces remain secondary or tertiary support and diagnostics contexts. The feature must not create a new decision queue or a new support-desk surface.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Each affected surface keeps exactly one primary inspect or open model. The support-diagnostic action is read-only, secondary to the existing tenant or operation surface, and must not compete with mutation or add redundant `View` actions.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** The support-diagnostic action is a contextual read-only action. It must stay separated from mutation and dangerous actions and must not turn into a mixed catch-all support menu.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first: summary, freshness, related record references, and redaction state first; raw diagnostics remain on the original canonical pages.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from one existing page is insufficient because support context spans multiple canonical records. The derived bundle replaces manual reconstruction, not existing truth. Tests must prove business outcomes: safe first-response context, safe redaction, and safe authorization boundaries.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each affected surface adds one explicit read-only support-diagnostic action, keeps one primary inspect model, adds no redundant `View` action, and adds no destructive placement changes. The tenant dashboard keeps its existing exemption for broader dashboard retrofit work.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Any preview or detail presentation for the bundle must use summary-first sections, explicit redaction messaging, and one clear path back to the canonical records. The feature does not add create or edit screens.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-241-001 Context coverage**: The system MUST allow an entitled operator to generate a support diagnostic bundle for at least two first-slice contexts: one tenant context and one specific `OperationRun` context.
|
||||
- **FR-241-002 Canonical truth reuse**: The bundle MUST reference existing canonical records for workspace, tenant, `OperationRun`, `ProviderConnection`, `Finding`, `StoredReport`, `TenantReview`, `ReviewPack` when present, and `AuditLog` references, instead of duplicating their full truth into a new persisted support record.
|
||||
- **FR-241-003 Tenant summary shape**: A tenant-context bundle MUST include a deterministic redacted summary covering tenant identity, workspace scope, provider connection health, most relevant recent operation context, relevant active findings, latest stored-report freshness, tenant-review or review-pack references when present, and audit references relevant to the same scope.
|
||||
- **FR-241-004 Operation summary shape**: An `OperationRun`-context bundle MUST reuse existing humanized operation summary language, include related provider and artifact references when present, include relevant findings or review artifacts when present, and include audit references tied to the same run or immediate follow-up.
|
||||
- **FR-241-005 Deterministic ordering**: For the same authorized input and unchanged source truth, the bundle MUST emit the same section order, same reference order, and same redaction outcomes.
|
||||
- **FR-241-006 Redaction first**: Sensitive raw provider payloads, secrets, tokens, full response bodies, and unrestricted log excerpts MUST be excluded by default. When exclusion happens, the bundle MUST show explicit redaction markers or translated high-level reasons instead of silent omission.
|
||||
- **FR-241-007 Access checks first**: Workspace membership, tenant entitlement, and support-diagnostics capability checks MUST run before any tenant-owned bundle section is resolved.
|
||||
- **FR-241-008 404 versus 403 boundaries**: Non-members and non-entitled users MUST receive deny-as-not-found behavior. Entitled users lacking the support-diagnostics capability MUST receive authorization failure. Inaccessible related records inside an otherwise allowed bundle MUST degrade safely without revealing protected record details.
|
||||
- **FR-241-009 Canonical link continuity**: Bundle links and record labels MUST reuse existing canonical navigation and explanation helpers so the bundle uses the same record names and destination meaning as the rest of the product.
|
||||
- **FR-241-010 Freshness and completeness cues**: The bundle MUST show freshness, missing-data, or stale-data cues for included references so support workflows can distinguish fresh context from incomplete or absent context.
|
||||
- **FR-241-011 Auditability**: Bundle generation and any explicit bundle copy or share action in scope MUST write audit entries that record actor, scope, primary context, and redaction mode without storing excluded raw payload content.
|
||||
- **FR-241-012 No new support-pack persistence**: The first slice MUST NOT create a persisted `SupportDiagnosticPack` entity, a support queue, or a broad export pipeline.
|
||||
- **FR-241-013 Later AI readiness**: The bundle contract MUST remain machine-readable and support-safe so later AI-assisted support can consume the same contract without requiring broader access.
|
||||
- **FR-241-014 Graceful missing-reference handling**: If a referenced canonical record is missing, expired, or inaccessible, the bundle MUST identify that state explicitly and continue rendering the remaining authorized context.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant dashboard support diagnostics | `App\Filament\Pages\TenantDashboard` | `Open support diagnostics` (read-only, capability-gated) | n/a | none | none | none added | `Open support diagnostics` | n/a | yes | Existing tenant dashboard exemption remains; this slice adds one bounded support action only |
|
||||
| Monitoring operation detail support diagnostics | `App\Filament\Pages\Operations\TenantlessOperationRunViewer` | `Open support diagnostics` (read-only, capability-gated) | Existing operation detail remains the primary inspect model | none | none | n/a | `Open support diagnostics` | n/a | yes | No new destructive action, no redundant `View` action, no action-group catch-all |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Support Diagnostic Bundle**: A derived, read-only support-safe envelope for one tenant context or one operation context.
|
||||
- **Diagnostic Reference Set**: The typed set of canonical record references included in the bundle, such as tenant, operation, provider connection, finding, stored report, tenant review, review pack, and audit reference.
|
||||
- **Redaction Marker**: An explicit indicator that a sensitive value or raw payload section was intentionally excluded and why.
|
||||
- **Diagnostic Context Slice**: The bounded entry context for one bundle generation request, either tenant context or `OperationRun` context.
|
||||
|
||||
## Assumptions & Dependencies
|
||||
|
||||
- Spec 240 Self-Service Tenant Onboarding & Connection Readiness already exists on its own feature branch and remains the immediately preceding supportability milestone.
|
||||
- Existing `OperationRun`, `Finding`, `ProviderConnection`, `StoredReport`, `TenantReview`, `ReviewPack`, `AuditLog`, and operator explanation foundations are mature enough to support a derived bundle without introducing new persistence.
|
||||
- Existing operation explanation and related-link helpers remain the authoritative path for run wording and canonical navigation continuity.
|
||||
- `StoredReport` currently acts as canonical evidence truth without a dedicated general-purpose report viewer; the first slice therefore references report identity and freshness instead of inventing a new report surface.
|
||||
- Product Usage & Adoption Telemetry and Operational Controls & Feature Flags remain deferred because they either depend on onboarding and readiness behavior landing first or require new greenfield control and telemetry infrastructure that this slice intentionally avoids.
|
||||
|
||||
## Risks
|
||||
|
||||
- Over-including provider diagnostics would create unnecessary data-exposure risk and undermine tenant or workspace isolation.
|
||||
- Under-including context would push support users back to manual reconstruction and fail the workflow-compression goal.
|
||||
- A page-local implementation on only one surface could drift from shared operation or provider language and create inconsistent support summaries.
|
||||
- Deterministic bundle ordering must not accidentally depend on incidental query order or uncontrolled related-record selection.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None for the first slice. Follow-up questions about external ticket attachment, persistent support requests, or AI-assisted support behavior are explicitly deferred.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Add an external ticketing or helpdesk integration.
|
||||
- Create a support-desk product or support queue inside TenantPilot.
|
||||
- Allow unrestricted raw provider payload export or download.
|
||||
- Build a broad log export pipeline.
|
||||
- Add an AI chatbot, autonomous AI support behavior, or agentic triage flow.
|
||||
- Introduce a persisted support-pack entity in the first slice.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-241-001**: In acceptance review, an entitled operator can open a tenant or operation support diagnostic bundle and identify the current issue, the authorized scope, and the next canonical records to inspect within 30 seconds without manually searching across more than one additional product surface.
|
||||
- **SC-241-002**: In validation scenarios, 100% of covered tenant and operation bundle cases exclude sensitive raw provider payloads by default while still surfacing the relevant related-record references and freshness cues.
|
||||
- **SC-241-003**: In validation scenarios, 100% of unauthorized cross-workspace or cross-tenant bundle attempts return not found, and 100% of entitled-but-uncapable attempts return authorization failure.
|
||||
- **SC-241-004**: In deterministic regression coverage, repeated generation of the same unchanged authorized bundle produces the same section order, same reference order, and same redaction markers.
|
||||
194
specs/241-support-diagnostic-pack/tasks.md
Normal file
194
specs/241-support-diagnostic-pack/tasks.md
Normal file
@ -0,0 +1,194 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: Support Diagnostic Pack
|
||||
|
||||
**Input**: Design documents from `/specs/241-support-diagnostic-pack/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the targeted `fast-feedback` and `confidence` unit + feature suites listed in `specs/241-support-diagnostic-pack/quickstart.md`.
|
||||
|
||||
## Scope Lock
|
||||
|
||||
- This task list covers only tenant-context and `OperationRun`-context support diagnostic bundles on the existing admin-plane tenant dashboard and tenantless operation detail viewer.
|
||||
- Deferred by design: external ticketing, standalone support pages or resources, raw export or download flows, AI runtime behavior, heavy browser coverage, and system-panel support surfaces.
|
||||
|
||||
## Test Governance Notes
|
||||
|
||||
- Lane assignment: targeted unit + feature proof from `specs/241-support-diagnostic-pack/quickstart.md` is the narrowest sufficient validation for bundle composition, authorization, redaction, canonical-link reuse, and audit logging.
|
||||
- No new browser or heavy-governance family should be introduced; keep any helper or fixture growth local to `SupportDiagnostics` tests and cheap by default.
|
||||
- Surface profile: `standard-native-filament` relief applies to the tenant dashboard action, and the canonical operation detail action must preserve the `monitoring-state-page` contract already documented in the spec and plan.
|
||||
- If implementation leaves a bounded shared-helper or provider-boundary hotspot, record that outcome in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md` before merge instead of widening this slice.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Pin the first-slice scope, existing surfaces, and shared helpers before runtime edits begin.
|
||||
|
||||
- [X] T001 Review the first-slice bundle contract, scope lock, and validation commands in `specs/241-support-diagnostic-pack/spec.md`, `specs/241-support-diagnostic-pack/plan.md`, `specs/241-support-diagnostic-pack/research.md`, `specs/241-support-diagnostic-pack/data-model.md`, `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml`, and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
- [X] T002 [P] Verify the existing tenant dashboard, canonical tenantless operation viewer, and shared helper seams that this slice must reuse in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, and `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared derived-bundle, capability, and audit seams required by both entry contexts without adding persistence or a standalone support surface.
|
||||
|
||||
**Critical**: No user story work should start until this phase is complete.
|
||||
|
||||
- [X] T003 Create the derived support-diagnostics bundle shell with explicit `forTenant(...)` and `forOperationRun(...)` entry points and one documented runtime shape in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
- [X] T004 [P] Register `support_diagnostics.view` in the canonical tenant capability registry and tenant role map without adding any system-plane support variant in `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
|
||||
- [X] T005 [P] Add the shared audit action identifier and redacted bundle-open metadata path for both contexts in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
|
||||
**Checkpoint**: Foundation ready - both entry contexts can build on one derived bundle contract, one capability gate, and one audit path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Open A Tenant Support-Safe Bundle (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: An entitled operator can open one tenant-scoped support diagnostic bundle from the tenant dashboard and get deterministic, redacted, canonical context without manual cross-page reconstruction.
|
||||
|
||||
**Independent Test**: Seed an entitled tenant with provider, run, finding, stored-report, review, and audit truth, open support diagnostics from the tenant dashboard, and confirm the preview stays redacted, canonical, and authorization-safe.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T006 [P] [US1] Add tenant-context feature coverage for the redacted overview, provider or run or finding or report or review or audit references, freshness cues, and `404` versus `403` behavior in `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [US1] Implement tenant-context bundle collection from canonical tenant, provider connection, recent operation, findings, stored reports, tenant review or review pack, and audit truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
- [X] T008 [US1] Add the read-only `Open support diagnostics` header action and preview rendering on the tenant dashboard with capability gating after membership and entitlement are established in `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
||||
- [X] T009 [US1] Keep tenant-context provider excerpts and redaction notes on shared provider-owned helpers instead of page-local wording in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is independently functional when an entitled operator can open a tenant bundle and identify the current issue plus the next canonical records to inspect without seeing raw payload content.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Open A Run-Centered Support-Safe Bundle (Priority: P1)
|
||||
|
||||
**Goal**: An entitled operator already inspecting one run can open the same support-safe contract from the canonical operation detail surface and keep existing run language and navigation semantics.
|
||||
|
||||
**Independent Test**: Seed a failed or degraded run with related provider, finding, report, review, and audit truth, open support diagnostics from the tenantless operation detail viewer, and confirm the preview reuses humanized run wording plus canonical links only.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T010 [P] [US2] Add operation-context feature coverage for reused humanized run summary, canonical related links, explicit redaction markers, and deny-as-not-found boundaries in `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T011 [US2] Implement operation-context bundle collection by reusing the current run explanation builder and run-bound related-record truth in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`
|
||||
- [X] T012 [US2] Add the read-only `Open support diagnostics` header action to the canonical tenantless operation viewer and enforce `support_diagnostics.view` only after existing run access has resolved an entitled tenant scope in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- [X] T013 [US2] Reuse canonical operation and related-record navigation instead of support-local URLs in `apps/platform/app/Support/OperationRunLinks.php`, `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is independently functional when the run detail surface can open the same support bundle contract without changing `OperationRun` lifecycle truth or introducing a second link vocabulary.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Rely On Deterministic, Redacted Support Summaries (Priority: P2)
|
||||
|
||||
**Goal**: The same authorized tenant or run input always produces the same ordered, machine-readable, redacted support bundle so later tooling can reuse it safely.
|
||||
|
||||
**Independent Test**: Generate the same authorized tenant and run bundles repeatedly against unchanged truth and confirm stable section order, stable reference order, explicit missing or inaccessible placeholders, and redaction markers for sensitive provider detail.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T014 [P] [US3] Add unit coverage for stable section order, stable reference order, and graceful missing or inaccessible degradation in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`
|
||||
- [X] T015 [P] [US3] Add unit coverage for redaction markers, translated high-level provider reasons, and excluded raw payload fields in `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`
|
||||
- [X] T016 [P] [US3] Add shared feature coverage for cross-surface authorization boundaries so non-members or non-entitled actors stay `404` and capability-denied members stay `403` in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`
|
||||
- [X] T017 [P] [US3] Add feature coverage for bundle-open audit entries with redacted metadata for both tenant and run contexts, and assert that opening diagnostics stays DB-only, performs no outbound HTTP, dispatches no provider-backed work, creates no new `OperationRun`, and emits no queued OperationRun-start side effect, in `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T018 [US3] Finalize deterministic ordering, freshness and completeness cues, explicit missing or inaccessible placeholders, and redaction-marker output in `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and `apps/platform/app/Support/RedactionIntegrity.php`
|
||||
- [X] T019 [US3] Wire bundle-open audit recording from both read-only actions without storing excluded payload content in `apps/platform/app/Filament/Pages/TenantDashboard.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is independently functional when bundle output is deterministic, redacted, machine-readable, and auditable across both approved contexts.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Align the final contract docs, format touched files, and run the narrow validation set for this slice.
|
||||
|
||||
- [X] T020 [P] Update the support-diagnostics contract and manual validation notes to match the final tenant and run action behavior in `specs/241-support-diagnostic-pack/contracts/support-diagnostics.openapi.yaml` and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
- [X] T021 [P] Run Laravel Pint on touched PHP files through Sail before merge using `apps/platform/vendor/bin/sail`
|
||||
- [X] T022 Run the targeted unit and feature validation commands listed in `specs/241-support-diagnostic-pack/quickstart.md` after implementation completes, explicitly using those suites to prove the DB-only / no outbound HTTP / no queued OperationRun-start side-effect contract via `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleRedactionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/TenantSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/OperationRunSupportDiagnosticActionTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuthorizationTest.php`, and `apps/platform/tests/Feature/SupportDiagnostics/SupportDiagnosticAuditTest.php`
|
||||
- [X] T023 Record the final guardrail close-out, lane result, and any bounded `document-in-feature` versus `follow-up-spec` note for shared-helper or provider-boundary adjustments in `specs/241-support-diagnostic-pack/plan.md` and `specs/241-support-diagnostic-pack/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) starts immediately.
|
||||
- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories.
|
||||
- Phase 3 (US1) depends on Phase 2 and establishes the MVP tenant-context bundle.
|
||||
- Phase 4 (US2) depends on Phase 2 and is safest after US1 in practice because both stories extend `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` and the same canonical navigation helpers.
|
||||
- Phase 5 (US3) depends on US1 and US2 because it hardens deterministic output, shared authorization proof, and audit behavior across both approved contexts.
|
||||
- Phase 6 (Polish) depends on every implemented story.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 is the MVP and the first independently shippable increment.
|
||||
- US2 remains independently testable, but it reuses the same builder and helper seams as US1, so merge order should favor US1 first.
|
||||
- US3 depends on both P1 stories because deterministic ordering, redaction, authorization proof, and audit proof must cover the shared bundle contract across both contexts.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write the listed Pest coverage first and ensure it fails before implementation.
|
||||
- Complete shared builder or helper changes before the final page-action rendering pass when both are required.
|
||||
- Re-run the narrowest affected unit or feature suite after each story checkpoint before moving to the next story.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Opportunities
|
||||
|
||||
### Phase 1
|
||||
|
||||
- T001 and T002 can run in parallel if one person confirms the feature documents while another verifies the shared code seams.
|
||||
|
||||
### Phase 2
|
||||
|
||||
- T004 and T005 can run in parallel after T003 defines the shared bundle shell.
|
||||
|
||||
### User Story 1
|
||||
|
||||
- T006 can start before runtime edits.
|
||||
- T008 and T009 can overlap once T007 establishes the tenant-context bundle payload.
|
||||
|
||||
### User Story 2
|
||||
|
||||
- T010 can start before runtime edits.
|
||||
- T012 and T013 can overlap once T011 establishes the run-context bundle payload.
|
||||
|
||||
### User Story 3
|
||||
|
||||
- T014, T015, T016, and T017 can run in parallel.
|
||||
- T018 and T019 should stay sequential because both finalize the shared builder and action audit flow.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1.
|
||||
2. Complete Phase 2.
|
||||
3. Complete Phase 3 (US1).
|
||||
4. Re-run the targeted tenant-context suite and stop for review.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Deliver US1 to compress tenant-context support work first.
|
||||
2. Add US2 to bring the same contract to the canonical run-detail surface.
|
||||
3. Add US3 to harden deterministic output, redaction, authorization, and audit proof across both contexts.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. Finish Phase 2 together before splitting work.
|
||||
2. Parallelize test authoring inside each story.
|
||||
3. Sequence merges carefully around `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, because all three stories extend the same shared bundle contract.
|
||||
34
specs/242-operational-controls/checklists/requirements.md
Normal file
34
specs/242-operational-controls/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Operational Controls
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-26
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Selection rationale and scope narrowing are documented directly in `spec.md` so planning can proceed without a separate clarification pass.
|
||||
@ -0,0 +1,153 @@
|
||||
version: 1
|
||||
kind: operational-controls
|
||||
|
||||
catalog:
|
||||
control_keys:
|
||||
findings.lifecycle.backfill:
|
||||
label: Findings lifecycle backfill
|
||||
supported_scopes:
|
||||
- global
|
||||
- workspace
|
||||
operation_types:
|
||||
- findings.lifecycle.backfill
|
||||
affected_surfaces:
|
||||
- system.ops.runbooks
|
||||
- tenant.findings.list
|
||||
restore.execute:
|
||||
label: Restore execution
|
||||
supported_scopes:
|
||||
- global
|
||||
- workspace
|
||||
operation_types:
|
||||
- restore.execute
|
||||
affected_surfaces:
|
||||
- tenant.restore_runs.create
|
||||
|
||||
activation_record:
|
||||
table: operational_control_activations
|
||||
fields:
|
||||
id: integer
|
||||
control_key: string
|
||||
scope_type:
|
||||
type: string
|
||||
allowed:
|
||||
- global
|
||||
- workspace
|
||||
workspace_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
reason_text: string
|
||||
expires_at:
|
||||
type: datetime
|
||||
nullable: true
|
||||
created_by_platform_user_id: integer
|
||||
updated_by_platform_user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
display_rules:
|
||||
owner_actor: updated_by_platform_user_id when present, otherwise created_by_platform_user_id
|
||||
invariants:
|
||||
- one active row per control_key + scope_type + workspace_id
|
||||
- workspace_id is null for global rows
|
||||
- enabled state is derived from no active matching row
|
||||
persistence_notes:
|
||||
- enforce one active global row per control_key with a partial unique index where scope_type = global
|
||||
- enforce one active workspace row per control_key + workspace_id with a partial unique index where scope_type = workspace
|
||||
- delete expired conflicting rows before inserting a new activation for the same control/scope
|
||||
- do not use this table as an archive of expired activations
|
||||
|
||||
management_commands:
|
||||
pause_control:
|
||||
required_platform_capabilities:
|
||||
- platform.access_system_panel
|
||||
- platform.ops.controls.manage
|
||||
safety_flow:
|
||||
- configure scope and reason
|
||||
- preview scope impact
|
||||
- confirm mutation
|
||||
input:
|
||||
control_key: string
|
||||
scope_type: global|workspace
|
||||
workspace_id: integer|null
|
||||
reason_text: string
|
||||
expires_at: datetime|null
|
||||
outcome:
|
||||
activation_created_or_updated: true
|
||||
audit_action: operational_control.paused|operational_control.updated
|
||||
|
||||
resume_control:
|
||||
required_platform_capabilities:
|
||||
- platform.access_system_panel
|
||||
- platform.ops.controls.manage
|
||||
safety_flow:
|
||||
- review current scope impact
|
||||
- confirm mutation
|
||||
input:
|
||||
control_key: string
|
||||
scope_type: global|workspace
|
||||
workspace_id: integer|null
|
||||
outcome:
|
||||
activation_removed: true
|
||||
audit_action: operational_control.resumed
|
||||
|
||||
decision_output:
|
||||
fields:
|
||||
control_key: string
|
||||
effective_state: enabled|paused
|
||||
matched_scope_type: none|global|workspace
|
||||
workspace_id: integer|null
|
||||
reason_text: string|null
|
||||
expires_at: datetime|null
|
||||
source_activation_id: integer|null
|
||||
guarantees:
|
||||
- returned before any in-scope start is allowed to continue
|
||||
- blocked decisions create no queued execution OperationRun, no queued execution RestoreRun, no queued job, and no provider-backed execution
|
||||
- control activation governs new starts only and does not mutate previously accepted runs
|
||||
|
||||
evaluation_rules:
|
||||
precedence:
|
||||
- active global activation wins over any workspace activation for the same control key
|
||||
- workspace activation applies only when no active global activation matches
|
||||
expiry:
|
||||
- expired activations are ignored
|
||||
disclosure:
|
||||
- tenant/admin surfaces disclose control-state details only after membership and capability scope are resolved
|
||||
|
||||
enforcement_targets:
|
||||
- control_key: findings.lifecycle.backfill
|
||||
target:
|
||||
seam: service.runbooks.findings_lifecycle_backfill.start
|
||||
callers:
|
||||
- system.ops.runbooks
|
||||
- tenant.findings.list
|
||||
- console.tenantpilot.findings.backfill-lifecycle
|
||||
- console.tenantpilot.run-deploy-runbooks
|
||||
action: Start findings lifecycle backfill
|
||||
operation_type: findings.lifecycle.backfill
|
||||
- control_key: restore.execute
|
||||
target:
|
||||
surface: tenant.restore_runs.create
|
||||
action: Execute restore
|
||||
operation_type: restore.execute
|
||||
|
||||
audit_expectations:
|
||||
action_ids:
|
||||
- operational_control.paused
|
||||
- operational_control.updated
|
||||
- operational_control.resumed
|
||||
- operational_control.execution_blocked
|
||||
required_metadata:
|
||||
- control_key
|
||||
- scope_type
|
||||
- workspace_id
|
||||
- reason_text
|
||||
- expires_at
|
||||
- actor_id
|
||||
event_specific_metadata:
|
||||
blocked_system_all_tenant_execution_events:
|
||||
- requested_scope
|
||||
ownership:
|
||||
global_control_changes: platform-plane event with null workspace_id and null tenant_id
|
||||
workspace_control_changes: workspace-scoped event
|
||||
blocked_execution_events: scoped to the affected workspace and tenant when a tenant is in context
|
||||
blocked_system_all_tenant_execution_events: platform-plane event with null workspace_id and null tenant_id plus requested_scope metadata
|
||||
164
specs/242-operational-controls/data-model.md
Normal file
164
specs/242-operational-controls/data-model.md
Normal file
@ -0,0 +1,164 @@
|
||||
# Data Model — Operational Controls
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
The first operational-controls slice adds one persisted runtime-safety record and two derived runtime concepts. It reuses existing execution and audit truth.
|
||||
|
||||
## Existing Canonical Entities Reused
|
||||
|
||||
### Workspace (`workspaces`)
|
||||
|
||||
**Purpose**: Existing workspace boundary for targeted operational-control scope.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `name`
|
||||
|
||||
**Feature use**:
|
||||
- Identifies the workspace targeted by a workspace-scoped control activation.
|
||||
- Continues to anchor workspace isolation and audit scope.
|
||||
|
||||
### Tenant (`tenants`)
|
||||
|
||||
**Purpose**: Existing tenant boundary for the affected execution surfaces.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `name`
|
||||
- `external_id`
|
||||
|
||||
**Feature use**:
|
||||
- Supplies workspace context for findings and restore execution checks.
|
||||
- Does not own control records in this slice.
|
||||
|
||||
### PlatformUser (`platform_users` or equivalent platform-authenticated user model)
|
||||
|
||||
**Purpose**: Existing platform-plane actor for control management.
|
||||
|
||||
**Feature use**:
|
||||
- Owns pause/resume actions in the system plane.
|
||||
- Supplies actor identity for audit and attribution on control changes.
|
||||
|
||||
### OperationRun (`operation_runs`)
|
||||
|
||||
**Purpose**: Existing canonical execution truth for in-scope starts when execution is allowed.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `context`
|
||||
|
||||
**Feature use**:
|
||||
- Remains the only execution truth for allowed starts.
|
||||
- Must not be created when an in-scope start is blocked by an active control.
|
||||
- Existing queued or historical `OperationRun` records remain unchanged when a later control activation blocks only new starts.
|
||||
|
||||
### RestoreRun (`restore_runs`)
|
||||
|
||||
**Purpose**: Existing restore execution truth for queued restore work.
|
||||
|
||||
**Feature use**:
|
||||
- No new queued execution `RestoreRun` is created by a blocked `restore.execute` start path.
|
||||
- Continues to link to `OperationRun` only when execution is allowed.
|
||||
|
||||
### AuditLog (`audit_logs`)
|
||||
|
||||
**Purpose**: Existing audit truth for control changes and blocked execution evidence.
|
||||
|
||||
**Feature use**:
|
||||
- Records pause, update, resume, and blocked-execution events with stable action IDs.
|
||||
- Avoids introducing a second historical record model for the first slice.
|
||||
|
||||
## New Persisted Entity
|
||||
|
||||
### OperationalControlActivation (`operational_control_activations`)
|
||||
|
||||
**Purpose**: The active runtime-safety record that pauses one bounded control key for either all workspaces or one specific workspace.
|
||||
|
||||
**Key fields**:
|
||||
- `id`
|
||||
- `control_key` — bounded to the first-slice catalog keys `findings.lifecycle.backfill` and `restore.execute`
|
||||
- `scope_type` — `global` or `workspace`
|
||||
- `workspace_id` — nullable; required when `scope_type = workspace`
|
||||
- `reason_text`
|
||||
- `expires_at` — nullable
|
||||
- `created_by_platform_user_id`
|
||||
- `updated_by_platform_user_id` — nullable
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
**Display rule**:
|
||||
- `owner` on the controls surface resolves to `updated_by_platform_user_id` when present, otherwise `created_by_platform_user_id`.
|
||||
|
||||
**Constraints**:
|
||||
- At most one active row per `control_key + scope_type + workspace_id` combination.
|
||||
- `workspace_id` must be null for `global` scope and present for `workspace` scope.
|
||||
- Expired rows are ignored by the evaluator.
|
||||
- PostgreSQL uniqueness is enforced with partial unique indexes: one active global row per `control_key` where `scope_type = global`, and one active workspace row per `control_key + workspace_id` where `scope_type = workspace`.
|
||||
- Writes must delete expired conflicting rows before inserting a new activation so ignored expired rows do not block a new active pause.
|
||||
|
||||
**Lifecycle**:
|
||||
- Created when a control is paused.
|
||||
- Updated when reason or expiry changes.
|
||||
- Expired rows are deleted by the write path before a replacement activation for the same control/scope is inserted.
|
||||
- Removed when the control is resumed.
|
||||
- No explicit `enabled` rows are stored; enabled is derived from no active matching row.
|
||||
|
||||
**Relationships**:
|
||||
- Optionally `belongsTo Workspace`
|
||||
- `createdBy` / `updatedBy` platform-user relations if the existing platform-user model supports them
|
||||
|
||||
## Derived Runtime Entities
|
||||
|
||||
### OperationalControlDefinition (derived, not persisted)
|
||||
|
||||
**Purpose**: Catalog metadata for one controllable risky action.
|
||||
|
||||
**Proposed runtime fields**:
|
||||
- `key`
|
||||
- `label`
|
||||
- `supported_scopes`
|
||||
- `operation_types`
|
||||
- `affected_surfaces`
|
||||
- `default_state` (derived `enabled`)
|
||||
|
||||
**Feature use**:
|
||||
- Drives the controls page and evaluator without turning the catalog into a user-managed taxonomy.
|
||||
|
||||
### OperationalControlDecision (derived, not persisted)
|
||||
|
||||
**Purpose**: The evaluated result returned to an affected surface or service start seam.
|
||||
|
||||
**Proposed runtime fields**:
|
||||
- `control_key`
|
||||
- `effective_state` — `enabled` or `paused`
|
||||
- `matched_scope_type` — `global`, `workspace`, or `none`
|
||||
- `workspace_id` — nullable
|
||||
- `reason_text` — nullable when enabled
|
||||
- `expires_at` — nullable
|
||||
- `source_activation_id` — nullable
|
||||
|
||||
**Feature use**:
|
||||
- Tells a surface whether execution may proceed.
|
||||
- Supplies one shared reason for blocked-state messaging and audit context.
|
||||
|
||||
## Evaluation Rules
|
||||
|
||||
- The evaluator resolves workspace context before checking control scope.
|
||||
- A matching global activation wins over a workspace activation in v1. Workspace-scoped activations only take effect when no active global activation exists for the same control.
|
||||
- Expired activations do not block execution.
|
||||
- Missing entitlement or missing capability is resolved before control-state disclosure on tenant/admin surfaces.
|
||||
|
||||
## Data Ownership Notes
|
||||
|
||||
- No tenant-owned control records are introduced in the first slice.
|
||||
- Control activations are platform-operated runtime-safety truth.
|
||||
- Global control changes audit as platform-plane events with null workspace/tenant ownership.
|
||||
- Workspace-targeted changes and blocked execution events with concrete workspace/tenant context retain truthful workspace/tenant audit scope.
|
||||
- Blocked system-plane all-tenant attempts audit as platform-plane events with null workspace/tenant ownership plus requested-scope metadata.
|
||||
- Tenant/admin surfaces consume only the derived decision, never direct activation editing.
|
||||
232
specs/242-operational-controls/plan.md
Normal file
232
specs/242-operational-controls/plan.md
Normal file
@ -0,0 +1,232 @@
|
||||
# Implementation Plan: Operational Controls
|
||||
|
||||
**Branch**: `242-operational-controls` | **Date**: 2026-04-26 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
- Replace the ad-hoc `allow_admin_maintenance_actions` environment gate with one product-owned operational-control path for the first-slice keys `findings.lifecycle.backfill` and `restore.execute`.
|
||||
- Introduce one platform-operated activation record plus one shared evaluator that plugs into the existing system runbook, tenant findings-maintenance, and restore-execution start seams without becoming a generic experimentation platform.
|
||||
- Reuse existing enforcement and UX seams - `UiEnforcement`, `ProviderOperationStartGate`, `OperationRunService`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `AuditRecorder`, `WorkspaceAuditLogger`, and `AuditActionId` - so the slice stays small, auditable, and server-side enforced.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `UiEnforcement`, `ProviderOperationStartGate`, `OperationRunService`, `AuditRecorder`, `WorkspaceAuditLogger`, `AuditActionId`, `PlatformCapabilities`
|
||||
**Storage**: PostgreSQL via existing product tables plus one new platform-operated `operational_control_activations` table; no tenant-owned control tables
|
||||
**Testing**: Pest unit + feature tests only
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Sail-backed Laravel admin surfaces under `/admin/t/{tenant}` and system surfaces under `/system`
|
||||
**Project Type**: web
|
||||
**Performance Goals**: effective-control resolution remains DB-only and cheap at action start time, adds no outbound HTTP, and blocks in-scope starts before queue or provider execution begins
|
||||
**Constraints**: no generic feature-flag platform, no new browser or heavy-governance suite, no break-glass bypass in v1, no parallel env gate for in-scope controls, global pauses win over workspace pauses, preserve 404 vs 403 semantics, keep provider-specific restore behavior out of platform-core control vocabulary
|
||||
**Scale/Scope**: 2 control keys, 2 scope levels (global and workspace), 1 system management surface, and 3 concrete enforcement families across 4 touched UI surfaces
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament + shared start/result primitives
|
||||
- **Shared-family relevance**: header actions, runbook launch actions, provider-backed start results, audit-backed control changes
|
||||
- **State layers in scope**: page, detail, action/modal
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: standard-native-filament, monitoring-state-page
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: none; v1 must not allow a second local runtime-control dialect
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `App\Filament\System\Pages\Ops\Runbooks`, new system ops controls page, `App\Filament\Resources\FindingResource\Pages\ListFindings`, `App\Filament\Resources\RestoreRunResource`, `App\Support\Rbac\UiEnforcement`, `App\Services\Providers\ProviderOperationStartGate`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ProviderOperationStartResultPresenter`, `App\Services\Audit\AuditRecorder`, `App\Services\Audit\WorkspaceAuditLogger`, `App\Support\Audit\AuditActionId`
|
||||
- **Shared abstractions reused**: `UiEnforcement`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationRunService`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `OperationRunLinks`, `SystemOperationRunLinks`, `AuditRecorder`, `WorkspaceAuditLogger`
|
||||
- **New abstraction introduced? why?**: one bounded `OperationalControlCatalog` plus one `OperationalControlEvaluator` are justified because the feature now has two real concrete control keys that must evaluate consistently across system-plane and tenant-plane start paths. No registry lattice, provider strategy system, or customer-facing flag DSL is introduced.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: existing abstractions already own auth, queue start UX, and audit writing; they are insufficient because none presently carries a reusable runtime-safety decision that can pause an action before it starts, and `WorkspaceAuditLogger` alone cannot truthfully own global platform-plane mutations.
|
||||
- **Bounded deviation / spread control**: no deviation is allowed for in-scope controls; every affected surface must route through the shared evaluator rather than direct `config(...)` reads or page-local booleans.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Central contract reused**: shared OperationRun start UX plus provider-start result helpers
|
||||
- **Delegated UX behaviors**: queued toast, `Open operation` / `View run` links, run-enqueued browser event, dedupe-or-blocked messaging, and tenant/workspace-safe URL resolution remain on existing shared paths
|
||||
- **Surface-owned behavior kept local**: initiation inputs, confirmation copy, and control-management forms only
|
||||
- **Queued DB-notification policy**: unchanged explicit opt-in only
|
||||
- **Terminal notification path**: existing central lifecycle mechanism for starts that are allowed
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Provider-owned seams**: provider-backed `restore.execute` dispatch, provider binding resolution, provider reason translation, existing restore safety and dry-run behavior
|
||||
- **Platform-core seams**: operational-control vocabulary, scope/effective-state evaluation, control management surface, audit labels, blocked-state semantics
|
||||
- **Neutral platform terms / contracts preserved**: operational control, activation, effective state, scope, reason, expiry, blocked execution
|
||||
- **Retained provider-specific semantics and why**: `restore.execute` remains Microsoft-specific provider behavior in the current release because the control feature governs only start allowance, not provider execution semantics
|
||||
- **Bounded extraction or follow-up path**: none in this slice; future catalog growth or provider-neutral expansions require a follow-up spec instead of implicit widening here
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Read/write separation: PASS - control management is an explicit platform-plane mutation with confirmation, audit, and focused tests; blocked execution paths remain non-mutating except for audit logging.
|
||||
- RBAC-UX: PASS - platform management stays on `/system`; tenant/admin execution surfaces stay on `/admin/t/{tenant}`; cross-plane access remains 404; entitled-but-paused users get explicit control feedback while membership and capability failures keep 404/403 semantics.
|
||||
- Workspace isolation / tenant isolation: PASS - workspace-targeted controls apply only within the chosen workspace; tenant surfaces still resolve tenant/workspace entitlement before control-state disclosure.
|
||||
- Run observability / Ops-UX: PASS - allowed starts reuse existing `OperationRun` paths; blocked starts create no run and no new lifecycle dialect; later control activation does not retroactively mutate already accepted runs; shared start/result helpers remain authoritative.
|
||||
- Shared path reuse / `XCUT-001`: PASS - the design extends existing UI enforcement, provider-start gating, audit logging, and operation start UX instead of introducing page-local flags.
|
||||
- Provider boundary / `PROV-001`: PASS - control language stays provider-neutral while restore execution remains provider-owned.
|
||||
- Proportionality / `PROP-001` and `ABSTR-001`: PASS - the only new structure is justified by two current-release controls and three existing enforcement surfaces; no experimentation platform or generalized remote-config system is planned.
|
||||
- Persisted truth / `PERSIST-001`: PASS - active control activations represent independent runtime-safety truth with their own scope, reason, expiry, and audit obligations; convenience UI state remains derived.
|
||||
- Behavioral state / `STATE-001`: PASS - paused/enabled semantics change whether execution may start and therefore justify one bounded effective-state model.
|
||||
- Filament-native UI / `UI-FIL-001`: PASS - all touched surfaces remain native Filament pages/resources/actions; no custom UI framework is introduced.
|
||||
- Global search rule: N/A - no new globally searchable resource is added.
|
||||
- Panel/provider registration: PASS - Filament v5 remains on Livewire v4 and no new panel/provider registration is required; Laravel 12 provider registration stays in `bootstrap/providers.php` if any provider change becomes necessary.
|
||||
- Test governance / `TEST-GOV-001`: PASS - proof stays in focused unit and feature lanes with no browser or heavy-governance expansion.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for catalog/evaluator/scope precedence/expiry logic; Feature for system control management, runbook enforcement, findings header-action enforcement, restore-execution enforcement, audit logging, and `404`/`403` semantics
|
||||
- **Affected validation lanes**: fast-feedback, confidence
|
||||
- **Why this lane mix is the narrowest sufficient proof**: the business truth is server-side effective-state resolution plus enforcement at existing Filament and service seams. Browser tests would duplicate modal choreography without proving additional runtime safety truth.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: add one local factory for active control activations plus platform-user and workspace-scoped setup helpers reused only by operational-control tests; avoid new shared browser or provider-fixture defaults
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; control fixtures stay opt-in and local to the new test family
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: standard-native-filament and monitoring-state-page relief are sufficient; assert disabled/blocked behavior and no side effects instead of browser-only choreography
|
||||
- **Closing validation and reviewer handoff**: reviewers should rerun the targeted unit/feature commands, verify the env gate is removed from the in-scope findings action, confirm restore execution is blocked before queue/provider start, confirm blocked-execution audit entries exist for runbook/findings/restore paths, confirm global control changes audit without false workspace ownership, confirm `/system/ops/controls` returns 403 for system users missing `platform.ops.controls.manage`, and confirm non-members still receive 404 while missing capabilities still receive 403 with the existing capability-denied UX rather than paused-state helper text
|
||||
- **Budget / baseline / trend follow-up**: low-to-moderate increase in focused unit/feature coverage only
|
||||
- **Review-stop questions**: did implementation add a second control persistence shape, leave the env gate in place, introduce a local blocked-state dialect, or widen into browser/heavy-governance lanes?
|
||||
- **Escalation path**: `reject-or-split` if the implementation widens into generic feature-flagging or customer-managed controls; `document-in-feature` for small shared-helper extensions that remain local to this slice
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: the planned new model, evaluator, and tests stay local to the first-slice control family; recurring growth beyond the two bounded control keys would require its own follow-up spec
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/242-operational-controls/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
├── contracts/
|
||||
│ └── operational-controls.contract.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/System/Pages/Ops/
|
||||
│ │ ├── Controls.php
|
||||
│ │ └── Runbooks.php
|
||||
│ ├── Filament/Resources/FindingResource/Pages/ListFindings.php
|
||||
│ ├── Filament/Resources/RestoreRunResource.php
|
||||
│ ├── Models/
|
||||
│ │ └── OperationalControlActivation.php
|
||||
│ ├── Services/Audit/AuditRecorder.php
|
||||
│ ├── Services/Audit/WorkspaceAuditLogger.php
|
||||
│ ├── Services/Providers/ProviderOperationStartGate.php
|
||||
│ ├── Support/Audit/AuditActionId.php
|
||||
│ ├── Support/Auth/PlatformCapabilities.php
|
||||
│ └── Support/OperationalControls/
|
||||
│ ├── OperationalControlCatalog.php
|
||||
│ ├── OperationalControlDecision.php
|
||||
│ └── OperationalControlEvaluator.php
|
||||
├── database/
|
||||
│ ├── factories/
|
||||
│ │ └── OperationalControlActivationFactory.php
|
||||
│ └── migrations/
|
||||
│ └── *_create_operational_control_activations_table.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── Findings/OperationalControlFindingsBackfillGateTest.php
|
||||
│ ├── OperationalControls/
|
||||
│ │ ├── NoAdHocOperationalControlBypassTest.php
|
||||
│ │ └── OperationalControlAuthorizationSemanticsTest.php
|
||||
│ ├── Restore/OperationalControlRestoreExecutionGateTest.php
|
||||
│ ├── System/OpsControls/OperationalControlManagementTest.php
|
||||
│ └── System/OpsRunbooks/OperationalControlRunbookGateTest.php
|
||||
└── Unit/Support/OperationalControls/
|
||||
├── OperationalControlCatalogTest.php
|
||||
├── OperationalControlEvaluatorTest.php
|
||||
└── OperationalControlScopeResolutionTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Single Laravel web application. The feature adds one bounded platform-operated model and one small support namespace for operational-control evaluation, then plugs that into existing system and tenant Filament surfaces.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No unapproved constitution violations are required. The only new persistence and abstraction are the justified control-activation record plus evaluator/catalog pair described below.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: founders and platform operators need a safe runtime way to pause already-existing risky actions without editing environment variables or relying on inconsistent per-surface logic.
|
||||
- **Existing structure is insufficient because**: `UiEnforcement` decides RBAC, `ProviderOperationStartGate` decides provider readiness, and env flags decide hidden page-local runtime behavior. None of those alone gives one auditable runtime-safety truth across both system and tenant surfaces.
|
||||
- **Narrowest correct implementation**: persist only explicit active control activations, derive the enabled state from absence of an activation, evaluate one effective decision through a shared catalog/evaluator, and wire that into the three concrete existing start paths.
|
||||
- **Ownership cost created**: one new table/model/factory, one small support namespace, one system page, new audit action IDs and capability constants, and focused unit/feature coverage.
|
||||
- **Alternative intentionally rejected**: keep env/config flags, reuse workspace settings, or build a generalized feature-flag system. Env/config flags are invisible product truth, workspace settings do not cleanly represent one global control truth, and a generic flag platform is far too broad.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
## Phase 0 — Research (output: `research.md`)
|
||||
|
||||
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/research.md`
|
||||
|
||||
Goals:
|
||||
- Confirm the narrowest persistence shape for runtime-safety truth and explicitly reject env-only or workspace-settings-only alternatives.
|
||||
- Confirm the smallest shared seam where control evaluation belongs for system runbooks, tenant findings lifecycle backfill, and provider-backed restore execution.
|
||||
- Define v1 scoping, global-first precedence, expiry, and audit expectations without inventing a generic flag taxonomy.
|
||||
- Document the v1 decision that break-glass and broad platform capabilities do not bypass an active operational control.
|
||||
|
||||
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||
|
||||
See:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/contracts/operational-controls.contract.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/quickstart.md`
|
||||
|
||||
Design focus:
|
||||
- Add one platform-operated activation record that can pause a control globally or for one workspace, with optional expiry, auditable reason, global-first precedence, and partial unique indexes that enforce one active global row per control and one active workspace row per control/workspace pair; the write path deletes expired conflicting rows before inserting a new activation, and this table is not used as an archive.
|
||||
- Add one new system ops controls page that lists the two bounded control keys, their effective state, scope, owner, expiry, change actions, and on-demand audit history links, and uses a staged scope-impact preview before control mutations are confirmed.
|
||||
- Use `OperationalControlDecision` as the shared control-state presentation primitive for controls, runbooks, findings, and restore surfaces.
|
||||
- Route `findings.lifecycle.backfill` through the new evaluator in both `ListFindings` and `Runbooks`, removing the existing env gate.
|
||||
- Route `findings.lifecycle.backfill` through `FindingsLifecycleBackfillRunbookService::start()` so the system runbooks page, tenant findings page, CLI command, and deploy-hook command all honor the same control decision.
|
||||
- Route `restore.execute` through the same evaluator before provider-backed or non-provider-backed queued restore execution is created.
|
||||
- Add dedicated audit action IDs and a dedicated platform capability for control management, using `AuditRecorder` for global control changes and blocked system-plane all-tenant attempts, and `WorkspaceAuditLogger` for workspace/tenant-scoped changes and blocked-execution evidence with concrete scope.
|
||||
- Keep blocked-state messaging on existing shared start/result helpers and avoid custom control-state UI frameworks.
|
||||
|
||||
## Phase 1 — Agent Context Update
|
||||
|
||||
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||
|
||||
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
- Add the `operational_control_activations` persistence, model, and local factory for active pause records.
|
||||
- Introduce the bounded operational-controls support namespace (`OperationalControlCatalog`, `OperationalControlDecision`, `OperationalControlEvaluator`) and keep enabled-state derived from active rows.
|
||||
- Add the dedicated controls-manage capability and its local grant path in the seeded platform operator setup.
|
||||
- Add the system-plane controls page and wire it into the existing system ops navigation with staged preview-plus-confirm pause/resume actions, audit logging, and on-demand audit history links.
|
||||
- Replace the findings env gate with evaluator-driven control checks on the tenant findings header action and the system runbooks start path.
|
||||
- Integrate the same evaluator into restore execution before any queued execution `OperationRun`, queued execution `RestoreRun`, queue dispatch, or provider-backed execution starts.
|
||||
- Add focused unit and feature tests, plus a guard test that blocks new ad-hoc runtime-control bypasses for in-scope controls and one proving path that activating a control does not rewrite previously accepted runs.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check target: PASS. The post-design shape must still use one bounded control catalog, one active-row persistence model, one evaluator, existing auth/start/audit helpers, and no second runtime-control dialect.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Delivered the bounded operational-controls slice end-to-end: one `operational_control_activations` truth model, one catalog/evaluator/decision support path, a new `/system/ops/controls` management page, findings lifecycle enforcement through `FindingsLifecycleBackfillRunbookService::start()`, and restore execution blocking before any queued execution `OperationRun`, queued execution `RestoreRun`, job dispatch, or provider-backed start.
|
||||
- Runtime cleanup landed with the in-scope findings env gate removed from `config/tenantpilot.php`, a source-scanning guard against ad-hoc bypasses, and workspace-isolation proof showing a workspace-scoped pause blocks only the targeted workspace while a second workspace remains unaffected.
|
||||
- Validation passed on the narrow feature lane: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` with `20 passed (253 assertions)`.
|
||||
- Formatting passed with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||
- Manual smoke passed in the integrated browser: the staged pause/resume flow on `/system/ops/controls` for `Findings lifecycle backfill` rendered scope-impact previews, applied the global pause, and returned to `Enabled` inside the SC-001 budget after bringing the local database up to date.
|
||||
50
specs/242-operational-controls/quickstart.md
Normal file
50
specs/242-operational-controls/quickstart.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Quickstart — Operational Controls
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
- A platform user able to access `/system`
|
||||
- Existing workspace, tenant, findings, restore-run, and operation-run factories available for tests
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||
- Run migrations for the new activation table: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
|
||||
- Refresh the seeded local platform operator after the new capability is added: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan db:seed --class=PlatformUserSeeder --no-interaction`
|
||||
- Run targeted tests after implementation:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- Full narrow suite: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
|
||||
## Manual smoke after implementation
|
||||
|
||||
1. Sign in to `/system` as a platform operator with `platform.access_system_panel` and the new operational-controls management capability.
|
||||
2. Sign in as a system user without the operational-controls management capability and verify `/system/ops/controls` returns 403 with the existing capability-denied UX rather than paused-state helper text.
|
||||
3. Open `/system/ops/controls`, begin pausing `Findings lifecycle backfill` globally, verify the modal shows scope-impact preview before confirmation, then confirm and verify the control card exposes on-demand change history or an audit link for that change.
|
||||
4. Open `/system/ops/runbooks`, choose the all-tenants findings-lifecycle path, and verify the runbook path shows an explicit paused-state message and does not start a run.
|
||||
5. Open `/admin/t/{tenant}/findings` as an entitled tenant user and verify `Backfill findings lifecycle` is still presented truthfully for entitled users but blocked with the same control reason.
|
||||
6. Invoke `tenantpilot:findings:backfill-lifecycle --tenant={tenant_id}` and verify the shared findings lifecycle service blocks the start with the same control state.
|
||||
7. Pause `Restore execution` for one workspace only, then verify an entitled tenant in that workspace cannot start restore execution, no queued execution `RestoreRun` or `OperationRun` is created by the blocked start path, and a blocked-execution audit entry is recorded.
|
||||
8. Verify an entitled tenant in a different workspace remains unaffected for `Restore execution`.
|
||||
9. Resume both controls and confirm the normal start paths return without a deploy or env edit.
|
||||
10. Verify audit entries exist for global pause/resume, workspace-targeted pause/resume, and blocked execution on the runbook, findings, and restore paths; confirm the blocked all-tenants runbook attempt is recorded as a platform-plane event with requested-scope metadata.
|
||||
11. Time one pause or resume flow on `/system/ops/controls` and confirm the staged preview-plus-confirm path completes in under 1 minute.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 remains on Livewire v4.0+ in this repo; the slice stays on native Filament pages/resources/actions.
|
||||
- No panel provider registration changes are planned; Laravel 12 provider registration remains in `bootstrap/providers.php` if any provider change becomes necessary.
|
||||
- No global-search behavior changes are involved because the slice adds no new searchable resource.
|
||||
- The state-changing control actions are destructive-like and must use `->requiresConfirmation()`.
|
||||
- Global pauses win over workspace-specific pauses in v1; no narrower workspace record re-enables a globally paused control.
|
||||
- No new frontend asset pipeline is expected; no new `filament:assets` deploy step is needed unless implementation adds registered assets later.
|
||||
|
||||
## Implementation Close-out
|
||||
|
||||
- Guardrail result: `tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` passed after narrowing the forbidden config check to the retired `tenantpilot.allow_admin_maintenance_actions` path instead of unrelated `tenantpilot` reads.
|
||||
- Latest targeted validation passed: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php tests/Feature/Filament/Spec113/AdminFindingsNoMaintenanceActionsTest.php tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php` with `20 passed (253 assertions)`.
|
||||
- Shared-helper note: `OperationalControlDecision` now exposes workspace-aware presentation helpers, the findings path routes through `FindingsLifecycleBackfillRunbookService::start()`, and restore execution is blocked before any queued execution `OperationRun`, queued execution `RestoreRun`, queue dispatch, or provider call.
|
||||
- Manual smoke status: passed in the integrated browser on `http://localhost/system/ops/controls` after seeding the local platform operator and running the pending operational-controls migration; the staged global pause and resume flow for `Findings lifecycle backfill` completed successfully within the SC-001 budget.
|
||||
133
specs/242-operational-controls/research.md
Normal file
133
specs/242-operational-controls/research.md
Normal file
@ -0,0 +1,133 @@
|
||||
# Research — Operational Controls
|
||||
|
||||
**Date**: 2026-04-26
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This document captures design decisions and supporting rationale for the first operational-controls slice. All decisions are grounded in current repository truth and the TenantPilot Constitution.
|
||||
|
||||
## Decision 1 — Persist only active pause records, derive the enabled state, and let global pauses win
|
||||
|
||||
**Decision**: Store only explicit active control activations that pause a control. Do not persist `enabled` rows or a broader multi-state lifecycle. The effective `enabled` state is derived from the absence of an active matching activation, and a matching global pause wins over a narrower workspace pause in v1.
|
||||
|
||||
**Rationale**:
|
||||
- The operator problem is safe runtime pause control, not a new workflow state machine.
|
||||
- Constitution `PERSIST-001` and `STATE-001` favor the smallest persisted truth that changes behavior.
|
||||
- Deriving `enabled` avoids importing a second layer of default-state maintenance.
|
||||
- Global-first precedence is the safest bounded rule because a platform-wide incident pause must not be narrowed by a workspace-specific row in this first slice.
|
||||
|
||||
**Evidence**:
|
||||
- The current code gap is an env-gated yes/no maintenance switch in `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`.
|
||||
- The first slice only needs to answer one question at execution time: may this action start right now for this scope?
|
||||
- The first slice does not support workspace-specific allow overrides, so no narrower row should reopen a globally paused control.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Persist both `enabled` and `paused` rows.
|
||||
- Rejected: unnecessary state duplication; absence of an active pause already means enabled.
|
||||
- Add a larger status family such as draft, scheduled, paused, forced, emergency.
|
||||
- Rejected: too broad for current-release truth.
|
||||
|
||||
## Decision 2 — Use one platform-operated activation table instead of env flags or workspace settings
|
||||
|
||||
**Decision**: Introduce one platform-operated `operational_control_activations` table that can represent either a global pause or a workspace-scoped pause. Do not split truth across env flags, platform config, and workspace settings.
|
||||
|
||||
**Rationale**:
|
||||
- The spec requires one auditable control truth across system and tenant surfaces.
|
||||
- Existing workspace settings infrastructure is workspace-only and cannot represent one global platform-wide safety state cleanly.
|
||||
- Env flags are invisible product truth and require deploy-time coordination.
|
||||
|
||||
**Evidence**:
|
||||
- Existing workspace settings writer only manages workspace-scoped settings in `apps/platform/app/Services/Settings/SettingsWriter.php`.
|
||||
- The current env gate lives in `apps/platform/config/tenantpilot.php` and is consumed directly in `ListFindings`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Reuse workspace settings for workspace overrides and keep a global env flag.
|
||||
- Rejected: split truth, inconsistent audit semantics, and no single effective-state evaluator.
|
||||
- Use env flags only.
|
||||
- Rejected: not operator-visible or auditable in-product.
|
||||
|
||||
## Decision 3 — Evaluate controls at the start seam, not only in UI visibility
|
||||
|
||||
**Decision**: Integrate control evaluation at the concrete start seams that already own execution decisions: `FindingsLifecycleBackfillRunbookService::start()` for all findings lifecycle backfill callers, and queued restore execution before `OperationRun` or provider dispatch begins.
|
||||
|
||||
**Rationale**:
|
||||
- UI-only hiding would fail the safety requirement because direct requests or stale page state could still start execution.
|
||||
- The repo already has clear start seams where action or service logic decides whether a run begins.
|
||||
- This keeps blocked-state truth server-side and shared.
|
||||
|
||||
**Evidence**:
|
||||
- Findings lifecycle backfill starts in `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php` and is called from the system runbooks page, tenant findings page, `tenantpilot:findings:backfill-lifecycle`, and `tenantpilot:run-deploy-runbooks`.
|
||||
- Restore execution starts in `apps/platform/app/Filament/Resources/RestoreRunResource.php` and already routes provider-backed starts through `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide or disable actions in UI only.
|
||||
- Rejected: violates the server-side enforcement requirement.
|
||||
|
||||
## Decision 4 — Add one system ops controls page instead of surface-local toggles
|
||||
|
||||
**Decision**: Manage the first-slice controls from one dedicated system ops page under `/system/ops/controls`. Do not add per-page toggles or bury control changes inside each affected surface. The page shows effective-state summaries by default and exposes change history through on-demand audit links instead of creating a second history surface.
|
||||
|
||||
**Rationale**:
|
||||
- Operators need one place to make the runtime-safety decision itself.
|
||||
- Constitution `DECIDE-001` and the spec’s decision-role table require a primary decision surface for control management.
|
||||
- A shared control center prevents drift between runbooks, findings, and restore surfaces.
|
||||
|
||||
**Evidence**:
|
||||
- The repo already groups ops surfaces under `apps/platform/app/Filament/System/Pages/Ops/`.
|
||||
- Existing runbooks and run viewers are already system-plane ops surfaces, so a sibling controls page fits the current information architecture.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a toggle to the runbooks page only.
|
||||
- Rejected: restore execution is not owned by that page and the control decision would stay fragmented.
|
||||
|
||||
## Decision 5 — Break-glass does not bypass operational controls in v1
|
||||
|
||||
**Decision**: Break-glass sessions do not automatically bypass active operational controls in the first slice.
|
||||
|
||||
**Rationale**:
|
||||
- Operational controls are introduced as runtime-safety truth, not as optional UI friction.
|
||||
- An implicit bypass would make incident behavior ambiguous and weaken auditability.
|
||||
- The first slice stays safer by forcing an explicit resume action before execution.
|
||||
|
||||
**Evidence**:
|
||||
- The system runbook page already has break-glass-aware reason requirements via `BreakGlassSession`, but operational controls are a distinct safety layer.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Let break-glass ignore controls.
|
||||
- Rejected: too risky for v1 and not required by current operator pain.
|
||||
|
||||
## Decision 6 — Reuse existing audit and start-result helpers, but keep global audits platform-scoped
|
||||
|
||||
**Decision**: Keep workspace-targeted changes and blocked execution evidence with concrete workspace or tenant context on `WorkspaceAuditLogger` plus `AuditActionId`, but record global control changes and blocked system-plane all-tenant attempts through `AuditRecorder` directly so they stay platform-plane events without false workspace ownership. Include requested-scope metadata on those platform-plane blocked attempts. Keep blocked/allowed execution messaging on the existing operation/provider start-result helpers.
|
||||
|
||||
**Rationale**:
|
||||
- Constitution `XCUT-001` requires reuse of existing shared interaction paths.
|
||||
- The repo already has shared primitives for queued toasts, dedupe messaging, and audit summaries.
|
||||
- This avoids a second language for blocked execution.
|
||||
- `WorkspaceAuditLogger` requires a `Workspace`, while `AuditRecorder` already supports null workspace and null tenant for truthful system-plane events.
|
||||
|
||||
**Evidence**:
|
||||
- Audit logging lives in `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`.
|
||||
- Global system-plane audit support lives in `apps/platform/app/Services/Audit/AuditRecorder.php`.
|
||||
- Canonical audit IDs live in `apps/platform/app/Support/Audit/AuditActionId.php`.
|
||||
- Provider-backed start messaging already routes through `ProviderOperationStartResultPresenter` and `OperationUxPresenter`.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Emit page-local notifications and free-form audit action strings.
|
||||
- Rejected: immediate drift risk and weaker reviewability.
|
||||
|
||||
## Decision 7 — Proof stays in Unit + Feature lanes only
|
||||
|
||||
**Decision**: Keep proof in focused unit and feature tests. Do not introduce browser tests or heavy-governance coverage for this first slice.
|
||||
|
||||
**Rationale**:
|
||||
- The business truth is effective-state evaluation, audit recording, and blocked/no-side-effect execution.
|
||||
- Browser coverage would mostly duplicate existing Filament modal behavior.
|
||||
- Constitution `TEST-GOV-001` requires the narrowest proving lane mix.
|
||||
|
||||
**Evidence**:
|
||||
- Existing system runbooks and restore features already have focused feature coverage patterns in the repo.
|
||||
- The new logic is server-side and deterministic.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add browser smoke for pause/resume flows.
|
||||
- Rejected: not needed to prove the core runtime-safety semantics of this slice.
|
||||
290
specs/242-operational-controls/spec.md
Normal file
290
specs/242-operational-controls/spec.md
Normal file
@ -0,0 +1,290 @@
|
||||
# Feature Specification: Operational Controls
|
||||
|
||||
**Feature Branch**: `242-operational-controls`
|
||||
**Created**: 2026-04-26
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Operational Controls & Feature Flags: create a narrow first slice that replaces ad-hoc environment-gated risky admin maintenance actions with a central audited operational control path. Reuse the existing system panel, platform capabilities, audit logging, and server-side action/service enforcement to let operators pause or disable selected high-risk features with explicit disabled-state messaging that is distinct from authorization failure, without turning this into a generic experimentation or entitlement platform."
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot already has risky actions that can only be paused through local environment flags, deploy-time changes, or ad-hoc code decisions instead of one product-owned operational control contract.
|
||||
- **Today's failure**: During an incident or rollout concern, operators cannot centrally pause in-scope risky flows such as findings lifecycle backfill or restore execution with consistent UX, auditable ownership, and server-side enforcement. The current `allow_admin_maintenance_actions` environment gate makes one tenant admin action appear or disappear outside the product, while similar runbook and provider-backed actions have no shared pause contract.
|
||||
- **User-visible improvement**: Platform operators can pause selected high-risk actions from a system-plane control surface, and affected admin/operator surfaces show explicit paused-state messaging instead of disappearing silently, looking unauthorized, or requiring a deployment to change runtime behavior.
|
||||
- **Smallest enterprise-capable version**: Introduce one bounded operational-control contract for two concrete first-slice controls - `findings.lifecycle.backfill` and `restore.execute` - with global and workspace-targeted activation, one system-plane management surface with on-demand audit history, and server-side enforcement on the existing runbook, findings-maintenance, and restore-execution start paths.
|
||||
- **Explicit non-goals**: No A/B testing, no customer-managed feature flags, no generic remote-config platform, no entitlement/billing replacement, no tenant-scoped self-service flags, no broad maintenance-mode replacement for the whole app, and no speculative control catalog for every future feature.
|
||||
- **Permanent complexity imported**: One operational-control catalog, one persisted control-activation record family with explicit scope and reason, one evaluation service at action/service boundaries, a small amount of shared paused-state copy/presentation, audit action IDs, and focused unit/feature/guard coverage.
|
||||
- **Why now**: The repo already exposes the control gap in live code through `config('tenantpilot.allow_admin_maintenance_actions')`, while live pilots and founder-operated support increase the need for safe runtime pause controls before more onboarding, support, AI, and customer-facing workflows land.
|
||||
- **Why not local**: A local config flag or page-specific guard cannot safely cover both system-plane runbooks and tenant-plane provider-backed execution, cannot produce one auditable truth, and teaches parallel runtime-control semantics across surfaces.
|
||||
- **Approval class**: Core Enterprise
|
||||
- **Red flags triggered**: New meta-infrastructure, foundation-sounding scope. Defense: the slice is intentionally limited to two real existing high-risk controls, one management surface, and one shared evaluator instead of a universal experimentation or entitlement platform.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: platform, workspace, tenant
|
||||
- **Primary Routes**:
|
||||
- New system-plane operational controls surface under `/system/ops/controls`
|
||||
- Existing system runbook launcher at `/system/ops/runbooks`
|
||||
- Existing tenant findings register at `/admin/t/{tenant}/findings`
|
||||
- Existing restore execution start flow in the restore-run create surface under `/admin/t/{tenant}/restore-runs/create`; existing restore record-view actions remain unchanged and out of scope for this slice
|
||||
- **Data Ownership**:
|
||||
- Control definitions remain platform-owned catalog truth in code, limited to the first-slice keys `findings.lifecycle.backfill` and `restore.execute`
|
||||
- Control activations are platform-operated runtime-safety records; workspace-targeted activations reference a workspace, while global activations apply to all workspaces without embedding tenant-owned data
|
||||
- No tenant-owned control records are introduced in this slice; tenant/admin surfaces consume effective control decisions only
|
||||
- Audit history stays on existing `AuditLog` truth with stable action IDs for control activation, update, removal, and blocked execution; global control changes are platform-plane audit events with no false workspace or tenant owner, workspace-targeted changes and blocked starts with concrete workspace/tenant context retain truthful workspace/tenant audit scope, and blocked system-plane attempts without a concrete workspace/tenant resolve to platform-plane audit events with requested-scope metadata
|
||||
- **RBAC**:
|
||||
- Management happens only in the platform `/system` plane and requires `PlatformCapabilities::ACCESS_SYSTEM_PANEL` plus a dedicated operational-controls management capability
|
||||
- Existing tenant/admin capabilities remain authoritative for the underlying in-scope actions (`findings.lifecycle.backfill`, `restore.execute`)
|
||||
- Non-members or non-entitled users still receive 404 on tenant/workspace boundaries; members lacking the underlying capability still receive 403 and continue to follow the existing surface-specific capability-denied UX with no paused-state helper text; entitled users blocked only by an active operational control receive explicit paused-state feedback that is distinct from authorization failure
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical collection route in `/admin`; it affects existing tenant and system execution surfaces
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Control evaluation never weakens existing tenant/workspace membership checks. Tenant-plane surfaces resolve tenant entitlement first, then evaluate the effective control state only for already-entitled users.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: header actions, runbook launch actions, provider-backed start gating, status messaging, audit prose
|
||||
- **Systems touched**: system runbooks, tenant findings maintenance action, `FindingsLifecycleBackfillRunbookService::start()` plus its CLI and deploy-hook callers, restore execution start path, existing audit logging, operation start UX, existing capability enforcement helpers
|
||||
- **Existing pattern(s) to extend**: `UiEnforcement`, `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `AuditRecorder`, `WorkspaceAuditLogger`, and existing system/tenant operation-link helpers
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: one new operational-control evaluator is allowed, but it must sit beside existing capability and provider-start gates instead of creating new page-local flag logic. Existing audit and start-result presenters remain authoritative for labels, reasons, action/result messaging, and truthful system-plane versus workspace-plane audit ownership.
|
||||
- **Why the existing shared path is sufficient or insufficient**: existing shared paths already solve authorization, audit recording, and start-result UX. They are insufficient because none of them currently carry one central runtime-safety decision that can pause an action consistently across tenant and system surfaces.
|
||||
- **Allowed deviation and why**: none. The first slice must remove the ad-hoc environment flag for in-scope maintenance actions rather than adding another exception path.
|
||||
- **Consistency impact**: control labels, paused-state wording, reason display, audit action IDs, and allow/block semantics must match across the controls page, runbooks page, findings list header action, restore execution start flow, and any related notifications.
|
||||
- **Review focus**: reviewers must block new direct `config(...)` or env-based runtime gates for in-scope operational controls and verify that findings lifecycle backfill routes through `FindingsLifecycleBackfillRunbookService::start()` plus the shared evaluator for UI, CLI, and deploy-hook callers, while restore continues through its existing start seam and presenters.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationRunService`, `OperationUxPresenter`, `OpsUxBrowserEvents`, `ProviderOperationStartResultPresenter`, `OperationRunLinks`, and `SystemOperationRunLinks`
|
||||
- **Delegated start/completion UX behaviors**: when an action is allowed, queued toast, `View run` link, run-enqueued browser event, dedupe-or-already-queued messaging, and tenant/workspace-safe URL resolution remain delegated to the existing shared paths. When a control blocks execution, the surface reuses the shared start-result or notification path for one explicit paused-state message and does not invent a second blocked-run dialect.
|
||||
- **Local surface-owned behavior that remains**: initiation inputs, confirmation text, and scope selection remain local to the runbook page, findings list page, or restore workflow. Local code does not decide operational-control truth.
|
||||
- **Queued DB-notification policy**: unchanged. This slice does not introduce new queued DB notifications for paused or allowed starts.
|
||||
- **Terminal notification path**: unchanged central lifecycle mechanism for runs that do start.
|
||||
- **Exception required?**: none
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes
|
||||
- **Boundary classification**: mixed
|
||||
- **Seams affected**: platform-core operational-control vocabulary, restore execution provider-start boundary, shared operator messaging for blocked execution
|
||||
- **Neutral platform terms preserved or introduced**: operational control, effective state, paused, scope, reason, expiry, owner, override
|
||||
- **Provider-specific semantics retained and why**: `restore.execute` remains a provider-owned operation and keeps its current Microsoft-only execution path and dry-run safeguards. The control system only governs whether that path may start; it does not rename or generalize restore semantics.
|
||||
- **Why this does not deepen provider coupling accidentally**: the control catalog is platform-owned and names operation keys that already exist. Provider-specific behavior stays inside the existing restore-start path and provider registry.
|
||||
- **Follow-up path**: none for the first slice; broader catalog growth remains a follow-up decision, not an implied obligation of this spec
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| System ops controls surface | yes | Native Filament + shared primitives | status messaging, audit-backed actions, control state summary | page, card/action state, modal | no | New system-plane control center for a bounded first-slice catalog |
|
||||
| System runbooks launcher | yes | Native Filament + shared runbook/start UX | run start messaging, confirmation flow, blocked-state messaging | page, action, preflight state | no | Existing page gains operational-control awareness only |
|
||||
| Tenant findings list header action | yes | Native Filament + existing action-surface primitives | header actions, run start messaging | table, header action, modal | no | Existing maintenance action loses env-flag gating and becomes control-aware |
|
||||
| Restore run create/start workflow | yes | Native Filament resource + shared provider start gate | provider-backed start result, disabled-state copy | form/wizard, create action, start-result state | no | Existing risky tenant workflow gains central pause semantics without new tenant-side control UI |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| System ops controls surface | Primary Decision Surface | Decide whether one risky feature should stay available, be paused, or be scoped down during an incident or rollout | control name, effective scope, paused/enabled state, reason, owner, expiry | change history, affected actions, audit links | Primary because this is the system-plane place where operators make the runtime-safety decision itself | Follows incident-control and rollout workflow, not feature storage structure | Replaces deploy-time or env-level toggling with one visible operational decision point |
|
||||
| System runbooks launcher | Secondary Context Surface | Decide whether to preflight or start a runbook once the control state is already known | current control state, preflight, confirmation requirements, next safe action | existing run detail after start, control reason history | Secondary because the main decision here is execution of a specific runbook, not control management | Keeps runbook workflow intact while surfacing control truth inline | Avoids surprise 403s or silent disappearance when the runbook is paused |
|
||||
| Tenant findings list header action | Secondary Context Surface | Decide whether to start tenant findings backfill | header action availability, paused-state message, tenant scope | run detail only after allowed start | Secondary because the list remains the primary findings workflow; runtime control is supporting context | Preserves list-first findings work while exposing truthful blocked state | Removes hidden env-driven behavior drift on one tenant surface |
|
||||
| Restore run create/start workflow | Secondary Context Surface | Decide whether a restore may proceed now | effective control state, restore-specific next action, existing safety messaging | preview, diff, and run detail when allowed | Secondary because restore creation remains the main operator decision and control state is a gating constraint | Keeps restore workflow focused while making pause state explicit before execution | Prevents risky restore attempts from failing late or ambiguously |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System ops controls surface | Utility / System | Operational safety control center | Pause, resume, or scope a control | Explicit card action or modal from the page itself | forbidden | Secondary details live in card reveals or modal details only | State-changing control actions are confirmation-protected and stay on the control card/modal | `/system/ops/controls` | `/system/ops/controls` | Global, all-workspaces, or workspace-targeted scope | Operational controls / Operational control | Effective state, reason, scope, and expiry | none |
|
||||
| System runbooks launcher | Monitoring / Queue / Workbench | System runbook launcher | Preflight or start a runbook | In-page action modal then `View run` after success | forbidden | Related navigation stays secondary in toast actions or page summaries | Dangerous execution remains in the existing `Run...` action with confirmation | `/system/ops/runbooks` | `/system/ops/runbooks` | Current control state and selected tenant/all-tenant scope | Runbooks / Runbook | Whether execution is allowed right now and why | none |
|
||||
| Tenant findings list header action | List / Table / Bulk | List-first resource | Open findings or start lifecycle backfill | Existing table inspection remains primary; header action is explicit secondary execution | required | Secondary execution stays in the header only | Backfill remains confirmation-protected in the header action | `/admin/t/{tenant}/findings` | existing findings detail route | Tenant scope and effective control state for entitled users | Findings / Finding | Findings list truth plus explicit maintenance availability | none |
|
||||
| Restore run create/start workflow | Wizard / Flow | Create and launch flow | Continue restore setup or stop because execution is paused | Existing create form/wizard remains primary | forbidden | Secondary navigation lives in helper links and post-start run links | Existing restore execution remains inside the create/start flow with its current safety steps | `/admin/t/{tenant}/restore-runs` | `/admin/t/{tenant}/restore-runs/{record}` | Tenant scope, preview/dry-run state, and effective control state | Restore runs / Restore run | Whether restore execution may proceed, with scope and reason | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System ops controls surface | Platform operator / break-glass operator | Decide whether risky runtime actions remain enabled, paused globally, or paused for one workspace | Control center | Is this risky action allowed right now, for whom, and why? | control label, effective state, scope, reason, owner, expiry | audit history, exact affected surfaces, internal notes | runtime safety state, scope, expiry | TenantPilot only | Pause control, Resume control, Change scope | Pausing or resuming a control |
|
||||
| System runbooks launcher | Platform operator | Decide whether to preflight/start the runbook or respect a pause control | Workbench | Can I run this runbook now? | current control state, runbook scope, preflight result, confirmation requirements | latest run detail and audit history after navigation | runtime safety state, execution readiness, preflight result | TenantPilot only when blocked; Microsoft tenant or tenant data changes only when allowed and executed | Preflight, Run | Run |
|
||||
| Tenant findings list header action | Tenant manager / owner | Decide whether findings lifecycle backfill may start for the current tenant | List-first resource + secondary header action | Is lifecycle backfill available for this tenant right now? | explicit paused/enabled state for entitled users, tenant scope, action label | run detail only if execution is allowed and started | runtime safety state, execution readiness | TenantPilot only when blocked; tenant data mutation if execution is allowed | Backfill findings lifecycle | Backfill findings lifecycle |
|
||||
| Restore run create/start workflow | Tenant manager / owner | Decide whether restore execution may proceed after existing safety checks | Guided creation flow | Can this restore execute now, or is the operation paused? | control state, restore scope, dry-run/preview state, next action | preview diff, post-start run detail, raw diagnostics after navigation | runtime safety state, lifecycle, restore safety/preflight state | TenantPilot only when blocked; Microsoft tenant when execution is allowed | Create restore run, Continue preview | Execute restore |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: yes
|
||||
- **New persisted entity/table/artifact?**: yes
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: yes, one bounded enabled/paused effective-state axis for the control contract
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: Operators cannot safely pause already-existing risky actions without deploy-time flags, inconsistent UX, or page-local code branches.
|
||||
- **Existing structure is insufficient because**: authorization and provider-start gates decide who may act, not whether the product should temporarily allow the action at all. The current env flag is invisible product truth and cannot cover system-plane plus tenant-plane paths consistently.
|
||||
- **Narrowest correct implementation**: a code-owned two-control catalog plus persisted control activations, one evaluator, one management surface, and two concrete enforcement families (`findings.lifecycle.backfill`, `restore.execute`).
|
||||
- **Ownership cost**: new runtime-safety records, audit action IDs, shared paused-state copy, evaluator tests, and guard coverage that blocks new ad-hoc runtime gates.
|
||||
- **Alternative intentionally rejected**: keep using env flags, rely on full Laravel maintenance mode, or build a generic customer-facing feature-flag system. Env flags are too hidden, maintenance mode is too broad, and a generic flag platform is too large.
|
||||
- **Release truth**: current-release truth
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature
|
||||
- **Validation lane(s)**: fast-feedback, confidence
|
||||
- **Why this classification and these lanes are sufficient**: the feature introduces one shared evaluator plus a small number of concrete UI/service enforcement points. Unit tests prove effective-state resolution, scope precedence, expiry, and block reasons. Feature tests prove system-plane management, tenant-plane and system-plane blocked execution, audit logging, and unchanged 404/403 semantics without browser-specific behavior.
|
||||
- **New or expanded test families**: focused operational-controls unit coverage, system-page management tests, findings-maintenance gate tests, restore-execution gate tests, and one guard test blocking new ad-hoc config gates for in-scope controls
|
||||
- **Fixture / helper cost impact**: moderate. Tests reuse existing platform users, workspaces, tenants, OperationRun, restore-run, and findings fixtures. No new browser harness, provider emulator, or heavy governance suite is required.
|
||||
- **Heavy-family visibility / justification**: none
|
||||
- **Special surface test profile**: standard-native-filament, monitoring-state-page
|
||||
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for the controls page and the affected admin/system surfaces, plus explicit server-side assertions that blocked actions create no run or provider execution side effect, all-tenant blocked runbook attempts audit truthfully, and later control activation does not rewrite already accepted runs.
|
||||
- **Reviewer handoff**: confirm that the env-gated findings action is now evaluator-driven, restore execution is blocked before queue/provider start, entitled-but-paused users see explicit operational-control messaging, non-entitled users still get 404 or 403 as appropriate, and audit entries record scope/reason/actor for control changes.
|
||||
- **Budget / baseline / trend impact**: low-to-moderate increase in narrow unit and feature coverage only
|
||||
- **Escalation needed**: none
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Planned validation commands**:
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`
|
||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Pause A Risky Action Centrally (Priority: P1)
|
||||
|
||||
As a platform operator, I can pause one risky control from the system plane so the affected runbook and tenant surfaces stop allowing that action without requiring a deployment.
|
||||
|
||||
**Why this priority**: This is the operator-visible core of the feature and the main incident-response value.
|
||||
|
||||
**Independent Test**: Activate the control for `findings.lifecycle.backfill`, open the system controls surface and the affected runbook/findings surfaces, and verify that the action is visibly paused with one explicit reason and no execution path.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a platform operator pauses `findings.lifecycle.backfill` globally, **When** an entitled operator opens `/system/ops/runbooks` or an entitled tenant user opens `/admin/t/{tenant}/findings`, **Then** the action remains visible in its normal place but is explicitly blocked with paused-state messaging rather than disappearing or looking unauthorized.
|
||||
2. **Given** the same control is resumed, **When** the affected surfaces reload, **Then** the normal execution path returns without a deploy or config-file change.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Block Execution Server-Side Without Masquerading As Auth (Priority: P1)
|
||||
|
||||
As an entitled operator, I want an active control to stop execution before any queued run or provider-backed action starts, while still preserving normal 404 and 403 authorization semantics.
|
||||
|
||||
**Why this priority**: The feature fails if it only hides UI or turns operational controls into fake authorization failures.
|
||||
|
||||
**Independent Test**: Activate controls for `findings.lifecycle.backfill` and `restore.execute`, attempt the affected actions through their normal pages, and assert that no `OperationRun` or provider-backed execution starts while entitlement and capability semantics remain unchanged.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an entitled tenant user has the underlying capability but `restore.execute` is paused for their workspace, **When** they attempt to start restore execution, **Then** the system returns explicit operational-control feedback, creates no new execution run, and makes no outbound provider call.
|
||||
2. **Given** a user lacks workspace or tenant entitlement, **When** they attempt the same affected action, **Then** the system still responds as not found instead of revealing control-state details.
|
||||
3. **Given** a user is entitled to the scope but lacks the underlying capability, **When** they attempt the affected action, **Then** the system still returns 403 rather than blaming the operational control.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Scope A Pause To One Workspace (Priority: P2)
|
||||
|
||||
As a platform operator, I can pause a risky control for one workspace without affecting unrelated workspaces, so incidents or staged rollouts stay bounded.
|
||||
|
||||
**Why this priority**: Workspace scoping is the smallest enterprise-capable version beyond a purely global kill switch and makes the feature reusable for future rollout control.
|
||||
|
||||
**Independent Test**: Create two workspaces, activate a workspace-scoped pause for one of them, and confirm that blocked behavior applies only to the targeted workspace while the other workspace continues normally.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** `restore.execute` is paused for Workspace A only, **When** entitled users in Workspace A and Workspace B attempt restore execution, **Then** Workspace A is blocked with explicit paused-state messaging and Workspace B continues normally.
|
||||
2. **Given** a workspace-scoped pause expires or is removed, **When** the targeted workspace retries the action, **Then** the action becomes available again without changing any unrelated workspace state.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A workspace-scoped activation and a global activation may both exist for the same control; v1 precedence is global-first, and a matching global pause always wins.
|
||||
- A control may expire while an operator is on the page; stale page state must not start a blocked action after expiry or removal.
|
||||
- Break-glass platform access does not automatically bypass an operational control unless the spec explicitly authorizes that path in a later slice.
|
||||
- An action may already be queued before a control is activated; the control governs new starts only and must not silently rewrite historical runs.
|
||||
- Tenant/admin users who are not entitled to the workspace or tenant must not learn that a control exists for the hidden scope.
|
||||
- The first slice must retire the in-scope env flag rather than leaving both the env gate and the control evaluator active in parallel.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Graph endpoint family, but it changes the start boundary for existing queued/provider-backed actions. For in-scope controls, the spec requires server-side enforcement before `findings.lifecycle.backfill` or `restore.execute` start, preserves existing confirmation/audit patterns, and keeps long-running work observable through the current `OperationRun` paths whenever execution is allowed.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces a new runtime-safety truth because the current product already needs it now: operators must pause risky actions without deploy-time env changes. The shape stays narrow: a two-key catalog, persisted activations, and one evaluator. It does not become a generalized experimentation, entitlement, or customer flag platform.
|
||||
|
||||
**Constitution alignment (XCUT-001):** The slice is cross-cutting across header actions, runbook starts, provider-backed start gates, and audit messaging. It reuses `UiEnforcement`, `ProviderOperationStartGate`, existing OperationRun UX presenters, and `WorkspaceAuditLogger` rather than introducing local blocked-state dialects.
|
||||
|
||||
**Constitution alignment (PROV-001):** `restore.execute` remains provider-owned, while operational-control vocabulary remains platform-core. The spec keeps provider specifics inside the existing restore path and uses neutral control language for scope, reason, and effective state.
|
||||
|
||||
**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature coverage. No browser or heavy-governance family is justified. Reviewer handoff must explicitly verify lane fit, unchanged 404/403 semantics, and no hidden provider-side effects on blocked paths.
|
||||
|
||||
**Constitution alignment (OPS-UX):** For starts that are still allowed, the default Ops-UX 3-surface contract remains unchanged. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned. Paused starts create no `OperationRun`, no queued DB notification, and no new summary-count semantics.
|
||||
|
||||
**Constitution alignment (OPS-UX-START-001):** The feature includes the `OperationRun UX Impact` section and reuses the shared start UX paths. Local surfaces remain responsible only for initiation inputs and page-local confirmation text. Blocked-state feedback is delivered through existing result/notification helpers instead of page-local composition.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This slice spans the platform `/system` plane for control management and the tenant/admin `/admin` plane for affected execution surfaces. Cross-plane access remains 404. Non-members or non-entitled users receive 404. Members lacking the underlying capability receive 403 and keep the existing surface-specific capability-denied UX rather than paused-state helper text. Entitled users blocked only by an active control receive explicit operational-control feedback distinct from authorization failure. All mutating management actions require a staged safety flow with scope-impact preview, server-side capability checks, and confirmation. Break-glass does not bypass an active control in v1.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If paused/enabled state is rendered as a badge or status chip, it must use centralized badge rendering or one shared control-state presentation path, not page-local color decisions.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** The controls page, runbooks page, findings page, and restore flow remain native Filament surfaces using existing action, section, infolist, and notification primitives. No local status card framework or custom blocked-state component library is introduced.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels use stable verbs and nouns: `Pause control`, `Resume control`, `Operational controls`, `Backfill findings lifecycle`, and `Restore execution`. `Restore execution` is the control and status label, while `Execute restore` is the gated action label. Route-entry labels such as `New restore run` and `Create restore run` refer only to the ungated draft/setup flow. The same vocabulary must be preserved across buttons, modal titles, notifications, and audit prose.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** The new system controls surface is the only new primary decision surface. Runbooks, findings, and restore remain secondary execution contexts that surface control truth inline instead of becoming separate troubleshooting flows.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The controls page acts as a bounded control center with explicit action buttons and no competing inspect model. Existing runbooks, findings, and restore surfaces preserve their current primary inspect/open paths and action hierarchies while gaining one truthful blocked-state branch.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Control management actions remain separated from navigation and diagnostics. The controls page owns pause/resume management. Runbooks, findings, and restore keep execution actions local but do not own control truth.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Default-visible content stays operator-first: whether the action is allowed, for which scope, and why. Raw internal control records or configuration internals stay secondary. Each affected execution action must state its mutation scope before execution when allowed, and blocked paths must state that no tenant/provider mutation will occur.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The spec allows one new evaluator because existing direct domain-to-UI mapping cannot express runtime-safety state consistently across system and tenant surfaces. No second presenter taxonomy or explanation framework is added beyond the minimum blocked-state copy path.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied on all touched Filament surfaces. Each affected surface keeps one primary inspect/open model, no redundant `View` action is added, no empty action groups are introduced, and state-changing control actions require confirmation.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** The new controls page uses native Filament sections/cards for control summaries and action modals. Existing runbooks, findings, and restore pages keep their established layout patterns. Any blocked-state summary remains within the current page structure and does not add ad-hoc full-page exception layouts.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST define a central operational-control catalog for the first-slice keys `findings.lifecycle.backfill` and `restore.execute`.
|
||||
- **FR-002**: Platform operators MUST be able to activate, update, and remove a control for all workspaces or one specific workspace from the system plane, with a human-readable reason and optional expiry, through a staged safety flow that previews scope impact before confirmation.
|
||||
- **FR-003**: System MUST enforce the effective control state server-side before any in-scope findings lifecycle backfill start at `FindingsLifecycleBackfillRunbookService::start()`, any affected maintenance action, or any provider-backed restore execution begins.
|
||||
- **FR-004**: System MUST show explicit paused-state feedback to entitled users on affected surfaces and MUST keep that feedback distinct from authorization failure.
|
||||
- **FR-005**: System MUST preserve existing 404 vs 403 semantics for non-membership and missing capability checks even when a control is active, and capability-denied members MUST follow the existing surface-specific denial UX rather than operational-control helper text.
|
||||
- **FR-006**: System MUST create no new `OperationRun`, no queued execution `RestoreRun`, no queued job, and no outbound provider execution when an in-scope action is blocked by an active control, and MUST NOT retroactively mutate already accepted or historical runs when a control is activated later.
|
||||
- **FR-007**: System MUST audit every control activation, update, removal, and blocked execution decision with stable action IDs, actor, scope, reason, and timestamp; global control changes MUST be recorded as platform-plane audit events without assigning a false workspace or tenant owner, and blocked system-plane attempts without a concrete workspace or tenant MUST be recorded as platform-plane events with requested-scope metadata.
|
||||
- **FR-008**: The findings-maintenance action currently gated by `config('tenantpilot.allow_admin_maintenance_actions')` MUST be migrated to the shared operational-control path and the local env gate retired for this in-scope behavior.
|
||||
- **FR-009**: System MUST expose enough effective-state information on the controls page and affected execution surfaces to make the operator's next action clear without opening raw config or database detail.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||
|
||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| System ops controls surface | app/Filament/System/Pages/Ops/Controls.php | `Pause control`, `Resume control`, `Edit scope` (scope-impact preview + confirmation required for state changes) | Same-page control cards or modals; no row-click model | none beyond card actions | none | none in v1 | same-page actions only | `Review impact`, `Save changes`, `Cancel` in staged modal forms | yes | Management is platform-plane only; global changes audit as platform-plane events without workspace/tenant ownership; system users missing `platform.ops.controls.manage` receive 403 before page content renders |
|
||||
| System runbooks launcher | app/Filament/System/Pages/Ops/Runbooks.php | `Preflight`, `Run...` | Same-page action modal and `View run` toast action | none | none | none | none | `Run`, `Cancel` in modal | yes | Existing start UX retained; blocked execution decisions are always audited |
|
||||
| Findings list page | app/Filament/Resources/FindingResource/Pages/ListFindings.php | `Backfill findings lifecycle` (confirmation required) | Existing findings inspection model unchanged | unchanged | unchanged | unchanged | unchanged | N/A | yes | In-scope change replaces env gating with control evaluation and blocked execution audit |
|
||||
| Restore run resource | app/Filament/Resources/RestoreRunResource.php | `New restore run` | Existing clickable-row/resource inspection model unchanged | existing row actions unchanged | existing grouped maintenance actions unchanged | existing empty-state CTA unchanged | existing view header unchanged | `Create restore run`, `Cancel` plus existing safety steps | yes | In-scope change gates only the `Execute restore` step inside the create flow; draft/setup labels and existing row/view actions remain unchanged |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operational Control Definition**: The bounded catalog entry that identifies one controllable risky action, its canonical key, operator label, supported scopes, and default behavior.
|
||||
- **Operational Control Activation**: The runtime safety record that pauses a control for either all workspaces or one specific workspace, including reason, optional expiry, and an owner display that resolves to the last mutating actor (`updated_by` when present, otherwise `created_by`).
|
||||
- **Operational Control Decision**: The derived evaluation result returned to affected surfaces and service boundaries, including effective state, matched scope, reason, and whether execution may proceed.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: In timed manual smoke, platform operators can pause or resume either first-slice control from the system plane in under 1 minute without editing environment variables, code, or database rows manually.
|
||||
- **SC-002**: In blocked validation scenarios, 100% of attempted in-scope starts create no new execution run and no outbound provider-backed execution for the targeted scope.
|
||||
- **SC-003**: In validation scenarios covering the affected surfaces, entitled users see explicit paused-state feedback on the first attempt in 100% of cases, while non-entitled users still receive 404 or 403 semantics as defined by RBAC rules.
|
||||
- **SC-004**: Workspace-scoped activation affects only the targeted workspace in validation scenarios and leaves at least one non-targeted workspace unaffected for the same control.
|
||||
187
specs/242-operational-controls/tasks.md
Normal file
187
specs/242-operational-controls/tasks.md
Normal file
@ -0,0 +1,187 @@
|
||||
---
|
||||
|
||||
description: "Task list for Operational Controls"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Operational Controls
|
||||
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/`
|
||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/contracts/operational-controls.contract.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/242-operational-controls/quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) for all runtime behavior changes in this slice. Keep proof in Unit + Feature lanes only.
|
||||
**Operations**: Allowed starts must continue to reuse the shared OperationRun start UX. Blocked starts for `findings.lifecycle.backfill` and `restore.execute` must create no queued execution `OperationRun`, no queued execution `RestoreRun`, no queued job, and no provider-backed execution. Control activation governs new starts only and must not retroactively mutate already accepted runs.
|
||||
**RBAC**: Management is platform-plane only under `/system`; affected execution surfaces stay on `/system` or `/admin/t/{tenant}`. Non-members remain `404`, members without the underlying capability remain `403`, and entitled users blocked only by an active operational control get explicit paused-state feedback.
|
||||
**Organization**: Tasks are grouped by user story so each slice remains independently testable and bounded to the first-slice controls `findings.lifecycle.backfill` and `restore.execute`.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare the local implementation lane and feature-local file layout without widening scope.
|
||||
|
||||
- [x] T001 Start the local Sail environment with `cd apps/platform && ./vendor/bin/sail up -d` (script: `apps/platform/vendor/bin/sail`)
|
||||
- [x] T002 Create the bounded feature-local directories under `apps/platform/app/Support/OperationalControls/`, `apps/platform/tests/Unit/Support/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, `apps/platform/tests/Feature/System/OpsRunbooks/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Restore/`, and `apps/platform/tests/Feature/OperationalControls/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Add the single new persistence model, the bounded operational-controls support namespace, and the shared capability/audit plumbing that all stories depend on.
|
||||
|
||||
**Checkpoint**: The repo has one `operational_control_activations` truth, one evaluator/catalog/decision support path, one platform capability, and shared audit IDs before any surface integration begins.
|
||||
|
||||
- [x] T003 Create the activation migration in `apps/platform/database/migrations/*_create_operational_control_activations_table.php`, including partial unique indexes for active global and workspace-scoped rows
|
||||
- [x] T004 Create the activation model in `apps/platform/app/Models/OperationalControlActivation.php`
|
||||
- [x] T005 [P] Create the activation factory in `apps/platform/database/factories/OperationalControlActivationFactory.php`
|
||||
- [x] T006 [P] Create the bounded catalog for `findings.lifecycle.backfill` and `restore.execute` in `apps/platform/app/Support/OperationalControls/OperationalControlCatalog.php`
|
||||
- [x] T007 [P] Create the derived decision object in `apps/platform/app/Support/OperationalControls/OperationalControlDecision.php`
|
||||
- [x] T008 Create the shared evaluator for global and workspace-targeted activations, including foundational global-first precedence and expiry handling, in `apps/platform/app/Support/OperationalControls/OperationalControlEvaluator.php`
|
||||
- [x] T009 [P] Add the platform management capability for the controls surface in `apps/platform/app/Support/Auth/PlatformCapabilities.php` and grant it in `apps/platform/database/seeders/PlatformUserSeeder.php` for the seeded local operator path
|
||||
- [x] T010 [P] Add stable audit action IDs for pause, update, resume, and execution-blocked events in `apps/platform/app/Support/Audit/AuditActionId.php`
|
||||
- [x] T011 Extend canonical audit plumbing for control scope, reason, expiry, requested-scope metadata, and blocked-execution evidence using `apps/platform/app/Services/Audit/AuditRecorder.php` for global control changes and blocked system all-tenant attempts and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for workspace/tenant-scoped changes and blocked execution
|
||||
- [x] T012 [P] Add catalog and evaluator unit coverage in `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php` and `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php`
|
||||
- [x] T013 [P] Add global-first precedence and expiry coverage in `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php`
|
||||
- [x] T014 Run the foundational unit suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php tests/Unit/Support/OperationalControls/OperationalControlEvaluatorTest.php tests/Unit/Support/OperationalControls/OperationalControlScopeResolutionTest.php` (tests: `apps/platform/tests/Unit/Support/OperationalControls/OperationalControlCatalogTest.php`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Pause A Risky Action Centrally (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Give platform operators one system-plane control center that can pause `findings.lifecycle.backfill` and make the runbook and findings surfaces show one explicit blocked-state path instead of env-driven disappearance.
|
||||
|
||||
**Independent Test**: Pause `findings.lifecycle.backfill` globally from `/system/ops/controls`, then verify `/system/ops/runbooks` and `/admin/t/{tenant}/findings` both show the action truthfully for entitled users, block execution server-side, and show the same paused-state reason.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [x] T015 [P] [US1] Add system-plane management coverage for staged scope-impact preview, controls-page 403 access denial, pause, update, resume, confirmation, global-audit ownership, on-demand audit history links, and audit logging in `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
|
||||
- [x] T016 [P] [US1] Add findings lifecycle gate coverage for blocked `findings.lifecycle.backfill` starts at the shared `FindingsLifecycleBackfillRunbookService::start()` seam, including system-runbook callers, mandatory blocked-execution audit evidence, and truthful platform-plane ownership for blocked all-tenant attempts in `apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php`
|
||||
- [x] T017 [P] [US1] Add findings header-action coverage for explicit blocked-state feedback, mandatory blocked-execution audit evidence, and no-start behavior in `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T018 [US1] Create the system ops controls page with native Filament sections, staged scope-impact preview plus confirmation-protected pause/resume actions, effective-state summaries, owner display, on-demand audit history links, and the mutation-path cleanup that deletes expired conflicting activations before writing a new pause in `apps/platform/app/Filament/System/Pages/Ops/Controls.php`
|
||||
- [x] T019 [US1] Integrate the evaluator into `apps/platform/app/Services/Runbooks/FindingsLifecycleBackfillRunbookService.php::start()` so the system runbooks page, CLI command, and deploy-hook command all honor the same blocked-start contract
|
||||
- [x] T020 [US1] Replace the env-gated findings maintenance path by routing the tenant findings action through the shared findings lifecycle service/evaluator path, blocked-execution audit recording, and shared paused-state feedback in `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
- [x] T021 [US1] Retire the in-scope findings env gate from `apps/platform/config/tenantpilot.php`
|
||||
- [x] T022 [US1] Run the US1 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` (tests: `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Block Execution Server-Side Without Masquerading As Auth (Priority: P1)
|
||||
|
||||
**Goal**: Stop `restore.execute` before any queue, `OperationRun`, `RestoreRun`, or provider-backed execution starts while preserving normal `404` and `403` semantics.
|
||||
|
||||
**Independent Test**: Activate `restore.execute`, attempt the restore execution flow as an entitled user, a non-member, and a member missing capability, and verify the outcomes are respectively paused-with-reason, `404`, and `403`, with no execution side effects on blocked paths.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [x] T023 [P] [US2] Add restore execution gate coverage for blocked execution starts, mandatory blocked-execution audit evidence, no queued-execution `RestoreRun`/`OperationRun` side effects, provider-start suppression, and proof that a later pause does not retroactively mutate already accepted restore runs in `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php`
|
||||
- [x] T024 [P] [US2] Add explicit authorization-semantics and break-glass non-bypass coverage for non-member `404`, missing-capability `403` with the existing capability-denied UX, and paused-state feedback only for entitled users blocked by an operational control in `apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T025 [US2] Integrate the evaluator into the restore execution start seam so blocked `restore.execute` decisions stop before any queued execution `RestoreRun`, queued execution `OperationRun`, queue dispatch, or provider call in `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- [x] T026 [US2] Reuse the shared provider-start gate for operational-control blocked outcomes instead of introducing restore-local runtime flags in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
|
||||
- [x] T027 [US2] Run the US2 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php` (tests: `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Scope A Pause To One Workspace (Priority: P2)
|
||||
|
||||
**Goal**: Allow platform operators to target one workspace without affecting unrelated workspaces, while keeping evaluator precedence and expiry behavior explicit and stable.
|
||||
|
||||
**Independent Test**: Pause `restore.execute` or `findings.lifecycle.backfill` for Workspace A only, then verify the targeted workspace is blocked with the correct reason and a second workspace remains unaffected until the activation is removed or expires.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [x] T028 [P] [US3] Extend controls-page feature coverage for workspace-targeted pause, update, expiry, and resume flows in `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`
|
||||
- [x] T029 [P] [US3] Add workspace-isolation coverage for targeted findings and restore blocking in `apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php` and `apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T030 [US3] Add workspace-targeted scope selection, validation, and effective-state presentation to the controls page in `apps/platform/app/Filament/System/Pages/Ops/Controls.php`
|
||||
- [x] T031 [US3] Extend `OperationalControlDecision` with workspace-targeted presentation details for matched scope, reason, expiry, and labels without redefining the foundational shared decision shape in `apps/platform/app/Support/OperationalControls/OperationalControlDecision.php`
|
||||
- [x] T032 [US3] Ensure runbooks, findings, and restore all pass workspace context consistently into control evaluation in `apps/platform/app/Filament/System/Pages/Ops/Runbooks.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Resources/RestoreRunResource.php`
|
||||
- [x] T033 [US3] Run the US3 suite with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/OpsControls/OperationalControlManagementTest.php tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php` (tests: `apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Lock down the shared-path contract, update feature artifacts if implementation details move, and run the narrow validation suite.
|
||||
|
||||
- [x] T034 [P] Add a CI guard against ad-hoc `config(...)` or page-local runtime-control bypasses for the in-scope controls in `apps/platform/tests/Feature/OperationalControls/NoAdHocOperationalControlBypassTest.php`
|
||||
- [x] T035 Update feature artifact close-out notes and final validation commands in `specs/242-operational-controls/plan.md` and `specs/242-operational-controls/quickstart.md`
|
||||
- [x] T036 Run formatting on touched platform files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` (target: `apps/platform/`)
|
||||
- [x] T037 Run the full narrow validation suite from `specs/242-operational-controls/quickstart.md`, including the timed manual smoke for SC-001, across `apps/platform/tests/Unit/Support/OperationalControls/`, `apps/platform/tests/Feature/System/OpsControls/`, `apps/platform/tests/Feature/System/OpsRunbooks/`, `apps/platform/tests/Feature/Findings/`, `apps/platform/tests/Feature/Restore/`, and `apps/platform/tests/Feature/OperationalControls/`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story Dependency Graph
|
||||
|
||||
```text
|
||||
Phase 1 (Setup)
|
||||
↓
|
||||
Phase 2 (Foundation: activation persistence + evaluator + capability/audit plumbing)
|
||||
↓
|
||||
US1 (system controls page + findings lifecycle gating) ─┐
|
||||
├─→ US3 (workspace-targeted scope + precedence/expiry)
|
||||
US2 (restore execution gate + auth semantics) ──────────┘
|
||||
```
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Foundational tasks marked `[P]` can run in parallel once the migration/model direction is agreed.
|
||||
- US1 tests for controls, runbooks, and findings can be authored in parallel because they target separate files.
|
||||
- US2 restore and authorization-semantics tests can run in parallel while the restore seam work is isolated to `RestoreRunResource.php` and `ProviderOperationStartGate.php`.
|
||||
- US3 extends existing US1/US2 tests and can parallelize the findings and restore workspace-isolation assertions while one person updates the controls page scope UI.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
Task: "Add system-plane management coverage in apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php"
|
||||
Task: "Add runbook gating coverage in apps/platform/tests/Feature/System/OpsRunbooks/OperationalControlRunbookGateTest.php"
|
||||
Task: "Add findings header-action coverage in apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php"
|
||||
Task: "Create the controls page in apps/platform/app/Filament/System/Pages/Ops/Controls.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
Task: "Add restore execution gate coverage in apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php"
|
||||
Task: "Add authorization-semantics coverage in apps/platform/tests/Feature/OperationalControls/OperationalControlAuthorizationSemanticsTest.php"
|
||||
Task: "Integrate the evaluator into apps/platform/app/Filament/Resources/RestoreRunResource.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
Task: "Extend controls-page workspace scope coverage in apps/platform/tests/Feature/System/OpsControls/OperationalControlManagementTest.php"
|
||||
Task: "Add workspace-isolation assertions in apps/platform/tests/Feature/Findings/OperationalControlFindingsBackfillGateTest.php"
|
||||
Task: "Add workspace-isolation assertions in apps/platform/tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php"
|
||||
Task: "Add workspace-targeted scope selection in apps/platform/app/Filament/System/Pages/Ops/Controls.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1)
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Deliver the controls page plus `findings.lifecycle.backfill` integrations in US1.
|
||||
3. Validate with the US1 feature suite before extending the second control.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. US1 delivers the new system-plane management surface and removes the ad-hoc findings env gate.
|
||||
2. US2 wires the same evaluator into `restore.execute` and proves blocked execution is not treated as authorization failure.
|
||||
3. US3 adds workspace-targeted scope, precedence, and expiry without widening the catalog or support namespace.
|
||||
4. Phase 6 adds the bypass guard, feature-artifact close-out, formatting, and the narrow validation pass.
|
||||
Loading…
Reference in New Issue
Block a user