feat: harden finding governance health surfaces (#197)
## Summary - harden findings and finding-exception Filament surfaces so workflow state, governance validity, overdue urgency, and next action are operator-first - add tenant stats widgets, segmented tabs, richer governance warnings, and baseline/dashboard attention propagation for overdue and lapsed governance states - add Spec 166 artifacts plus regression coverage for findings, badges, baseline summaries, tenantless operation viewer behavior, and critical table standards ## Verification - `vendor/bin/sail bin pint --dirty --format agent` - `vendor/bin/sail artisan test --compact` ## Filament Notes - Livewire v4.0+ compliance: yes, implementation stays on Filament v5 / Livewire v4 APIs only - Provider registration: unchanged, Laravel 12 panel/provider registration remains in `bootstrap/providers.php` - Global search: unchanged in this slice; `FindingExceptionResource` stays not globally searchable, no new globally searchable resource was introduced - Destructive actions: existing revoke/reject/approve/renew/workflow mutations remain capability-gated and confirmation-gated where already defined - Asset strategy: no new assets added; existing deploy process remains unchanged, including `php artisan filament:assets` when registered assets are used - Testing plan delivered: findings list/detail, exception register, dashboard attention, baseline summary, badge semantics, and tenantless operation viewer coverage Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #197
This commit is contained in:
parent
02e75e1cda
commit
55aef627aa
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -110,6 +110,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
- PostgreSQL with existing `operation_runs` JSONB-backed `context`, `summary_counts`, and `failure_summary`; no schema change planned (164-run-detail-hardening)
|
||||||
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
- PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks` (165-baseline-summary-trust)
|
||||||
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
- PostgreSQL with existing baseline, findings, and `operation_runs` tables plus JSONB-backed compare context; no schema change planned (165-baseline-summary-trust)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets (166-finding-governance-health)
|
||||||
|
- PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required (166-finding-governance-health)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -129,8 +131,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 166-finding-governance-health: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
- 165-baseline-summary-trust: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareStats`, `BaselineCompareExplanationRegistry`, `ReasonPresenter`, `BadgeCatalog` or `BadgeRenderer`, `UiEnforcement`, and `OperationRunLinks`
|
||||||
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
- 164-run-detail-hardening: Added PHP 8.4, Laravel 12, Blade views, Alpine via Filament v5 / Livewire v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRunResource`, `TenantlessOperationRunViewer`, `EnterpriseDetailBuilder`, `ArtifactTruthPresenter`, `OperationUxPresenter`, and `SummaryCountsNormalizer`
|
||||||
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,20 +1,32 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.12.0 → 1.13.0
|
- Version change: 1.13.0 → 1.14.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- None
|
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
- Proportionality First (PROP-001)
|
||||||
|
- No Premature Abstraction (ABSTR-001)
|
||||||
|
- No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- One Truth, Few Layers (LAYER-001)
|
||||||
|
- Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Default Bias (BIAS-001)
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/memory/constitution.md
|
- ✅ .specify/memory/constitution.md
|
||||||
- ✅ .specify/templates/spec-template.md
|
- ✅ .specify/templates/spec-template.md
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
- ✅ docs/product/principles.md
|
|
||||||
- ✅ docs/product/standards/README.md
|
- ✅ docs/product/standards/README.md
|
||||||
- ✅ docs/HANDOVER.md
|
- ✅ docs/HANDOVER.md
|
||||||
|
- ✅ docs/product/principles.md
|
||||||
|
- ✅ Agents.md
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
@ -45,6 +57,73 @@ ### Deterministic Capabilities
|
|||||||
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
- Backup/restore/risk/support flags MUST be derived deterministically from config/contracts via a Capabilities Resolver.
|
||||||
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
- The resolver output MUST be programmatically testable (snapshot/golden tests) so config changes cannot silently break behavior.
|
||||||
|
|
||||||
|
### Proportionality First (PROP-001)
|
||||||
|
- New structure, layering, persistence, or semantic machinery MUST be justified by current release truth, current operator workflow, and a concrete reason a narrower implementation is insufficient.
|
||||||
|
- Code MUST NOT become more generic, more layered, or more persistent than the current product actually needs.
|
||||||
|
- Reviews MUST reject speculative generalization framed only as future flexibility.
|
||||||
|
|
||||||
|
### No Premature Abstraction (ABSTR-001)
|
||||||
|
- New factories, registries, resolvers, strategy systems, interfaces, extension-point frameworks, type registries, or orchestration pipelines MUST NOT be introduced before at least two real concrete cases require them.
|
||||||
|
- Test convenience alone is not sufficient justification for a new abstraction.
|
||||||
|
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
|
||||||
|
|
||||||
|
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
|
||||||
|
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
|
||||||
|
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
|
||||||
|
- Convenience projections, UI helpers, speculative artifacts, derived summaries, and temporary semantic wrappers MUST remain derived unless current-release operator workflows require independent persistence.
|
||||||
|
- Release 2/3 entities MUST NOT be fully built in Release 1 unless they are foundational and already exercised by the shipped workflow.
|
||||||
|
|
||||||
|
### No New State Without Behavioral Consequence (STATE-001)
|
||||||
|
- New states, statuses, reason codes, lifecycle labels, and semantic categories MUST change operator action, workflow routing, permission or policy enforcement, lifecycle behavior, persistence truth, audit responsibility, retention behavior, or retry/failure handling.
|
||||||
|
- Presentation-only distinctions MUST remain derived labels rather than persisted domain state.
|
||||||
|
- Reason code families MUST NOT expand unless each added value has a distinct system or operator consequence.
|
||||||
|
|
||||||
|
### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
||||||
|
- Badges, explanation text, trust/confidence labels, detail cards, and status summaries MUST remain lightweight presentation helpers unless they are proven product contracts.
|
||||||
|
- New UI semantics MUST NOT require mandatory presenter, badge, explanation, taxonomy, or multi-step interpretation pipelines by default.
|
||||||
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
- The burden of proof is always on the broader abstraction.
|
||||||
|
|
||||||
|
### One Truth, Few Layers (LAYER-001)
|
||||||
|
- A single domain truth MUST NOT be redundantly modeled across model fields, service result objects, presenters, UI summaries, explanation builders, badge taxonomies, run context wrappers, and persisted mirror entities without clear necessity.
|
||||||
|
- Prefer one canonical truth with thin adapters.
|
||||||
|
- Any new layer MUST replace an existing layer or prove why the existing layer cannot serve the need.
|
||||||
|
- Additive semantic layering is discouraged; absorption is preferred over accumulation.
|
||||||
|
|
||||||
|
### Spec Discipline Over Slice Proliferation (SPEC-DISC-001)
|
||||||
|
- Related semantic, taxonomy, and presentation-contract changes SHOULD be grouped into one coherent spec instead of many micro-specs that each add classes, enums, DTOs, and tests.
|
||||||
|
- Every spec MUST explicitly state whether it introduces a new source of truth, persisted entity, abstraction, state, or cross-cutting framework.
|
||||||
|
- If the answer is yes, the spec MUST explain why the addition is necessary now.
|
||||||
|
|
||||||
|
### Tests Must Protect Business Truth (TEST-TRUTH-001)
|
||||||
|
- Testing is mandatory, but test growth MUST follow business truth rather than indirection created for its own sake.
|
||||||
|
- Tests MUST prioritize domain behavior, permissions, isolation, lifecycle correctness, and operator-critical outcomes.
|
||||||
|
- Large dedicated test surfaces for thin presentation indirection SHOULD be avoided.
|
||||||
|
- If a pattern creates more test burden than product certainty, the pattern SHOULD be simplified.
|
||||||
|
|
||||||
|
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
|
||||||
|
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
|
||||||
|
- Badge systems, explanation builders, trust/confidence overlays, presentation taxonomies, generic provider frameworks without real provider variance, speculative export/report/review infrastructure, UI meta-governance frameworks, and derived helper entities promoted into persisted truth are high-risk overproduction zones and require extra restraint.
|
||||||
|
|
||||||
|
### Mandatory Bloat Check for New Specs (BLOAT-001)
|
||||||
|
- Any spec that introduces a new enum or status family, DTO/envelope/presenter layer, persisted entity or table, interface/contract/registry/resolver, cross-domain UI framework, or taxonomy/classification system MUST include a proportionality review.
|
||||||
|
- That review MUST answer:
|
||||||
|
1. What current operator problem does this solve?
|
||||||
|
2. Why is existing structure insufficient?
|
||||||
|
3. Why is this the narrowest correct implementation?
|
||||||
|
4. What ownership cost does this create?
|
||||||
|
5. What alternative was intentionally rejected?
|
||||||
|
6. Is this current-release truth or future-release preparation?
|
||||||
|
- Specs that cannot answer these questions clearly MUST NOT merge.
|
||||||
|
|
||||||
|
### Default Bias (BIAS-001)
|
||||||
|
- Default codebase bias is: derive before persist, map before frameworkize, localize before generalize, simplify before extend, replace before layer, explicit before generic, and present directly before interpreting recursively.
|
||||||
|
|
||||||
### Workspace Isolation is Non-negotiable
|
### Workspace Isolation is Non-negotiable
|
||||||
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
- Workspace membership is an isolation boundary. If the actor is not entitled to the workspace scope, the system MUST respond as
|
||||||
deny-as-not-found (404).
|
deny-as-not-found (404).
|
||||||
@ -473,9 +552,12 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope & Compliance
|
### Scope, Compliance, and Review Expectations
|
||||||
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
|
||||||
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
|
||||||
|
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
|
||||||
|
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
|
||||||
|
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
|
||||||
|
|
||||||
### Amendment Procedure
|
### Amendment Procedure
|
||||||
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
- Propose changes as a PR that updates `.specify/memory/constitution.md`.
|
||||||
@ -487,4 +569,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.13.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-26
|
**Version**: 1.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
||||||
|
|||||||
@ -48,6 +48,13 @@ ## Constitution Check
|
|||||||
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
|
||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
|
||||||
|
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
|
||||||
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
- Filament-native UI (UI-FIL-001): admin/operator surfaces use native Filament components or shared primitives first; no ad-hoc status UI, local semantic color/border decisions, or hand-built replacements when native/shared semantics exist; any exception is explicitly justified
|
||||||
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
|
||||||
@ -122,9 +129,20 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|-----------|------------|-------------------------------------|
|
|-----------|------------|-------------------------------------|
|
||||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
> **Fill when the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact, interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework.**
|
||||||
|
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk requires this?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current code cannot serve safely or clearly]
|
||||||
|
- **Narrowest correct implementation**: [Why this shape is the smallest viable one]
|
||||||
|
- **Ownership cost created**: [Maintenance, testing, cognitive load, migration, or review burden]
|
||||||
|
- **Alternative intentionally rejected**: [Simpler option and why it failed]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|||||||
@ -25,6 +25,27 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
| e.g. Tenant policies page | Tenant operator | List/detail | What needs action right now? | Policy health, drift, assignment coverage | Raw payloads, provider IDs, low-level API details | lifecycle, data completeness, governance result | TenantPilot only / Microsoft tenant / simulation only | Sync policies, View policy | Restore policy |
|
||||||
|
|
||||||
|
## 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?**: [yes/no]
|
||||||
|
- **New persisted entity/table/artifact?**: [yes/no]
|
||||||
|
- **New abstraction?**: [yes/no]
|
||||||
|
- **New enum/state/reason family?**: [yes/no]
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: [yes/no]
|
||||||
|
- **Current operator problem**: [What present-day workflow or risk does this solve?]
|
||||||
|
- **Existing structure is insufficient because**: [Why the current implementation shape cannot safely or clearly solve it]
|
||||||
|
- **Narrowest correct implementation**: [Why this is the smallest viable solution]
|
||||||
|
- **Ownership cost**: [What maintenance, testing, review, migration, or conceptual cost this adds]
|
||||||
|
- **Alternative intentionally rejected**: [What simpler option was considered and why it was not sufficient]
|
||||||
|
- **Release truth**: [Current-release truth or future-release preparation]
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -102,6 +123,16 @@ ## Requirements *(mandatory)*
|
|||||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** If this feature introduces new persistence,
|
||||||
|
new abstractions, new states, or new semantic layers, the spec MUST explain:
|
||||||
|
- which current operator workflow or current product truth requires the addition now,
|
||||||
|
- why a narrower implementation is insufficient,
|
||||||
|
- whether the addition is current-release truth or future-release preparation,
|
||||||
|
- what ownership cost it creates,
|
||||||
|
- and how the choice follows the default bias of deriving before persisting, replacing before layering, and being explicit before generic.
|
||||||
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
||||||
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
|
||||||
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
|
||||||
@ -150,6 +181,13 @@ ## Requirements *(mandatory)*
|
|||||||
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
- how workspace and tenant context remain explicit in navigation, action copy, and page semantics,
|
||||||
- and the page contract for each new or materially refactored operator-facing page.
|
- and the page contract for each new or materially refactored operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** If this feature adds UI semantics, presenters, explanation layers,
|
||||||
|
status taxonomies, or other interpretation layers, the spec MUST describe:
|
||||||
|
- why direct mapping from canonical domain truth to UI is insufficient,
|
||||||
|
- which existing layer is replaced or why no existing layer can serve,
|
||||||
|
- how the feature avoids creating redundant truth across models, service results, presenters, summaries, wrappers, and persisted mirrors,
|
||||||
|
- and how tests focus on business consequences rather than thin indirection alone.
|
||||||
|
|
||||||
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
**Constitution alignment (Filament Action Surfaces):** If this feature adds or modifies any Filament Resource / RelationManager / Page,
|
||||||
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
the spec MUST include a “UI Action Matrix” (see below) and explicitly state whether the Action Surface Contract is satisfied.
|
||||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||||
|
|||||||
@ -67,6 +67,13 @@ # Tasks: [FEATURE NAME]
|
|||||||
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
- OR documenting an explicit exemption with rationale if UX-001 is not fully satisfied.
|
||||||
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
**Badges**: If this feature changes status-like badge semantics, tasks MUST use `BadgeCatalog` / `BadgeRenderer` (BADGE-001),
|
||||||
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
avoid ad-hoc mappings in Filament, and include mapping tests for any new/changed values.
|
||||||
|
**Proportionality / Anti-Bloat**: If this feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table/artifact,
|
||||||
|
interface/contract/registry/resolver, taxonomy/classification system, or cross-domain UI framework, tasks MUST include:
|
||||||
|
- completing the spec’s Proportionality Review,
|
||||||
|
- implementing the narrowest correct shape justified by current-release truth,
|
||||||
|
- removing or replacing superseded layers where practical instead of stacking new ones on top,
|
||||||
|
- keeping convenience projections and UI helpers derived unless independent persistence is explicitly justified,
|
||||||
|
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
@ -213,6 +220,7 @@ ## Phase N: Polish & Cross-Cutting Concerns
|
|||||||
- [ ] TXXX Performance optimization across all stories
|
- [ ] TXXX Performance optimization across all stories
|
||||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
- [ ] TXXX Security hardening
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Proportionality cleanup: remove or collapse superseded layers introduced during implementation
|
||||||
- [ ] TXXX Run quickstart.md validation
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -25,12 +25,14 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/constitution.md`
|
1. Read `.specify/memory/constitution.md`
|
||||||
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
2. For new work: create/update `specs/<NNN>-<slug>/spec.md`
|
||||||
3. Produce `specs/<NNN>-<slug>/plan.md`
|
3. Produce `specs/<NNN>-<slug>/plan.md`
|
||||||
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
4. Break into `specs/<NNN>-<slug>/tasks.md`
|
||||||
5. Implement changes in small PRs
|
5. Implement changes in small PRs
|
||||||
|
|
||||||
|
Any spec that introduces a new persisted entity, abstraction, enum/status family, or taxonomy/framework must include the proportionality review required by the constitution before implementation starts.
|
||||||
|
|
||||||
If requirements change during implementation, update spec/plan before continuing.
|
If requirements change during implementation, update spec/plan before continuing.
|
||||||
|
|
||||||
## Workflow (SDD in diesem Repo)
|
## Workflow (SDD in diesem Repo)
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -281,6 +282,11 @@ public function table(Table $table): Table
|
|||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
|
TextColumn::make('governance_warning')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (FindingException $record): ?string => $this->governanceWarning($record))
|
||||||
|
->color(fn (FindingException $record): string => $this->governanceWarningColor($record))
|
||||||
|
->wrap(),
|
||||||
TextColumn::make('requester.name')
|
TextColumn::make('requester.name')
|
||||||
->label('Requested by')
|
->label('Requested by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -500,4 +506,34 @@ private function hasActiveQueueFilters(): bool
|
|||||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
|| is_string(data_get($this->tableFilters, 'status.value'))
|
||||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function governanceWarning(FindingException $record): ?string
|
||||||
|
{
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function governanceWarningColor(FindingException $record): string
|
||||||
|
{
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
$finding = $record->relationLoaded('finding')
|
||||||
|
? $record->finding
|
||||||
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,8 +54,6 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
*/
|
*/
|
||||||
public ?array $navigationContextPayload = null;
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
public bool $opsUxIsTabHidden = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
@ -286,10 +284,6 @@ public function pollInterval(): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->opsUxIsTabHidden === true) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled($this->mountedActions ?? null)) {
|
if (filled($this->mountedActions ?? null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
use App\Services\Findings\FindingExceptionService;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -122,6 +124,36 @@ public static function getEloquentQuery(): Builder
|
|||||||
->with(static::relationshipsForView());
|
->with(static::relationshipsForView());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{active: int, expiring: int, expired: int, pending: int, total: int}
|
||||||
|
*/
|
||||||
|
public static function exceptionStatsForCurrentTenant(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['active' => 0, 'expiring' => 0, 'expired' => 0, 'pending' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = FindingException::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('workspace_id', (int) $tenant->workspace_id)
|
||||||
|
->selectRaw('count(*) as total')
|
||||||
|
->selectRaw("count(*) filter (where status = 'active') as active")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expiring') as expiring")
|
||||||
|
->selectRaw("count(*) filter (where status = 'expired') as expired")
|
||||||
|
->selectRaw("count(*) filter (where status = 'pending') as pending")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => (int) ($counts?->active ?? 0),
|
||||||
|
'expiring' => (int) ($counts?->expiring ?? 0),
|
||||||
|
'expired' => (int) ($counts?->expired ?? 0),
|
||||||
|
'pending' => (int) ($counts?->pending ?? 0),
|
||||||
|
'total' => (int) ($counts?->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||||
{
|
{
|
||||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
||||||
@ -238,10 +270,21 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('finding.severity')
|
||||||
|
->label('Severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity))
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('finding_summary')
|
Tables\Columns\TextColumn::make('finding_summary')
|
||||||
->label('Finding')
|
->label('Finding')
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->wrap()
|
||||||
|
->limit(60),
|
||||||
Tables\Columns\TextColumn::make('governance_warning')
|
Tables\Columns\TextColumn::make('governance_warning')
|
||||||
->label('Governance warning')
|
->label('Governance warning')
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
||||||
@ -257,7 +300,14 @@ public static function table(Table $table): Table
|
|||||||
->label('Review due')
|
->label('Review due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
->sortable(),
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->review_due_at)),
|
||||||
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
|
->label('Expires')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('—')
|
||||||
|
->sortable()
|
||||||
|
->description(fn (FindingException $record): ?string => static::relativeTimeDescription($record->expires_at)),
|
||||||
Tables\Columns\TextColumn::make('requested_at')
|
Tables\Columns\TextColumn::make('requested_at')
|
||||||
->label('Requested')
|
->label('Requested')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
@ -269,6 +319,12 @@ public static function table(Table $table): Table
|
|||||||
SelectFilter::make('current_validity_state')
|
SelectFilter::make('current_validity_state')
|
||||||
->label('Validity')
|
->label('Validity')
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
||||||
|
SelectFilter::make('finding_severity')
|
||||||
|
->label('Finding severity')
|
||||||
|
->options(FilterOptionCatalog::findingSeverities())
|
||||||
|
->query(fn (Builder $query, array $data): Builder => filled($data['value'])
|
||||||
|
? $query->whereHas('finding', fn (Builder $q) => $q->where('severity', $data['value']))
|
||||||
|
: $query),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
@ -441,15 +497,62 @@ private static function tenantMemberOptions(): array
|
|||||||
|
|
||||||
private static function findingSummary(FindingException $record): string
|
private static function findingSummary(FindingException $record): string
|
||||||
{
|
{
|
||||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
$finding = $record->finding;
|
||||||
|
|
||||||
if (is_string($summary) && trim($summary) !== '') {
|
if (! $finding instanceof \App\Models\Finding) {
|
||||||
return trim($summary);
|
return 'Finding #'.$record->finding_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayName = $finding->resolvedSubjectDisplayName();
|
||||||
|
$findingType = $finding->finding_type;
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if (is_string($displayName) && trim($displayName) !== '') {
|
||||||
|
$parts[] = trim($displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($findingType) && trim($findingType) !== '') {
|
||||||
|
$label = str_replace('_', ' ', trim($findingType));
|
||||||
|
$parts[] = '('.ucfirst($label).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts !== []) {
|
||||||
|
return implode(' ', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Finding #'.$record->finding_id;
|
return 'Finding #'.$record->finding_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function relativeTimeDescription(mixed $date): ?string
|
||||||
|
{
|
||||||
|
if (! $date instanceof \DateTimeInterface) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$carbon = \Illuminate\Support\Carbon::instance($date);
|
||||||
|
|
||||||
|
if ($carbon->isToday()) {
|
||||||
|
return 'Today';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isPast()) {
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($carbon->isTomorrow()) {
|
||||||
|
return 'Tomorrow';
|
||||||
|
}
|
||||||
|
|
||||||
|
$daysUntil = (int) now()->startOfDay()->diffInDays($carbon->startOfDay());
|
||||||
|
|
||||||
|
if ($daysUntil <= 14) {
|
||||||
|
return 'In '.$daysUntil.' days';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $carbon->diffForHumans();
|
||||||
|
}
|
||||||
|
|
||||||
private static function canManageRecord(FindingException $record): bool
|
private static function canManageRecord(FindingException $record): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -479,10 +582,49 @@ private static function governanceWarningColor(FindingException $record): string
|
|||||||
? $record->finding
|
? $record->finding
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
: $record->finding()->withSubjectDisplayName()->first();
|
||||||
|
|
||||||
|
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function canAccessApprovalQueueForTenant(?Tenant $tenant = null): bool
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $workspace)
|
||||||
|
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function approvalQueueUrl(?Tenant $tenant = null): ?string
|
||||||
|
{
|
||||||
|
$tenant ??= static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route('admin.finding-exceptions.open-queue', [
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,13 +6,57 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
|
use App\Models\FindingException;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ListFindingExceptions extends ListRecords
|
class ListFindingExceptions extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
protected static string $resource = FindingExceptionResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
FindingExceptionStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
$needsAction = $stats['pending'] + $stats['expiring'] + $stats['expired'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_PENDING,
|
||||||
|
FindingException::STATUS_EXPIRING,
|
||||||
|
FindingException::STATUS_EXPIRED,
|
||||||
|
]))
|
||||||
|
->badge($needsAction > 0 ? $needsAction : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'active' => Tab::make('Active')
|
||||||
|
->icon('heroicon-m-check-badge')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->where('status', FindingException::STATUS_ACTIVE)),
|
||||||
|
'historical' => Tab::make('Historical')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [
|
||||||
|
FindingException::STATUS_REJECTED,
|
||||||
|
FindingException::STATUS_REVOKED,
|
||||||
|
FindingException::STATUS_SUPERSEDED,
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -21,6 +65,12 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url(FindingResource::getUrl('index')),
|
->url(FindingResource::getUrl('index')),
|
||||||
|
Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => FindingExceptionResource::canAccessApprovalQueueForTenant())
|
||||||
|
->url(fn (): ?string => FindingExceptionResource::approvalQueueUrl()),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,23 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
||||||
}),
|
}),
|
||||||
|
Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof FindingException
|
||||||
|
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||||
|
})
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof FindingException
|
||||||
|
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
|
|||||||
@ -64,6 +64,11 @@ class FindingResource extends Resource
|
|||||||
use InteractsWithTenantOwnedRecords;
|
use InteractsWithTenantOwnedRecords;
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, RelatedContextEntry|null>
|
||||||
|
*/
|
||||||
|
private static array $primaryRelatedEntryCache = [];
|
||||||
|
|
||||||
protected static ?string $model = Finding::class;
|
protected static ?string $model = Finding::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -146,6 +151,65 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Status and next action')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||||
|
TextEntry::make('severity')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
|
TextEntry::make('finding_due_attention')
|
||||||
|
->label('Due state')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): ?string => static::dueAttentionLabel($record))
|
||||||
|
->color(fn (Finding $record): string => static::dueAttentionColor($record))
|
||||||
|
->visible(fn (Finding $record): bool => static::dueAttentionLabel($record) !== null),
|
||||||
|
TextEntry::make('finding_governance_validity_leading')
|
||||||
|
->label('Governance')
|
||||||
|
->badge()
|
||||||
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->visible(fn (Finding $record): bool => static::governanceValidityState($record) !== null),
|
||||||
|
TextEntry::make('owner_user_id_leading')
|
||||||
|
->label('Owner')
|
||||||
|
->state(fn (Finding $record): string => $record->ownerUser?->name ?? 'Unassigned'),
|
||||||
|
TextEntry::make('assignee_user_id_leading')
|
||||||
|
->label('Assignee')
|
||||||
|
->state(fn (Finding $record): string => $record->assigneeUser?->name ?? 'Unassigned'),
|
||||||
|
TextEntry::make('finding_primary_narrative')
|
||||||
|
->label('Current reading')
|
||||||
|
->state(fn (Finding $record): string => static::primaryNarrative($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextEntry::make('finding_governance_warning_leading')
|
||||||
|
->label('Governance warning')
|
||||||
|
->state(fn (Finding $record): ?string => static::governanceWarning($record))
|
||||||
|
->color(fn (Finding $record): string => static::governanceWarningColor($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::governanceWarning($record) !== null),
|
||||||
|
TextEntry::make('finding_historical_context')
|
||||||
|
->label('Historical context')
|
||||||
|
->state(fn (Finding $record): ?string => static::historicalContext($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::historicalContext($record) !== null),
|
||||||
|
TextEntry::make('finding_primary_next_action')
|
||||||
|
->label('Next action')
|
||||||
|
->state(fn (Finding $record): ?string => static::primaryNextAction($record))
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Finding $record): bool => static::primaryNextAction($record) !== null),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Finding')
|
Section::make('Finding')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||||
@ -628,19 +692,45 @@ public static function table(Table $table): Table
|
|||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
Tables\Columns\TextColumn::make('finding_type')
|
||||||
|
->badge()
|
||||||
|
->label('Type')
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingType))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingType))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingType)),
|
||||||
|
Tables\Columns\TextColumn::make('subject_display_name')
|
||||||
|
->label('Subject')
|
||||||
|
->placeholder('—')
|
||||||
|
->searchable()
|
||||||
|
->limit(40)
|
||||||
|
->wrap()
|
||||||
|
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||||
|
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
||||||
|
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
||||||
|
Tables\Columns\TextColumn::make('severity')
|
||||||
|
->badge()
|
||||||
|
->sortable()
|
||||||
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||||
Tables\Columns\TextColumn::make('severity')
|
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||||
|
Tables\Columns\TextColumn::make('governance_validity')
|
||||||
|
->label('Governance')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
||||||
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => static::governanceWarning($record)),
|
||||||
Tables\Columns\TextColumn::make('evidence_fidelity')
|
Tables\Columns\TextColumn::make('evidence_fidelity')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->badge()
|
->badge()
|
||||||
@ -652,12 +742,6 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('subject_display_name')
|
|
||||||
->label('Subject')
|
|
||||||
->placeholder('—')
|
|
||||||
->searchable()
|
|
||||||
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
|
|
||||||
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
|
|
||||||
Tables\Columns\TextColumn::make('subject_type')
|
Tables\Columns\TextColumn::make('subject_type')
|
||||||
->label('Subject type')
|
->label('Subject type')
|
||||||
->searchable()
|
->searchable()
|
||||||
@ -667,18 +751,19 @@ public static function table(Table $table): Table
|
|||||||
->label('Due')
|
->label('Due')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabel($record)),
|
||||||
Tables\Columns\TextColumn::make('assigneeUser.name')
|
Tables\Columns\TextColumn::make('assigneeUser.name')
|
||||||
->label('Assignee')
|
->label('Assignee')
|
||||||
->placeholder('—'),
|
->placeholder('—')
|
||||||
|
->description(fn (Finding $record): string => $record->ownerUser?->name !== null ? 'Owner: '.$record->ownerUser->name : 'Owner: unassigned'),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\Filter::make('open')
|
Tables\Filters\Filter::make('open')
|
||||||
->label('Open')
|
->label('Active workflow')
|
||||||
->default()
|
|
||||||
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
->query(fn (Builder $query): Builder => $query->whereIn('status', Finding::openStatusesForQuery())),
|
||||||
Tables\Filters\Filter::make('overdue')
|
Tables\Filters\Filter::make('overdue')
|
||||||
->label('Overdue')
|
->label('Overdue')
|
||||||
@ -706,6 +791,45 @@ public static function table(Table $table): Table
|
|||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(FilterOptionCatalog::findingStatuses())
|
->options(FilterOptionCatalog::findingStatuses())
|
||||||
->label('Status'),
|
->label('Status'),
|
||||||
|
Tables\Filters\SelectFilter::make('workflow_family')
|
||||||
|
->label('Workflow family')
|
||||||
|
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'active' => $query->whereIn('status', Finding::openStatusesForQuery()),
|
||||||
|
'accepted_risk' => $query->where('status', Finding::STATUS_RISK_ACCEPTED),
|
||||||
|
'historical' => $query->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED]),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
Tables\Filters\SelectFilter::make('governance_validity')
|
||||||
|
->label('Governance')
|
||||||
|
->options(FilterOptionCatalog::findingExceptionValidityStates())
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$value = $data['value'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === FindingException::VALIDITY_MISSING_SUPPORT) {
|
||||||
|
return $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereDoesntHave('findingException');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereHas('findingException', function (Builder $exceptionQuery) use ($value): void {
|
||||||
|
$exceptionQuery->where('current_validity_state', $value);
|
||||||
|
});
|
||||||
|
}),
|
||||||
Tables\Filters\SelectFilter::make('finding_type')
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
->options([
|
->options([
|
||||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
@ -760,9 +884,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
FilterPresets::dateRange('created_at', 'Created', 'created_at'),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
->recordUrl(static fn (Finding $record): string => static::getUrl('view', ['record' => $record]))
|
||||||
? static::getUrl('view', ['record' => $record])
|
|
||||||
: null)
|
|
||||||
->actions([
|
->actions([
|
||||||
static::primaryRelatedAction(),
|
static::primaryRelatedAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -1078,7 +1200,7 @@ public static function table(Table $table): Table
|
|||||||
])->label('More'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No findings match this view')
|
->emptyStateHeading('No findings match this view')
|
||||||
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.')
|
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface findings and governance follow-up.')
|
||||||
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1131,7 +1253,15 @@ private static function primaryRelatedAction(): Actions\Action
|
|||||||
|
|
||||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
return app(RelatedNavigationResolver::class)
|
$cacheKey = is_numeric($record->getKey())
|
||||||
|
? (string) $record->getKey()
|
||||||
|
: spl_object_hash($record);
|
||||||
|
|
||||||
|
if (array_key_exists($cacheKey, static::$primaryRelatedEntryCache)) {
|
||||||
|
return static::$primaryRelatedEntryCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::$primaryRelatedEntryCache[$cacheKey] = app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $record);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1186,7 +1316,7 @@ public static function triageAction(): Actions\Action
|
|||||||
->label('Triage')
|
->label('Triage')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
@ -1212,7 +1342,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
->label('Start progress')
|
->label('Start progress')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
@ -1237,7 +1367,7 @@ public static function assignAction(): Actions\Action
|
|||||||
->label('Assign')
|
->label('Assign')
|
||||||
->icon('heroicon-o-user-plus')
|
->icon('heroicon-o-user-plus')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'assignee_user_id' => $record->assignee_user_id,
|
'assignee_user_id' => $record->assignee_user_id,
|
||||||
'owner_user_id' => $record->owner_user_id,
|
'owner_user_id' => $record->owner_user_id,
|
||||||
@ -1281,7 +1411,7 @@ public static function resolveAction(): Actions\Action
|
|||||||
->label('Resolve')
|
->label('Resolve')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
@ -1316,7 +1446,7 @@ public static function closeAction(): Actions\Action
|
|||||||
->label('Close')
|
->label('Close')
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
@ -1351,7 +1481,7 @@ public static function requestExceptionAction(): Actions\Action
|
|||||||
->label('Request exception')
|
->label('Request exception')
|
||||||
->icon('heroicon-o-shield-exclamation')
|
->icon('heroicon-o-shield-exclamation')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Select::make('owner_user_id')
|
Select::make('owner_user_id')
|
||||||
@ -1412,9 +1542,9 @@ public static function renewExceptionAction(): Actions\Action
|
|||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRenewed() ?? false)
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'owner_user_id' => static::currentFindingException($record)?->owner_user_id,
|
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
||||||
])
|
])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
@ -1476,7 +1606,7 @@ public static function revokeExceptionAction(): Actions\Action
|
|||||||
->label('Revoke exception')
|
->label('Revoke exception')
|
||||||
->icon('heroicon-o-no-symbol')
|
->icon('heroicon-o-no-symbol')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::currentFindingException($record)?->canBeRevoked() ?? false)
|
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('revocation_reason')
|
Textarea::make('revocation_reason')
|
||||||
@ -1503,7 +1633,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
@ -1701,6 +1831,21 @@ private static function currentFindingException(Finding $record): ?FindingExcept
|
|||||||
return static::resolvedFindingException($finding);
|
return static::resolvedFindingException($finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function loadedFindingException(Finding $finding): ?FindingException
|
||||||
|
{
|
||||||
|
$exception = $finding->relationLoaded('findingException')
|
||||||
|
? $finding->findingException
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $exception instanceof FindingException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exception->loadMissing('currentDecision');
|
||||||
|
|
||||||
|
return $exception;
|
||||||
|
}
|
||||||
|
|
||||||
private static function resolvedFindingException(Finding $finding): ?FindingException
|
private static function resolvedFindingException(Finding $finding): ?FindingException
|
||||||
{
|
{
|
||||||
$exception = $finding->relationLoaded('findingException')
|
$exception = $finding->relationLoaded('findingException')
|
||||||
@ -1748,6 +1893,10 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
{
|
{
|
||||||
$exception = static::resolvedFindingException($finding);
|
$exception = static::resolvedFindingException($finding);
|
||||||
|
|
||||||
|
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
}
|
}
|
||||||
@ -1755,6 +1904,56 @@ private static function governanceWarningColor(Finding $finding): string
|
|||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function governanceValidityState(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryNarrative(Finding $finding): string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolvePrimaryNarrative($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function historicalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolveHistoricalContext($finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function primaryNextAction(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return app(FindingRiskGovernanceResolver::class)
|
||||||
|
->resolvePrimaryNextAction($finding, static::resolvedFindingException($finding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dueAttentionLabel(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
if (! $finding->hasOpenStatus() || ! $finding->due_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at->isPast()) {
|
||||||
|
return 'Overdue';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->due_at->lessThanOrEqualTo(now()->addDays(3))) {
|
||||||
|
return 'Due soon';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dueAttentionColor(Finding $finding): string
|
||||||
|
{
|
||||||
|
return match (static::dueAttentionLabel($finding)) {
|
||||||
|
'Overdue' => 'danger',
|
||||||
|
'Due soon' => 'warning',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -1774,4 +1973,35 @@ private static function tenantMemberOptions(): array
|
|||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{open: int, overdue: int, high_severity: int, risk_accepted: int, total: int}
|
||||||
|
*/
|
||||||
|
public static function findingStatsForCurrentTenant(): array
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return ['open' => 0, 'overdue' => 0, 'high_severity' => 0, 'risk_accepted' => 0, 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now()->toDateTimeString();
|
||||||
|
|
||||||
|
$counts = Finding::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->selectRaw('count(*) as total')
|
||||||
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as open")
|
||||||
|
->selectRaw("sum(case when status in ('new', 'triaged', 'in_progress', 'reopened') and due_at is not null and due_at < ? then 1 else 0 end) as overdue", [$now])
|
||||||
|
->selectRaw("sum(case when severity in ('high', 'critical') and status in ('new', 'triaged', 'in_progress', 'reopened') then 1 else 0 end) as high_severity")
|
||||||
|
->selectRaw("sum(case when status = 'risk_accepted' then 1 else 0 end) as risk_accepted")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open' => (int) ($counts?->open ?? 0),
|
||||||
|
'overdue' => (int) ($counts?->overdue ?? 0),
|
||||||
|
'high_severity' => (int) ($counts?->high_severity ?? 0),
|
||||||
|
'risk_accepted' => (int) ($counts?->risk_accepted ?? 0),
|
||||||
|
'total' => (int) ($counts?->total ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
use App\Jobs\BackfillFindingLifecycleJob;
|
use App\Jobs\BackfillFindingLifecycleJob;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -22,6 +23,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@ -61,6 +63,42 @@ protected function getHeaderWidgets(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
BaselineCompareCoverageBanner::class,
|
BaselineCompareCoverageBanner::class,
|
||||||
|
FindingStatsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, Tab>
|
||||||
|
*/
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-m-list-bullet'),
|
||||||
|
'needs_action' => Tab::make('Needs action')
|
||||||
|
->icon('heroicon-m-exclamation-triangle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery()))
|
||||||
|
->badge($stats['open'] > 0 ? $stats['open'] : null)
|
||||||
|
->badgeColor('warning'),
|
||||||
|
'overdue' => Tab::make('Overdue')
|
||||||
|
->icon('heroicon-m-clock')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now()))
|
||||||
|
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
|
||||||
|
->badgeColor('danger'),
|
||||||
|
'risk_accepted' => Tab::make('Risk accepted')
|
||||||
|
->icon('heroicon-m-shield-check')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)),
|
||||||
|
'resolved' => Tab::make('Resolved')
|
||||||
|
->icon('heroicon-m-archive-box')
|
||||||
|
->modifyQueryUsing(fn (Builder $query): Builder => $query
|
||||||
|
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\FindingResource\Pages;
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -30,6 +32,23 @@ protected function getHeaderActions(): array
|
|||||||
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
->hidden(fn (): bool => ! (app(RelatedNavigationResolver::class)
|
||||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
|
Actions\Action::make('open_approval_queue')
|
||||||
|
->label('Open approval queue')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(function (): bool {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof Finding
|
||||||
|
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
||||||
|
})
|
||||||
|
->url(function (): ?string {
|
||||||
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
return $record instanceof Finding
|
||||||
|
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
Actions\ActionGroup::make(FindingResource::workflowActions())
|
Actions\ActionGroup::make(FindingResource::workflowActions())
|
||||||
->label('Actions')
|
->label('Actions')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-ellipsis-vertical')
|
||||||
|
|||||||
@ -44,16 +44,16 @@ protected function getViewData(): array
|
|||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$runUrl = $stats->operationRunId !== null
|
$runUrl = $stats->operationRunId !== null
|
||||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
||||||
: null;
|
: null;
|
||||||
$findingsUrl = FindingResource::getUrl('index', tenant: $tenant);
|
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
$summaryAssessment = $stats->summaryAssessment();
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
'findings' => $findingsUrl,
|
'findings' => $findingsUrl,
|
||||||
'landing' => $landingUrl,
|
'landing' => $tenantLandingUrl,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ protected function getViewData(): array
|
|||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => $landingUrl,
|
'landingUrl' => $tenantLandingUrl,
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'findingsUrl' => $findingsUrl,
|
'findingsUrl' => $findingsUrl,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'nextActionUrl' => $nextActionUrl,
|
||||||
|
|||||||
@ -36,17 +36,78 @@ protected function getViewData(): array
|
|||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
|
|
||||||
|
$overdueOpenCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$lapsedGovernanceCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query
|
||||||
|
->whereDoesntHave('findingException')
|
||||||
|
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||||
|
$exceptionQuery->whereIn('current_validity_state', [
|
||||||
|
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||||
|
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$expiringGovernanceCount = (int) Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereHas('findingException', function ($query): void {
|
||||||
|
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
$highSeverityCount = (int) Finding::query()
|
$highSeverityCount = (int) Finding::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
->where('status', Finding::STATUS_NEW)
|
->whereIn('severity', [
|
||||||
->where('severity', Finding::SEVERITY_HIGH)
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
])
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
if ($lapsedGovernanceCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Lapsed accepted-risk governance',
|
||||||
|
'body' => "{$lapsedGovernanceCount} finding(s) need governance follow-up before accepted risk is safe to rely on.",
|
||||||
|
'badge' => 'Governance',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Overdue findings',
|
||||||
|
'body' => "{$overdueOpenCount} open finding(s) are overdue and still need workflow follow-up.",
|
||||||
|
'badge' => 'Findings',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiringGovernanceCount > 0) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Expiring accepted-risk governance',
|
||||||
|
'body' => "{$expiringGovernanceCount} finding(s) will need governance review soon.",
|
||||||
|
'badge' => 'Governance',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($highSeverityCount > 0) {
|
if ($highSeverityCount > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'High severity drift findings',
|
'title' => 'High severity active findings',
|
||||||
'body' => "{$highSeverityCount} finding(s) need review.",
|
'body' => "{$highSeverityCount} active finding(s) need review.",
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
@ -87,8 +148,16 @@ protected function getViewData(): array
|
|||||||
'body' => $compareAssessment->headline,
|
'body' => $compareAssessment->headline,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No high severity drift is open',
|
'title' => 'No overdue findings',
|
||||||
'body' => 'No high severity drift findings are currently open for this tenant.',
|
'body' => 'No open findings are currently overdue for this tenant.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Accepted-risk governance is healthy',
|
||||||
|
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'No high severity active findings',
|
||||||
|
'body' => 'No high severity findings are currently open for this tenant.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No active operations',
|
'title' => 'No active operations',
|
||||||
|
|||||||
@ -38,14 +38,15 @@ protected function getViewData(): array
|
|||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(tenant: $tenant);
|
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
||||||
'run' => $runUrl,
|
'run' => $runUrl,
|
||||||
|
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
'landing' => $landingUrl,
|
'landing' => $landingUrl,
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
||||||
|| ($summaryAssessment->stateFamily === 'action_required' && $summaryAssessment->evaluationResult === 'failed_result');
|
|| $summaryAssessment->stateFamily === 'action_required';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $shouldShow,
|
'shouldShow' => $shouldShow,
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class FindingExceptionStatsOverview extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$counts = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Active', $counts['active'])
|
||||||
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('success'),
|
||||||
|
Stat::make('Expiring', $counts['expiring'])
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color('warning'),
|
||||||
|
Stat::make('Expired', $counts['expired'])
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->color('danger'),
|
||||||
|
Stat::make('Pending approval', $counts['pending'])
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
39
app/Filament/Widgets/Tenant/FindingStatsOverview.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class FindingStatsOverview extends BaseWidget
|
||||||
|
{
|
||||||
|
protected static bool $isLazy = false;
|
||||||
|
|
||||||
|
protected ?string $pollingInterval = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Stat>
|
||||||
|
*/
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
$counts = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('Open', $counts['open'])
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->color($counts['open'] > 0 ? 'warning' : 'success'),
|
||||||
|
Stat::make('Overdue', $counts['overdue'])
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->color($counts['overdue'] > 0 ? 'danger' : 'gray'),
|
||||||
|
Stat::make('High severity', $counts['high_severity'])
|
||||||
|
->icon('heroicon-o-fire')
|
||||||
|
->color($counts['high_severity'] > 0 ? 'danger' : 'gray'),
|
||||||
|
Stat::make('Risk accepted', $counts['risk_accepted'])
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class OpenFindingExceptionsQueueController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Tenant $tenant): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->first();
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext = app(WorkspaceContext::class);
|
||||||
|
|
||||||
|
if (! $workspaceContext->isMember($user, $workspace)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceContext->setCurrentWorkspace($workspace, $user, $request);
|
||||||
|
|
||||||
|
if (! $workspaceContext->rememberTenantContext($tenant, $request)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to(FindingExceptionsQueue::getUrl([
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
], panel: 'admin'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
@ -119,15 +120,10 @@ public function tenantRoleValue(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $this->tenants()
|
/** @var CapabilityResolver $resolver */
|
||||||
->whereKey($tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->value('role');
|
|
||||||
|
|
||||||
if (! is_string($role)) {
|
return $resolver->getRole($this, $tenant)?->value;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $role;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function allowsTenantSync(Tenant $tenant): bool
|
public function allowsTenantSync(Tenant $tenant): bool
|
||||||
@ -145,9 +141,10 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenantMemberships()
|
/** @var CapabilityResolver $resolver */
|
||||||
->where('tenant_id', $tenant->getKey())
|
$resolver = app(CapabilityResolver::class);
|
||||||
->exists();
|
|
||||||
|
return $resolver->isMember($this, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
use App\Policies\FindingPolicy;
|
use App\Policies\FindingPolicy;
|
||||||
use App\Policies\OperationRunPolicy;
|
use App\Policies\OperationRunPolicy;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\DeviceComplianceSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\FallbackSnapshotTypeRenderer;
|
||||||
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
use App\Services\Baselines\SnapshotRendering\Renderers\IntuneRoleDefinitionSnapshotTypeRenderer;
|
||||||
@ -76,6 +78,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->singleton(CapabilityResolver::class);
|
||||||
|
$this->app->singleton(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
$this->app->bind(FindingGeneratorContract::class, PermissionPostureFindingGenerator::class);
|
||||||
|
|
||||||
$this->app->bind(
|
$this->app->bind(
|
||||||
|
|||||||
@ -12,6 +12,16 @@
|
|||||||
|
|
||||||
final class FindingRiskGovernanceResolver
|
final class FindingRiskGovernanceResolver
|
||||||
{
|
{
|
||||||
|
public function resolveWorkflowFamily(Finding $finding): string
|
||||||
|
{
|
||||||
|
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||||
|
Finding::STATUS_RISK_ACCEPTED => 'accepted_risk',
|
||||||
|
Finding::STATUS_RESOLVED,
|
||||||
|
Finding::STATUS_CLOSED => 'historical',
|
||||||
|
default => 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||||
{
|
{
|
||||||
$now ??= CarbonImmutable::instance(now());
|
$now ??= CarbonImmutable::instance(now());
|
||||||
@ -111,6 +121,43 @@ public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException
|
|||||||
], true);
|
], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolveGovernanceValidity(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
|
{
|
||||||
|
$exception ??= $finding->relationLoaded('findingException')
|
||||||
|
? $finding->findingException
|
||||||
|
: $finding->findingException()->first();
|
||||||
|
|
||||||
|
if ($exception instanceof FindingException) {
|
||||||
|
return $this->resolveValidityState($exception, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $finding->isRiskAccepted()
|
||||||
|
? FindingException::VALIDITY_MISSING_SUPPORT
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||||
|
{
|
||||||
|
if (! $finding->isRiskAccepted()) {
|
||||||
|
return 'not_applicable';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||||
|
FindingException::VALIDITY_VALID => 'healthy',
|
||||||
|
FindingException::VALIDITY_EXPIRING,
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_REVOKED,
|
||||||
|
FindingException::VALIDITY_REJECTED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT => 'attention_needed',
|
||||||
|
default => 'attention_needed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresGovernanceAttention(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
|
||||||
|
{
|
||||||
|
return $this->resolveGovernanceAttention($finding, $exception, $now) === 'attention_needed';
|
||||||
|
}
|
||||||
|
|
||||||
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
{
|
{
|
||||||
$exception ??= $finding->relationLoaded('findingException')
|
$exception ??= $finding->relationLoaded('findingException')
|
||||||
@ -135,6 +182,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
if ($finding->isRiskAccepted()) {
|
if ($finding->isRiskAccepted()) {
|
||||||
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
||||||
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
||||||
|
'expiring_exception' => 'The linked exception is still valid, but it is nearing expiry and needs review.',
|
||||||
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
||||||
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||||
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
||||||
@ -147,6 +195,7 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
}
|
}
|
||||||
|
|
||||||
return match ($exceptionStatus) {
|
return match ($exceptionStatus) {
|
||||||
|
FindingException::STATUS_EXPIRING => 'The linked exception is nearing expiry and needs review.',
|
||||||
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
||||||
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||||
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
||||||
@ -154,6 +203,59 @@ public function resolveWarningMessage(Finding $finding, ?FindingException $excep
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolveHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return match ((string) $finding->status) {
|
||||||
|
Finding::STATUS_RESOLVED => $this->resolvedHistoricalContext($finding),
|
||||||
|
Finding::STATUS_CLOSED => $this->closedHistoricalContext($finding),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||||
|
{
|
||||||
|
return match ($this->resolveWorkflowFamily($finding)) {
|
||||||
|
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||||
|
? 'Accepted risk remains visible because current governance is still valid.'
|
||||||
|
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||||
|
'historical' => match ((string) $finding->status) {
|
||||||
|
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||||
|
default => 'This finding is historical workflow context.',
|
||||||
|
},
|
||||||
|
default => 'This finding is still active workflow work and should be reviewed until it is resolved, closed, or formally governed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvePrimaryNextAction(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||||
|
{
|
||||||
|
if ($finding->hasOpenStatus() && $finding->due_at?->isPast() === true) {
|
||||||
|
return 'Review the overdue finding and update ownership or next workflow step.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->resolveWorkflowFamily($finding) === 'accepted_risk') {
|
||||||
|
return match ($this->resolveGovernanceValidity($finding, $exception, $now)) {
|
||||||
|
FindingException::VALIDITY_VALID => 'Keep the exception under review until remediation is possible.',
|
||||||
|
FindingException::VALIDITY_EXPIRING => 'Renew or review the exception before the current governance window lapses.',
|
||||||
|
FindingException::VALIDITY_EXPIRED,
|
||||||
|
FindingException::VALIDITY_REVOKED,
|
||||||
|
FindingException::VALIDITY_REJECTED,
|
||||||
|
FindingException::VALIDITY_MISSING_SUPPORT => 'Restore valid governance or move the finding back into active remediation.',
|
||||||
|
default => 'Review the current governance state before treating this accepted risk as stable.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||||
|
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($finding->assignee_user_id === null || $finding->owner_user_id === null) {
|
||||||
|
return 'Assign an owner and next workflow step so follow-up does not stall.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Review the current workflow state and decide whether to progress, resolve, close, or request governance coverage.';
|
||||||
|
}
|
||||||
|
|
||||||
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||||
{
|
{
|
||||||
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
||||||
@ -227,4 +329,26 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
|||||||
? CarbonImmutable::instance($fallback)
|
? CarbonImmutable::instance($fallback)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
$reason = (string) ($finding->resolved_reason ?? '');
|
||||||
|
|
||||||
|
return match ($reason) {
|
||||||
|
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||||
|
'permission_granted',
|
||||||
|
'permission_removed_from_registry',
|
||||||
|
'role_assignment_removed',
|
||||||
|
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||||
|
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function closedHistoricalContext(Finding $finding): ?string
|
||||||
|
{
|
||||||
|
return match ((string) ($finding->closed_reason ?? '')) {
|
||||||
|
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||||
|
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,8 +18,8 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
FindingException::VALIDITY_EXPIRING => new BadgeSpec('Expiring', 'warning', 'heroicon-o-exclamation-triangle'),
|
||||||
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
FindingException::VALIDITY_EXPIRED => new BadgeSpec('Expired', 'danger', 'heroicon-o-clock'),
|
||||||
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
FindingException::VALIDITY_REVOKED => new BadgeSpec('Revoked', 'danger', 'heroicon-o-no-symbol'),
|
||||||
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'gray', 'heroicon-o-x-circle'),
|
FindingException::VALIDITY_REJECTED => new BadgeSpec('Rejected', 'danger', 'heroicon-o-x-circle'),
|
||||||
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'gray', 'heroicon-o-question-mark-circle'),
|
FindingException::VALIDITY_MISSING_SUPPORT => new BadgeSpec('Missing support', 'danger', 'heroicon-o-question-mark-circle'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ public function spec(mixed $value): BadgeSpec
|
|||||||
Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
Finding::STATUS_TRIAGED => new BadgeSpec('Triaged', 'gray', 'heroicon-m-check-circle'),
|
||||||
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
Finding::STATUS_IN_PROGRESS => new BadgeSpec('In progress', 'info', 'heroicon-m-arrow-path'),
|
||||||
Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'),
|
Finding::STATUS_REOPENED => new BadgeSpec('Reopened', 'danger', 'heroicon-m-arrow-uturn-left'),
|
||||||
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'success', 'heroicon-o-check-circle'),
|
Finding::STATUS_RESOLVED => new BadgeSpec('Resolved', 'gray', 'heroicon-o-check-circle'),
|
||||||
Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
Finding::STATUS_CLOSED => new BadgeSpec('Closed', 'gray', 'heroicon-o-x-circle'),
|
||||||
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
Finding::STATUS_RISK_ACCEPTED => new BadgeSpec('Risk accepted', 'gray', 'heroicon-o-shield-check'),
|
||||||
default => BadgeSpec::unknown(),
|
default => BadgeSpec::unknown(),
|
||||||
|
|||||||
@ -14,6 +14,9 @@ enum BaselineCompareReasonCode: string
|
|||||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||||
case RolloutDisabled = 'rollout_disabled';
|
case RolloutDisabled = 'rollout_disabled';
|
||||||
case NoDriftDetected = 'no_drift_detected';
|
case NoDriftDetected = 'no_drift_detected';
|
||||||
|
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||||
|
case GovernanceExpiring = 'governance_expiring';
|
||||||
|
case GovernanceLapsed = 'governance_lapsed';
|
||||||
|
|
||||||
public function message(): string
|
public function message(): string
|
||||||
{
|
{
|
||||||
@ -23,6 +26,9 @@ public function message(): string
|
|||||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||||
|
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
||||||
|
self::GovernanceExpiring => 'Accepted-risk governance is nearing expiry and needs review.',
|
||||||
|
self::GovernanceLapsed => 'Accepted-risk governance has lapsed and needs follow-up.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +38,10 @@ public function explanationFamily(): ExplanationFamily
|
|||||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete,
|
self::EvidenceCaptureIncomplete,
|
||||||
self::RolloutDisabled => ExplanationFamily::CompletedButLimited,
|
self::RolloutDisabled,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -42,7 +51,10 @@ public function trustworthinessLevel(): TrustworthinessLevel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete => TrustworthinessLevel::LimitedConfidence,
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||||
self::RolloutDisabled,
|
self::RolloutDisabled,
|
||||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||||
};
|
};
|
||||||
@ -53,7 +65,10 @@ public function absencePattern(): ?string
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::NoDriftDetected => 'true_no_result',
|
self::NoDriftDetected => 'true_no_result',
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete => 'suppressed_output',
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::OverdueFindingsRemain,
|
||||||
|
self::GovernanceExpiring,
|
||||||
|
self::GovernanceLapsed => 'suppressed_output',
|
||||||
self::RolloutDisabled => 'blocked_prerequisite',
|
self::RolloutDisabled => 'blocked_prerequisite',
|
||||||
self::NoSubjectsInScope => 'missing_input',
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -80,6 +80,11 @@ private function __construct(
|
|||||||
public readonly ?int $evidenceGapOperationalCount = null,
|
public readonly ?int $evidenceGapOperationalCount = null,
|
||||||
public readonly ?int $evidenceGapTransientCount = null,
|
public readonly ?int $evidenceGapTransientCount = null,
|
||||||
public readonly ?bool $evidenceGapLegacyMode = null,
|
public readonly ?bool $evidenceGapLegacyMode = null,
|
||||||
|
public readonly int $overdueOpenFindingsCount = 0,
|
||||||
|
public readonly int $expiringGovernanceCount = 0,
|
||||||
|
public readonly int $lapsedGovernanceCount = 0,
|
||||||
|
public readonly int $activeNonNewFindingsCount = 0,
|
||||||
|
public readonly int $highSeverityActiveFindingsCount = 0,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function forTenant(?Tenant $tenant): self
|
public static function forTenant(?Tenant $tenant): self
|
||||||
@ -160,6 +165,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
$rbacRoleDefinitionSummary = self::rbacRoleDefinitionSummaryForRun($latestRun);
|
||||||
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
$evidenceGapDetails = self::evidenceGapDetailsForRun($latestRun);
|
||||||
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
$baselineCompareDiagnostics = self::baselineCompareDiagnosticsForRun($latestRun);
|
||||||
|
$findingAttentionCounts = self::findingAttentionCounts($tenant);
|
||||||
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
$evidenceGapSummary = is_array($evidenceGapDetails['summary'] ?? null) ? $evidenceGapDetails['summary'] : [];
|
||||||
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
$evidenceGapStructuralCount = is_numeric($evidenceGapSummary['structural_count'] ?? null)
|
||||||
? (int) $evidenceGapSummary['structural_count']
|
? (int) $evidenceGapSummary['structural_count']
|
||||||
@ -205,6 +211,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,6 +255,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,6 +321,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,6 +361,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,6 +398,11 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
evidenceGapOperationalCount: $evidenceGapOperationalCount,
|
||||||
evidenceGapTransientCount: $evidenceGapTransientCount,
|
evidenceGapTransientCount: $evidenceGapTransientCount,
|
||||||
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
evidenceGapLegacyMode: $evidenceGapLegacyMode,
|
||||||
|
overdueOpenFindingsCount: $findingAttentionCounts['overdue_open_findings_count'],
|
||||||
|
expiringGovernanceCount: $findingAttentionCounts['expiring_governance_count'],
|
||||||
|
lapsedGovernanceCount: $findingAttentionCounts['lapsed_governance_count'],
|
||||||
|
activeNonNewFindingsCount: $findingAttentionCounts['active_non_new_findings_count'],
|
||||||
|
highSeverityActiveFindingsCount: $findingAttentionCounts['high_severity_active_findings_count'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -705,6 +736,79 @@ private static function rbacRoleDefinitionSummaryForRun(?OperationRun $run): ?ar
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* overdue_open_findings_count: int,
|
||||||
|
* expiring_governance_count: int,
|
||||||
|
* lapsed_governance_count: int,
|
||||||
|
* active_non_new_findings_count: int,
|
||||||
|
* high_severity_active_findings_count: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private static function findingAttentionCounts(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$tenantId = (int) $tenant->getKey();
|
||||||
|
|
||||||
|
$overdueOpenFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$expiringGovernanceCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->whereHas('findingException', function ($query): void {
|
||||||
|
$query->where('current_validity_state', \App\Models\FindingException::VALIDITY_EXPIRING);
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$lapsedGovernanceCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('status', Finding::STATUS_RISK_ACCEPTED)
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query
|
||||||
|
->whereDoesntHave('findingException')
|
||||||
|
->orWhereHas('findingException', function ($exceptionQuery): void {
|
||||||
|
$exceptionQuery->whereIn('current_validity_state', [
|
||||||
|
\App\Models\FindingException::VALIDITY_EXPIRED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REVOKED,
|
||||||
|
\App\Models\FindingException::VALIDITY_REJECTED,
|
||||||
|
\App\Models\FindingException::VALIDITY_MISSING_SUPPORT,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$activeNonNewFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', [
|
||||||
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
|
Finding::STATUS_TRIAGED,
|
||||||
|
Finding::STATUS_IN_PROGRESS,
|
||||||
|
Finding::STATUS_REOPENED,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$highSeverityActiveFindingsCount = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereIn('severity', [
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overdue_open_findings_count' => $overdueOpenFindingsCount,
|
||||||
|
'expiring_governance_count' => $expiringGovernanceCount,
|
||||||
|
'lapsed_governance_count' => $lapsedGovernanceCount,
|
||||||
|
'active_non_new_findings_count' => $activeNonNewFindingsCount,
|
||||||
|
'high_severity_active_findings_count' => $highSeverityActiveFindingsCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function operatorExplanation(): OperatorExplanationPattern
|
public function operatorExplanation(): OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
/** @var BaselineCompareExplanationRegistry $registry */
|
/** @var BaselineCompareExplanationRegistry $registry */
|
||||||
|
|||||||
@ -57,6 +57,9 @@ public function __construct(
|
|||||||
public array $nextAction,
|
public array $nextAction,
|
||||||
public ?string $lastComparedLabel = null,
|
public ?string $lastComparedLabel = null,
|
||||||
public ?string $reasonCode = null,
|
public ?string $reasonCode = null,
|
||||||
|
public int $overdueOpenFindingsCount = 0,
|
||||||
|
public int $expiringGovernanceCount = 0,
|
||||||
|
public int $lapsedGovernanceCount = 0,
|
||||||
) {
|
) {
|
||||||
if (! in_array($this->stateFamily, [
|
if (! in_array($this->stateFamily, [
|
||||||
self::STATE_POSITIVE,
|
self::STATE_POSITIVE,
|
||||||
@ -126,7 +129,10 @@ public function nextActionTarget(): string
|
|||||||
* highSeverityCount: int,
|
* highSeverityCount: int,
|
||||||
* nextAction: array{label: string, target: string},
|
* nextAction: array{label: string, target: string},
|
||||||
* lastComparedLabel: ?string,
|
* lastComparedLabel: ?string,
|
||||||
* reasonCode: ?string
|
* reasonCode: ?string,
|
||||||
|
* overdueOpenFindingsCount: int,
|
||||||
|
* expiringGovernanceCount: int,
|
||||||
|
* lapsedGovernanceCount: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@ -145,6 +151,9 @@ public function toArray(): array
|
|||||||
'nextAction' => $this->nextAction,
|
'nextAction' => $this->nextAction,
|
||||||
'lastComparedLabel' => $this->lastComparedLabel,
|
'lastComparedLabel' => $this->lastComparedLabel,
|
||||||
'reasonCode' => $this->reasonCode,
|
'reasonCode' => $this->reasonCode,
|
||||||
|
'overdueOpenFindingsCount' => $this->overdueOpenFindingsCount,
|
||||||
|
'expiringGovernanceCount' => $this->expiringGovernanceCount,
|
||||||
|
'lapsedGovernanceCount' => $this->lapsedGovernanceCount,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,18 +17,60 @@ public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAsses
|
|||||||
$explanation = $stats->operatorExplanation();
|
$explanation = $stats->operatorExplanation();
|
||||||
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
$findingsVisibleCount = (int) ($stats->findingsCount ?? 0);
|
||||||
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
|
$highSeverityCount = (int) ($stats->severityCounts['high'] ?? 0);
|
||||||
|
$overdueOpenFindingsCount = $stats->overdueOpenFindingsCount;
|
||||||
|
$expiringGovernanceCount = $stats->expiringGovernanceCount;
|
||||||
|
$lapsedGovernanceCount = $stats->lapsedGovernanceCount;
|
||||||
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
$reasonCode = is_string($stats->reasonCode) ? BaselineCompareReasonCode::tryFrom($stats->reasonCode) : null;
|
||||||
$evaluationResult = $stats->state === 'failed'
|
$evaluationResult = $stats->state === 'failed'
|
||||||
? 'failed_result'
|
? 'failed_result'
|
||||||
: $explanation->evaluationResult;
|
: $explanation->evaluationResult;
|
||||||
$positiveClaimAllowed = $this->positiveClaimAllowed($stats, $explanation, $reasonCode, $evaluationResult);
|
$positiveClaimAllowed = $this->positiveClaimAllowed(
|
||||||
|
$stats,
|
||||||
|
$explanation,
|
||||||
|
$reasonCode,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
$isStale = $this->hasStaleResult($stats, $evaluationResult);
|
||||||
$stateFamily = $this->stateFamily($stats, $findingsVisibleCount, $positiveClaimAllowed, $isStale);
|
$stateFamily = $this->stateFamily(
|
||||||
|
$stats,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$positiveClaimAllowed,
|
||||||
|
$isStale,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
$summaryReasonCode = $this->summaryReasonCode(
|
||||||
|
$stats,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
);
|
||||||
|
|
||||||
return new BaselineCompareSummaryAssessment(
|
return new BaselineCompareSummaryAssessment(
|
||||||
stateFamily: $stateFamily,
|
stateFamily: $stateFamily,
|
||||||
headline: $this->headline($stats, $stateFamily, $findingsVisibleCount, $highSeverityCount, $evaluationResult),
|
headline: $this->headline(
|
||||||
supportingMessage: $this->supportingMessage($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$highSeverityCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
|
supportingMessage: $this->supportingMessage(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
tone: $this->tone($stats, $stateFamily),
|
tone: $this->tone($stats, $stateFamily),
|
||||||
positiveClaimAllowed: $positiveClaimAllowed,
|
positiveClaimAllowed: $positiveClaimAllowed,
|
||||||
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
trustworthinessLevel: $explanation->trustworthinessLevel->value,
|
||||||
@ -36,9 +78,20 @@ public function assess(BaselineCompareStats $stats): BaselineCompareSummaryAsses
|
|||||||
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
evidenceImpact: $this->evidenceImpact($stats, $evaluationResult, $isStale),
|
||||||
findingsVisibleCount: $findingsVisibleCount,
|
findingsVisibleCount: $findingsVisibleCount,
|
||||||
highSeverityCount: $highSeverityCount,
|
highSeverityCount: $highSeverityCount,
|
||||||
nextAction: $this->nextAction($stats, $stateFamily, $findingsVisibleCount, $evaluationResult),
|
nextAction: $this->nextAction(
|
||||||
|
$stats,
|
||||||
|
$stateFamily,
|
||||||
|
$findingsVisibleCount,
|
||||||
|
$evaluationResult,
|
||||||
|
$overdueOpenFindingsCount,
|
||||||
|
$expiringGovernanceCount,
|
||||||
|
$lapsedGovernanceCount,
|
||||||
|
),
|
||||||
lastComparedLabel: $stats->lastComparedHuman,
|
lastComparedLabel: $stats->lastComparedHuman,
|
||||||
reasonCode: $stats->reasonCode,
|
reasonCode: $summaryReasonCode,
|
||||||
|
overdueOpenFindingsCount: $overdueOpenFindingsCount,
|
||||||
|
expiringGovernanceCount: $expiringGovernanceCount,
|
||||||
|
lapsedGovernanceCount: $lapsedGovernanceCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +100,9 @@ private function positiveClaimAllowed(
|
|||||||
OperatorExplanationPattern $explanation,
|
OperatorExplanationPattern $explanation,
|
||||||
?BaselineCompareReasonCode $reasonCode,
|
?BaselineCompareReasonCode $reasonCode,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): bool {
|
): bool {
|
||||||
if ($stats->state !== 'ready') {
|
if ($stats->state !== 'ready') {
|
||||||
return false;
|
return false;
|
||||||
@ -72,6 +128,10 @@ private function positiveClaimAllowed(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
if ($this->hasStaleResult($stats, $evaluationResult)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -88,11 +148,17 @@ private function stateFamily(
|
|||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
bool $positiveClaimAllowed,
|
bool $positiveClaimAllowed,
|
||||||
bool $isStale,
|
bool $isStale,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): string {
|
): string {
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
$stats->state === 'comparing' => BaselineCompareSummaryAssessment::STATE_IN_PROGRESS,
|
||||||
$stats->state === 'failed',
|
$stats->state === 'failed',
|
||||||
$findingsVisibleCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
$findingsVisibleCount > 0,
|
||||||
|
$overdueOpenFindingsCount > 0,
|
||||||
|
$expiringGovernanceCount > 0,
|
||||||
|
$lapsedGovernanceCount > 0 => BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED,
|
||||||
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
in_array($stats->state, ['no_tenant', 'no_assignment', 'no_snapshot', 'idle'], true) => BaselineCompareSummaryAssessment::STATE_UNAVAILABLE,
|
||||||
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
$isStale => BaselineCompareSummaryAssessment::STATE_STALE,
|
||||||
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
$positiveClaimAllowed => BaselineCompareSummaryAssessment::STATE_POSITIVE,
|
||||||
@ -131,6 +197,9 @@ private function headline(
|
|||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
int $highSeverityCount,
|
int $highSeverityCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): string {
|
): string {
|
||||||
return match ($stateFamily) {
|
return match ($stateFamily) {
|
||||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => 'No confirmed drift in the latest baseline compare.',
|
||||||
@ -143,6 +212,9 @@ private function headline(
|
|||||||
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
BaselineCompareSummaryAssessment::STATE_STALE => 'The latest baseline compare result is stale.',
|
||||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
$stats->state === 'failed' || $evaluationResult === 'failed_result' => 'The latest baseline compare failed before it produced a usable result.',
|
||||||
|
$lapsedGovernanceCount > 0 => sprintf('Accepted-risk governance has lapsed on %d finding%s.', $lapsedGovernanceCount, $lapsedGovernanceCount === 1 ? '' : 's'),
|
||||||
|
$overdueOpenFindingsCount > 0 => sprintf('%d overdue finding%s still need review.', $overdueOpenFindingsCount, $overdueOpenFindingsCount === 1 ? '' : 's'),
|
||||||
|
$expiringGovernanceCount > 0 => sprintf('Accepted-risk governance is nearing expiry on %d finding%s.', $expiringGovernanceCount, $expiringGovernanceCount === 1 ? '' : 's'),
|
||||||
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
|
$highSeverityCount > 0 => sprintf('%d high-severity drift finding%s need review.', $highSeverityCount, $highSeverityCount === 1 ? '' : 's'),
|
||||||
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
default => sprintf('%d open drift finding%s need review.', $findingsVisibleCount, $findingsVisibleCount === 1 ? '' : 's'),
|
||||||
},
|
},
|
||||||
@ -161,6 +233,9 @@ private function supportingMessage(
|
|||||||
string $stateFamily,
|
string $stateFamily,
|
||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): ?string {
|
): ?string {
|
||||||
return match ($stateFamily) {
|
return match ($stateFamily) {
|
||||||
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
BaselineCompareSummaryAssessment::STATE_POSITIVE => $stats->lastComparedHuman !== null
|
||||||
@ -177,6 +252,9 @@ private function supportingMessage(
|
|||||||
: 'Refresh compare before relying on this posture.',
|
: 'Refresh compare before relying on this posture.',
|
||||||
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED => match (true) {
|
||||||
$stats->state === 'failed' => $stats->failureReason,
|
$stats->state === 'failed' => $stats->failureReason,
|
||||||
|
$lapsedGovernanceCount > 0 => 'Restore valid governance or move those findings back into active remediation before relying on accepted risk.',
|
||||||
|
$overdueOpenFindingsCount > 0 => 'Overdue workflow items remain even if the latest compare did not introduce new drift findings.',
|
||||||
|
$expiringGovernanceCount > 0 => 'Current governance is still valid, but review or renewal is due soon.',
|
||||||
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
$findingsVisibleCount > 0 => 'Open findings remain on this tenant and need review.',
|
||||||
default => $stats->message,
|
default => $stats->message,
|
||||||
},
|
},
|
||||||
@ -204,8 +282,11 @@ private function nextAction(
|
|||||||
string $stateFamily,
|
string $stateFamily,
|
||||||
int $findingsVisibleCount,
|
int $findingsVisibleCount,
|
||||||
string $evaluationResult,
|
string $evaluationResult,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
): array {
|
): array {
|
||||||
if ($findingsVisibleCount > 0) {
|
if ($findingsVisibleCount > 0 || $overdueOpenFindingsCount > 0 || $expiringGovernanceCount > 0 || $lapsedGovernanceCount > 0) {
|
||||||
return [
|
return [
|
||||||
'label' => 'Open findings',
|
'label' => 'Open findings',
|
||||||
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
'target' => BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS,
|
||||||
@ -260,6 +341,27 @@ private function nextAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function summaryReasonCode(
|
||||||
|
BaselineCompareStats $stats,
|
||||||
|
int $overdueOpenFindingsCount,
|
||||||
|
int $expiringGovernanceCount,
|
||||||
|
int $lapsedGovernanceCount,
|
||||||
|
): ?string {
|
||||||
|
if ($lapsedGovernanceCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::GovernanceLapsed->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($overdueOpenFindingsCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::OverdueFindingsRemain->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($expiringGovernanceCount > 0) {
|
||||||
|
return BaselineCompareReasonCode::GovernanceExpiring->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stats->reasonCode;
|
||||||
|
}
|
||||||
|
|
||||||
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
private function hasStaleResult(BaselineCompareStats $stats, string $evaluationResult): bool
|
||||||
{
|
{
|
||||||
if ($stats->state !== 'ready') {
|
if ($stats->state !== 'ready') {
|
||||||
|
|||||||
@ -130,6 +130,31 @@ public static function findingStatuses(bool $includeLegacyAcknowledged = true):
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingWorkflowFamilies(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'active' => 'Active workflow',
|
||||||
|
'accepted_risk' => 'Accepted risk',
|
||||||
|
'historical' => 'Historical',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingSeverities(): array
|
||||||
|
{
|
||||||
|
return self::badgeOptions(BadgeDomain::FindingSeverity, [
|
||||||
|
Finding::SEVERITY_LOW,
|
||||||
|
Finding::SEVERITY_MEDIUM,
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@ -161,6 +186,17 @@ public static function findingExceptionValidityStates(): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function findingGovernanceAttentionStates(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'healthy' => 'Healthy governance',
|
||||||
|
'attention_needed' => 'Governance attention needed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param iterable<mixed>|null $types
|
* @param iterable<mixed>|null $types
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
|
|||||||
@ -120,7 +120,7 @@ ### Missing (no code, no spec beyond brainstorming)
|
|||||||
|
|
||||||
## Architecture & Principles (Non-Negotiables)
|
## Architecture & Principles (Non-Negotiables)
|
||||||
|
|
||||||
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.13.0)
|
Source: [.specify/memory/constitution.md](.specify/memory/constitution.md) (v1.14.0)
|
||||||
|
|
||||||
### Core Principles
|
### Core Principles
|
||||||
|
|
||||||
@ -132,6 +132,8 @@ ### Core Principles
|
|||||||
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
6. **Tenant Isolation** — Every read/write must be tenant-scoped; `DerivesWorkspaceIdFromTenant` concern auto-fills `workspace_id` from tenant.
|
||||||
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
7. **Operator Surface Principles** — `/admin` defaults are operator-first, diagnostics are progressively disclosed, status dimensions stay distinct, mutation scope is explicit before execution, and every materially changed operator page carries an explicit page contract.
|
||||||
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
|
8. **Filament-native first / no ad-hoc styling** — Admin and operator UI must use Filament-native components or shared primitives before any local Blade/Tailwind assembly; page-local status styling is not an acceptable substitute.
|
||||||
|
9. **Proportionality first** — New structure, layers, persistence, and semantic machinery must be justified by current release truth, current operator workflow, and why a narrower solution is insufficient.
|
||||||
|
10. **Anti-bloat guardrails** — No premature abstraction, no new persisted truth without independent source-of-truth need, no new domain state without behavioral consequence, and specs that add structural complexity must carry an explicit proportionality review.
|
||||||
|
|
||||||
### RBAC-UX Rules
|
### RBAC-UX Rules
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Principles
|
|||||||
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
|
||||||
> New specs must align with these. If a principle needs to change, update this file first.
|
> New specs must align with these. If a principle needs to change, update this file first.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-26
|
**Last reviewed**: 2026-03-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -60,6 +60,26 @@ ### Enterprise-grade auditability
|
|||||||
|
|
||||||
## Data & Architecture
|
## Data & Architecture
|
||||||
|
|
||||||
|
### Proportionality first
|
||||||
|
New structure, layers, persistence, and semantic machinery must be justified by current release truth and current operator workflow.
|
||||||
|
If a narrower implementation can solve the current problem safely, it wins.
|
||||||
|
|
||||||
|
### No premature abstraction
|
||||||
|
No new registries, resolvers, strategy systems, orchestration layers, interfaces, or extension frameworks before at least two real concrete cases exist.
|
||||||
|
Exceptions are allowed only when security, isolation, auditability, compliance evidence, or queue correctness require them now.
|
||||||
|
|
||||||
|
### Persist only real truth
|
||||||
|
New tables or stored artifacts exist only for independent truth, lifecycle, audit, retention, compliance, routing, or durable operator workflow needs.
|
||||||
|
Convenience projections, UI helpers, and speculative artifacts stay derived.
|
||||||
|
|
||||||
|
### New state requires new behavior
|
||||||
|
Statuses, reason codes, and lifecycle labels are domain truth only when they change operator action, routing, permissioning, lifecycle, retention, audit, or retry behavior.
|
||||||
|
Otherwise they remain derived presentation.
|
||||||
|
|
||||||
|
### One truth, few layers
|
||||||
|
Avoid re-modeling the same domain truth across models, result DTOs, presenters, summaries, wrappers, and persisted mirrors.
|
||||||
|
New layers should replace old ones or prove why the old ones cannot serve.
|
||||||
|
|
||||||
### Inventory-first, Snapshots-second
|
### Inventory-first, Snapshots-second
|
||||||
- `InventoryItem` = last observed metadata
|
- `InventoryItem` = last observed metadata
|
||||||
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
|
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
|
||||||
@ -98,6 +118,10 @@ ### Filament-native first, no ad-hoc styling
|
|||||||
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
|
No hand-built status chips, alert cards, or local semantic color/border styling when Filament or a central primitive already expresses the meaning.
|
||||||
Any exception must be justified explicitly and stay minimal.
|
Any exception must be justified explicitly and stay minimal.
|
||||||
|
|
||||||
|
### UI semantics stay lightweight
|
||||||
|
Badges, explanation text, trust/confidence labels, and status summaries are presentation helpers until they prove they are durable product contracts.
|
||||||
|
Avoid building interpretive stacks that require multiple semantic wrapper layers before one domain truth can be shown.
|
||||||
|
|
||||||
### Canonical navigation and terminology
|
### Canonical navigation and terminology
|
||||||
Consistent naming, consistent routing, consistent mental model.
|
Consistent naming, consistent routing, consistent mental model.
|
||||||
No competing terms for the same concept.
|
No competing terms for the same concept.
|
||||||
@ -122,6 +146,9 @@ ### Spec-first workflow
|
|||||||
Runtime behavior changes require spec update first.
|
Runtime behavior changes require spec update first.
|
||||||
Every spec must declare: scope, primary routes, data ownership, RBAC requirements (SCOPE-002).
|
Every spec must declare: scope, primary routes, data ownership, RBAC requirements (SCOPE-002).
|
||||||
|
|
||||||
|
### Mandatory proportionality review for structural additions
|
||||||
|
Any spec that adds a new enum/status family, DTO/presenter layer, persisted entity, interface/registry/resolver, or taxonomy must explain the operator problem, why existing structure is insufficient, why the implementation is the narrowest correct one, its ownership cost, the rejected simpler alternative, and whether it serves current-release truth.
|
||||||
|
|
||||||
### Regression guards mandatory
|
### Regression guards mandatory
|
||||||
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
|
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
|
||||||
Architectural guard tests enforce code-level contracts.
|
Architectural guard tests enforce code-level contracts.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> Guard tests enforce critical constraints automatically.
|
||||||
|
|
||||||
**Last reviewed**: 2026-03-26
|
**Last reviewed**: 2026-03-27
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ ## Standards Index
|
|||||||
|
|
||||||
## How Standards Are Enforced
|
## How Standards Are Enforced
|
||||||
|
|
||||||
1. **Constitution** — Principles in `.specify/memory/constitution.md` govern why we build this way.
|
1. **Constitution** — Principles in `.specify/memory/constitution.md` govern why we build this way and whether new persistence, abstractions, or semantic frameworks are justified at all.
|
||||||
2. **Standards** (this directory) — Concrete rules for how every surface must behave.
|
2. **Standards** (this directory) — Concrete rules for how every surface must behave.
|
||||||
3. **Guard tests** — Automated Pest tests that fail CI when critical standards are violated.
|
3. **Guard tests** — Automated Pest tests that fail CI when critical standards are violated.
|
||||||
4. **PR review** — The [review checklist](list-surface-review-checklist.md) is checked for every spec or PR that touches a list surface.
|
4. **PR review** — The [review checklist](list-surface-review-checklist.md) is checked for every spec or PR that touches a list surface.
|
||||||
@ -42,7 +42,7 @@ ## Related Docs
|
|||||||
|
|
||||||
| Document | Location | Purpose |
|
| Document | Location | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Constitution | `.specify/memory/constitution.md` | Permanent principles (OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
|
||||||
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
| Product Principles | `docs/product/principles.md` | High-level product decisions |
|
||||||
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
|
||||||
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
Review pending exception requests across entitled tenants without leaving the Monitoring area.
|
Review pending requests, expiring governance, and lapsed exception coverage across entitled tenants without leaving the Monitoring area.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@ -33,6 +33,17 @@
|
|||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
{{ \App\Support\Badges\BadgeRenderer::label(\App\Support\Badges\BadgeDomain::FindingRiskGovernanceValidity)($selectedException->current_validity_state) }}
|
||||||
</div>
|
</div>
|
||||||
|
@php
|
||||||
|
$governanceWarning = app(\App\Services\Findings\FindingRiskGovernanceResolver::class)->resolveWarningMessage($selectedException->finding, $selectedException);
|
||||||
|
$governanceWarningColor = (string) $selectedException->current_validity_state === \App\Models\FindingException::VALIDITY_EXPIRING
|
||||||
|
? 'text-warning-700 dark:text-warning-300'
|
||||||
|
: 'text-danger-700 dark:text-danger-300';
|
||||||
|
@endphp
|
||||||
|
@if (filled($governanceWarning))
|
||||||
|
<div class="mt-3 text-sm {{ $governanceWarningColor }}">
|
||||||
|
{{ $governanceWarning }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
|
|||||||
@ -7,9 +7,6 @@
|
|||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div
|
<div
|
||||||
x-data
|
|
||||||
x-init="$wire.set('opsUxIsTabHidden', document.hidden)"
|
|
||||||
x-on:visibilitychange.window="$wire.set('opsUxIsTabHidden', document.hidden)"
|
|
||||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||||
>
|
>
|
||||||
@if ($contextBanner !== null)
|
@if ($contextBanner !== null)
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
use App\Http\Controllers\AdminConsentCallbackController;
|
use App\Http\Controllers\AdminConsentCallbackController;
|
||||||
use App\Http\Controllers\Auth\EntraController;
|
use App\Http\Controllers\Auth\EntraController;
|
||||||
use App\Http\Controllers\ClearTenantContextController;
|
use App\Http\Controllers\ClearTenantContextController;
|
||||||
|
use App\Http\Controllers\OpenFindingExceptionsQueueController;
|
||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Http\Controllers\ReviewPackDownloadController;
|
use App\Http\Controllers\ReviewPackDownloadController;
|
||||||
use App\Http\Controllers\SelectTenantController;
|
use App\Http\Controllers\SelectTenantController;
|
||||||
@ -67,6 +68,10 @@
|
|||||||
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
->post('/admin/switch-workspace', SwitchWorkspaceController::class)
|
||||||
->name('admin.switch-workspace');
|
->name('admin.switch-workspace');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
|
||||||
|
->get('/admin/finding-exceptions/open-queue/{tenant}', OpenFindingExceptionsQueueController::class)
|
||||||
|
->name('admin.finding-exceptions.open-queue');
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
Route::middleware(['web', 'auth', 'ensure-correct-guard:web', 'ensure-workspace-selected'])
|
||||||
->post('/admin/select-tenant', SelectTenantController::class)
|
->post('/admin/select-tenant', SelectTenantController::class)
|
||||||
->name('admin.select-tenant');
|
->name('admin.select-tenant');
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-27
|
||||||
|
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No code-level implementation mechanics (new classes, schema, jobs, or algorithms) are prescribed
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for product, design, and implementation stakeholders in repo-native spec language
|
||||||
|
- [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 code-level implementation mechanics leak into the specification beyond required route, RBAC, operator-surface contract detail, and UI Action Matrix location references
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass 1 completed against the finished spec.
|
||||||
|
- No open clarification markers remain.
|
||||||
|
- Proportionality review completed: the spec explicitly records that it adds no new source of truth, persistence, abstraction, state family, or cross-domain taxonomy.
|
||||||
|
- The spec intentionally references existing route surfaces and shared semantic primitives because this repo's spec template requires operator-surface and constitution alignment details; it does not prescribe implementation mechanics.
|
||||||
@ -0,0 +1,329 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Finding Governance Health Internal Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Internal admin-plane contract for hardened finding governance and resolution semantics.
|
||||||
|
These endpoints represent the server-side data contract backing Filament and Livewire surfaces,
|
||||||
|
not a new public API.
|
||||||
|
servers:
|
||||||
|
- url: /api/internal
|
||||||
|
paths:
|
||||||
|
/tenants/{tenantId}/findings:
|
||||||
|
get:
|
||||||
|
summary: List findings with workflow, governance, and urgency semantics
|
||||||
|
operationId: listTenantFindingsWithGovernanceHealth
|
||||||
|
tags: [Findings]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged, triaged, in_progress, reopened, resolved, closed, risk_accepted]
|
||||||
|
- name: governanceValidity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
- name: overdue
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Findings list rows with operator-facing lifecycle and governance semantics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingListItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/findings/{findingId}:
|
||||||
|
get:
|
||||||
|
summary: View finding detail with leading status and governance zone
|
||||||
|
operationId: showFindingWithGovernanceHealth
|
||||||
|
tags: [Findings]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- $ref: '#/components/parameters/FindingId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Finding detail payload with primary operator summary and secondary diagnostics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FindingDetail'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/finding-exceptions:
|
||||||
|
get:
|
||||||
|
summary: List tenant exception records aligned with finding governance semantics
|
||||||
|
operationId: listTenantFindingExceptionsForGovernanceHealth
|
||||||
|
tags: [Finding Exceptions]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
- name: validity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Tenant exception register rows with governance validity and warnings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingExceptionSurfaceItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/workspaces/{workspaceId}/finding-exceptions/queue:
|
||||||
|
get:
|
||||||
|
summary: Canonical exception queue with tenant-safe governance attention signals
|
||||||
|
operationId: listCanonicalFindingExceptionQueueForGovernanceHealth
|
||||||
|
tags: [Finding Exceptions]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/WorkspaceId'
|
||||||
|
- name: tenantId
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: validity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Canonical queue rows filtered to entitled tenants only
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/FindingExceptionSurfaceItem'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/dashboard/needs-attention:
|
||||||
|
get:
|
||||||
|
summary: Tenant dashboard needs-attention summary including overdue and governance attention
|
||||||
|
operationId: showTenantNeedsAttentionSummary
|
||||||
|
tags: [Dashboard]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Read-only summary items for tenant dashboard attention state
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NeedsAttentionSummary'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
/tenants/{tenantId}/baseline-compare/summary:
|
||||||
|
get:
|
||||||
|
summary: Baseline compare summary with honest findings and governance attention cues
|
||||||
|
operationId: showBaselineCompareSummaryWithFindingGovernanceContext
|
||||||
|
tags: [Baseline Compare]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Baseline compare summary payload with findings and governance attention context
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BaselineCompareFindingSummary'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
TenantId:
|
||||||
|
name: tenantId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
FindingId:
|
||||||
|
name: findingId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
WorkspaceId:
|
||||||
|
name: workspaceId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
Forbidden:
|
||||||
|
description: Member lacks required capability in the current scope
|
||||||
|
NotFound:
|
||||||
|
description: Workspace or tenant scope is not entitled
|
||||||
|
schemas:
|
||||||
|
FindingListItem:
|
||||||
|
type: object
|
||||||
|
required: [id, workflowStatus, workflowFamily, severity, dueAttention, governance]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
workflowStatus:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged, triaged, in_progress, reopened, resolved, closed, risk_accepted]
|
||||||
|
workflowFamily:
|
||||||
|
type: string
|
||||||
|
enum: [active, accepted_risk, historical]
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
enum: [low, medium, high, critical]
|
||||||
|
dueAttention:
|
||||||
|
type: string
|
||||||
|
enum: [none, due_soon, overdue]
|
||||||
|
ownerName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
assigneeName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governance:
|
||||||
|
$ref: '#/components/schemas/GovernanceSignal'
|
||||||
|
FindingDetail:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/FindingListItem'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
primaryNextAction:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governanceWarning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
resolutionContext:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
diagnostics:
|
||||||
|
type: object
|
||||||
|
description: Secondary diagnostic context such as run ids, fingerprints, and evidence sections
|
||||||
|
FindingExceptionSurfaceItem:
|
||||||
|
type: object
|
||||||
|
required: [id, status, validityState, findingId]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
findingId:
|
||||||
|
type: integer
|
||||||
|
tenantId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
validityState:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
governanceWarning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
reviewDueAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
expiresAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
GovernanceSignal:
|
||||||
|
type: object
|
||||||
|
required: [attentionState]
|
||||||
|
properties:
|
||||||
|
validityState:
|
||||||
|
type: string
|
||||||
|
enum: [valid, expiring, expired, revoked, rejected, missing_support]
|
||||||
|
nullable: true
|
||||||
|
exceptionStatus:
|
||||||
|
type: string
|
||||||
|
enum: [pending, active, expiring, expired, rejected, revoked, superseded]
|
||||||
|
nullable: true
|
||||||
|
attentionState:
|
||||||
|
type: string
|
||||||
|
enum: [healthy, attention_needed, not_applicable]
|
||||||
|
warning:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
NeedsAttentionSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [title, body, badge]
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
badge:
|
||||||
|
type: string
|
||||||
|
badgeColor:
|
||||||
|
type: string
|
||||||
|
nextStep:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
healthyChecks:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
BaselineCompareFindingSummary:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
findingsCount:
|
||||||
|
type: integer
|
||||||
|
summaryHeadline:
|
||||||
|
type: string
|
||||||
|
supportingMessage:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
governanceAttentionPresent:
|
||||||
|
type: boolean
|
||||||
|
overdueFindingsPresent:
|
||||||
|
type: boolean
|
||||||
|
nextActionLabel:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
149
specs/166-finding-governance-health/data-model.md
Normal file
149
specs/166-finding-governance-health/data-model.md
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# Data Model: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## 1. Finding
|
||||||
|
|
||||||
|
- **Purpose**: Tenant-owned issue record that carries workflow lifecycle, severity, due state, ownership, and evidence-backed diagnostic context.
|
||||||
|
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` already exist).
|
||||||
|
- **Relevant existing fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_type`
|
||||||
|
- `status`: `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, `risk_accepted`
|
||||||
|
- `severity`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `assignee_user_id`
|
||||||
|
- `due_at`
|
||||||
|
- `sla_days`
|
||||||
|
- `resolved_at`, `resolved_reason`
|
||||||
|
- `closed_at`, `closed_reason`, `closed_by_user_id`
|
||||||
|
- `reopened_at`, `triaged_at`, `in_progress_at`
|
||||||
|
- `current_operation_run_id`, `baseline_operation_run_id`
|
||||||
|
- `evidence_jsonb`
|
||||||
|
- **Relationships**:
|
||||||
|
- belongs to `Tenant`
|
||||||
|
- has one current `FindingException`
|
||||||
|
- belongs to owner, assignee, acknowledged-by, and closed-by users
|
||||||
|
- **Existing invariants preserved**:
|
||||||
|
- Workflow status remains the source of lifecycle truth.
|
||||||
|
- `risk_accepted` remains a finding workflow state, not proof of healthy governance by itself.
|
||||||
|
- Open-status and terminal-status helper behavior does not change in this slice.
|
||||||
|
|
||||||
|
## 2. FindingException
|
||||||
|
|
||||||
|
- **Purpose**: Tenant-owned governance record that determines whether accepted risk is currently backed by valid, expiring, expired, revoked, rejected, or missing-support governance.
|
||||||
|
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` already exist).
|
||||||
|
- **Relevant existing fields**:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `finding_id`
|
||||||
|
- `status`: `pending`, `active`, `expiring`, `expired`, `rejected`, `revoked`, `superseded`
|
||||||
|
- `current_validity_state`: `valid`, `expiring`, `expired`, `revoked`, `rejected`, `missing_support`
|
||||||
|
- `requested_by_user_id`
|
||||||
|
- `owner_user_id`
|
||||||
|
- `approved_by_user_id`
|
||||||
|
- `request_reason`, `approval_reason`, `rejection_reason`, `revocation_reason`
|
||||||
|
- `requested_at`, `approved_at`, `rejected_at`, `revoked_at`
|
||||||
|
- `effective_from`, `review_due_at`, `expires_at`
|
||||||
|
- `current_decision_id`
|
||||||
|
- `evidence_summary`
|
||||||
|
- **Relationships**:
|
||||||
|
- belongs to `Finding`
|
||||||
|
- belongs to requester, owner, approver, and current decision
|
||||||
|
- has many decisions and evidence references
|
||||||
|
- **Existing invariants preserved**:
|
||||||
|
- Governance validity remains derived from exception state and timing.
|
||||||
|
- The exception remains the authoritative source for healthy versus lapsed governance.
|
||||||
|
|
||||||
|
## 3. Derived Surface Projection: Finding Governance Surface State
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted operator-facing projection used by findings list, finding detail, exception surfaces, and summary widgets.
|
||||||
|
- **Derived from**:
|
||||||
|
- `Finding.status`
|
||||||
|
- `Finding.severity`
|
||||||
|
- `Finding.owner_user_id`, `Finding.assignee_user_id`, `Finding.due_at`
|
||||||
|
- linked `FindingException`
|
||||||
|
- `FindingRiskGovernanceResolver::resolveFindingState()`
|
||||||
|
- `FindingRiskGovernanceResolver::resolveWarningMessage()`
|
||||||
|
- **Proposed derived fields**:
|
||||||
|
- `workflow_status`: current finding status value
|
||||||
|
- `workflow_family`: `active`, `accepted_risk`, or `historical`
|
||||||
|
- `governance_validity`: `valid`, `expiring`, `expired`, `revoked`, `rejected`, `missing_support`, or `null`
|
||||||
|
- `governance_attention`: `healthy`, `attention_needed`, or `not_applicable`
|
||||||
|
- `governance_warning`: nullable operator-readable warning message
|
||||||
|
- `due_attention`: `overdue`, `due_soon`, `none`
|
||||||
|
- `ownership_attention`: `assigned`, `owner_missing`, `assignee_missing`, or `both_missing` where relevant
|
||||||
|
- `resolution_context`: nullable secondary text such as `no longer observed` when derivable
|
||||||
|
- `primary_next_action`: derived operator guidance such as inspect exception, renew governance, review overdue finding, or review historical closure
|
||||||
|
- **Invariant**:
|
||||||
|
- This projection is derived only. It must not become a new stored truth or a replacement status enum.
|
||||||
|
|
||||||
|
## 4. Derived Surface Projection: Finding Detail Status Zone
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted grouping for the leading zone on finding detail.
|
||||||
|
- **Derived from**:
|
||||||
|
- the Finding Governance Surface State
|
||||||
|
- existing finding severity and related owner or assignee relationships
|
||||||
|
- existing exception owner or approver details when relevant
|
||||||
|
- **Required visible fields**:
|
||||||
|
- lifecycle status
|
||||||
|
- severity or priority
|
||||||
|
- governance validity and warning when applicable
|
||||||
|
- owner and assignee context
|
||||||
|
- due or SLA urgency
|
||||||
|
- next-step guidance
|
||||||
|
- **Invariant**:
|
||||||
|
- This zone reorganizes existing truth only; it does not add a new domain layer.
|
||||||
|
|
||||||
|
## 5. Derived Surface Projection: Tenant Governance Attention Summary
|
||||||
|
|
||||||
|
- **Purpose**: Non-persisted aggregate for dashboard and baseline-compare summary surfaces.
|
||||||
|
- **Derived from**:
|
||||||
|
- findings with open or terminal statuses
|
||||||
|
- overdue findings in open workflow states
|
||||||
|
- accepted-risk findings whose governance projection resolves to expiring, expired, revoked, rejected, or missing-support states
|
||||||
|
- existing compare summary state from `BaselineCompareStats`
|
||||||
|
- **Proposed aggregate values**:
|
||||||
|
- `overdue_open_findings_count`
|
||||||
|
- `expiring_governance_count`
|
||||||
|
- `lapsed_governance_count`
|
||||||
|
- `active_non_new_findings_count`
|
||||||
|
- `high_severity_active_findings_count`
|
||||||
|
- `healthy_checks` fallback only when none of the above require attention
|
||||||
|
- **Invariant**:
|
||||||
|
- Summary surfaces remain glance-first and DB-only. They surface operator-critical truth without becoming a new reporting system.
|
||||||
|
|
||||||
|
## Surface State Families
|
||||||
|
|
||||||
|
### Finding workflow families
|
||||||
|
|
||||||
|
- `active`: `new`, `acknowledged`, `triaged`, `in_progress`, `reopened`
|
||||||
|
- `accepted_risk`: `risk_accepted`
|
||||||
|
- `historical`: `resolved`, `closed`
|
||||||
|
|
||||||
|
### Governance-health families
|
||||||
|
|
||||||
|
- `healthy`: accepted risk with `valid` governance
|
||||||
|
- `attention_needed`: accepted risk with `expiring`, `expired`, `revoked`, `rejected`, or `missing_support` governance
|
||||||
|
- `not_applicable`: active or historical findings without current governance relevance
|
||||||
|
|
||||||
|
### Urgency families
|
||||||
|
|
||||||
|
- `overdue`: open finding with due date in the past
|
||||||
|
- `due_soon`: open finding approaching due threshold if current UI supports it
|
||||||
|
- `none`: no immediate due urgency signal
|
||||||
|
|
||||||
|
## Relationship Rules
|
||||||
|
|
||||||
|
- A finding can exist without an exception.
|
||||||
|
- A finding in `risk_accepted` status without a valid linked exception must project as governance attention, not as healthy accepted risk.
|
||||||
|
- A finding outside `risk_accepted` may still have historical exception context, but workflow lifecycle remains the primary status dimension.
|
||||||
|
- Exception surfaces and finding surfaces must render the same governance truth for the same finding or exception combination.
|
||||||
|
|
||||||
|
## Behavioral Invariants For This Spec
|
||||||
|
|
||||||
|
- No new persisted entity, table, enum, or status family is introduced.
|
||||||
|
- `resolved` and `closed` remain workflow states and do not gain implicit technical-remediation meaning.
|
||||||
|
- Governance validity remains derived from exception truth, not from finding status alone.
|
||||||
|
- Summary attention must not count only `new` findings when overdue or lapsed-governance conditions already exist.
|
||||||
134
specs/166-finding-governance-health/plan.md
Normal file
134
specs/166-finding-governance-health/plan.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# Implementation Plan: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Branch**: `166-finding-governance-health` | **Date**: 2026-03-27 | **Spec**: [/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/166-finding-governance-health/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden findings, exception, and tenant-summary surfaces so operators can quickly distinguish active findings, accepted risk with healthy governance, accepted risk with governance attention needed, historical resolved or closed workflow states, and overdue work. The implementation stays deliberately narrow: it reuses existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, centralized badge and filter catalogs, and current Filament surfaces rather than adding schema, new status families, or a presenter framework.
|
||||||
|
|
||||||
|
The work is primarily surface hardening across the tenant findings list, finding detail, tenant exception register, canonical exception queue, tenant dashboard attention widget, and baseline-compare summary surfaces. The design keeps workflow lifecycle and governance validity as separate visible dimensions, promotes due and ownership urgency ahead of diagnostics, and adds cross-surface tests that enforce semantic consistency.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `Finding`, `FindingException`, `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, and tenant dashboard widgets
|
||||||
|
**Storage**: PostgreSQL using existing `findings`, `finding_exceptions`, related decision tables, and existing DB-backed summary sources; no schema changes required
|
||||||
|
**Testing**: Pest feature tests, Pest unit tests, and Livewire or Filament component tests
|
||||||
|
**Target Platform**: Laravel Sail web application on PostgreSQL
|
||||||
|
**Project Type**: Web application monolith
|
||||||
|
**Performance Goals**: Findings list, exception list, dashboard widgets, and baseline-compare summary remain DB-only at render time; added governance cues must preserve scan-first list behavior and avoid disproportionate query cost; no new background processing is introduced
|
||||||
|
**Constraints**: No new schema or enum; no new Microsoft Graph calls; no new `OperationRun`; no competing governance truth; destructive actions keep existing confirmation and audit behavior; tenant and workspace isolation semantics must remain unchanged; avoid new presenter or semantic-framework layers
|
||||||
|
**Scale/Scope**: One tenant findings resource, one finding detail, one tenant exception register, one canonical exception queue, one tenant attention widget, and one baseline-compare summary surface, plus shared badge, filter, and resolver semantics with focused regression coverage
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- **Pre-Phase-0 Gate: PASS**
|
||||||
|
- Inventory-first: PASS. The feature changes how existing findings and exception truth is surfaced; it does not redefine inventory, snapshots, or backup truth.
|
||||||
|
- Read/write separation: PASS. Existing writes stay unchanged and confirmation-gated where already required; this slice is mostly read-surface hardening.
|
||||||
|
- Graph contract path: PASS. No Graph calls are introduced.
|
||||||
|
- Deterministic capabilities: PASS. Existing capability registry and RBAC helpers remain the sole authorization source.
|
||||||
|
- RBAC-UX / workspace / tenant isolation: PASS. Tenant findings and exception surfaces remain tenant-scoped, canonical queue remains entitlement-filtered, non-members remain 404, and in-scope capability denials remain 403.
|
||||||
|
- Global search: PASS. This slice does not expand global-search exposure.
|
||||||
|
- Run observability: PASS by non-applicability. No new long-running or remote work is introduced.
|
||||||
|
- Ops-UX 3-surface feedback: PASS by non-applicability. No new `OperationRun` flow is added.
|
||||||
|
- Data minimization: PASS. Surface hardening reuses current DB-backed truth and does not add new stored payloads.
|
||||||
|
- Proportionality / no premature abstraction / persisted truth / state / UI semantics / few layers: PASS. The feature explicitly avoids new persistence, new state families, and a new semantic framework. The narrow implementation is to tighten existing list, detail, queue, and summary mappings.
|
||||||
|
- BADGE-001: PASS. The plan reuses existing centralized badge domains for finding status, severity, exception status, and governance validity.
|
||||||
|
- UI-FIL-001: PASS. Native Filament resources, infolists, tables, widgets, and existing shared primitives remain the implementation path.
|
||||||
|
- UI-NAMING-001: PASS. The work sharpens existing operator language instead of inventing new labels.
|
||||||
|
- OPSURF-001: PASS. The plan explicitly separates workflow lifecycle, governance validity, and due urgency, and keeps diagnostics secondary.
|
||||||
|
- Filament UI Action Surface Contract: PASS. Existing inspection affordances and action groups remain; the main change is semantic emphasis, not new mutation inventory.
|
||||||
|
- Filament UI UX-001: PASS. The design promotes a clearer leading status zone on view pages, retains searchable and filterable tables, and keeps summary widgets scan-first.
|
||||||
|
|
||||||
|
**Post-Phase-1 Re-check: PASS**
|
||||||
|
- The design introduces no new stored truth, no new resolver layer, and no new status family.
|
||||||
|
- All proposed contracts and data models remain derived from existing `Finding`, `FindingException`, centralized badge semantics, and current tenant or workspace queries.
|
||||||
|
- The testing strategy protects business truth across findings list, finding detail, exception surfaces, and summary widgets instead of snapshotting a new presentation framework.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/166-finding-governance-health/
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── Monitoring/
|
||||||
|
│ │ └── TenantDashboard.php
|
||||||
|
│ ├── Resources/
|
||||||
|
│ │ ├── FindingResource.php
|
||||||
|
│ │ ├── FindingResource/
|
||||||
|
│ │ └── FindingExceptionResource/
|
||||||
|
│ └── Widgets/
|
||||||
|
│ ├── Dashboard/
|
||||||
|
│ ├── Tenant/
|
||||||
|
│ └── Workspace/
|
||||||
|
├── Models/
|
||||||
|
├── Services/
|
||||||
|
│ └── Findings/
|
||||||
|
└── Support/
|
||||||
|
├── Badges/
|
||||||
|
└── Filament/
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
└── filament/
|
||||||
|
├── pages/
|
||||||
|
└── widgets/
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Findings/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ ├── Authorization/
|
||||||
|
│ └── Evidence/
|
||||||
|
└── Unit/
|
||||||
|
└── Badges/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the existing Laravel monolith structure. The implementation should stay inside the current findings and exception resources, current dashboard and baseline-compare surfaces, the existing findings governance resolver, and centralized badge or filter helpers. Regression coverage should extend current Findings, Filament, Authorization, Evidence, and Badge test suites rather than creating a new surface framework or test package.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations require justification. The plan intentionally avoids adding persistence, new abstractions, or new state families.
|
||||||
|
|
||||||
|
Implementation confirmation (2026-03-27): delivery stayed within the planned narrow slice. No additional semantic gap was discovered that would require a new resolver layer, persistence field, or extra follow-up spec beyond the already documented resolution-origin candidate.
|
||||||
|
|
||||||
|
## Phase 0 — Research Output
|
||||||
|
|
||||||
|
- [research.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/research.md)
|
||||||
|
|
||||||
|
## Phase 1 — Design Output
|
||||||
|
|
||||||
|
- [data-model.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/data-model.md)
|
||||||
|
- [quickstart.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/quickstart.md)
|
||||||
|
- [contracts/finding-governance-health.openapi.yaml](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/166-finding-governance-health/contracts/finding-governance-health.openapi.yaml)
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Planning
|
||||||
|
|
||||||
|
The implementation task plan covers these execution slices:
|
||||||
|
|
||||||
|
1. Findings list hardening so lifecycle state, governance validity, overdue urgency, and ownership become scan-first without degrading filterability or list density.
|
||||||
|
2. Finding detail restructuring so an operator-first status and governance zone appears above diagnostics, and resolved or closed copy becomes semantically cautious.
|
||||||
|
3. Exception-surface alignment so tenant register and canonical queue carry the same healthy versus attention-needed governance signals as finding surfaces.
|
||||||
|
4. Tenant-summary propagation so `NeedsAttention`, `BaselineCompareNow`, and baseline-compare landing surfaces include overdue and unhealthy governance truth instead of relying only on `new` findings.
|
||||||
|
5. Shared semantics tightening so `FindingRiskGovernanceResolver`, `BadgeCatalog`, `BadgeRenderer`, and `FilterOptionCatalog` remain the sole truth sources for status and governance vocabulary.
|
||||||
|
6. Cross-surface regression coverage spanning findings list, finding detail, exception register, canonical queue, dashboard attention, baseline-compare summary, and badge-domain semantics.
|
||||||
88
specs/166-finding-governance-health/quickstart.md
Normal file
88
specs/166-finding-governance-health/quickstart.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Quickstart: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that findings, exceptions, and tenant summary surfaces clearly distinguish active work, healthy accepted risk, lapsed or expiring governance, overdue work, and historical resolved or closed workflow states.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail and ensure the application database is available.
|
||||||
|
2. Have at least one workspace and one tenant with visible findings in tenant context.
|
||||||
|
3. Use a seeded dataset that includes at minimum:
|
||||||
|
- one active open finding
|
||||||
|
- one overdue open finding
|
||||||
|
- one `risk_accepted` finding with valid governance
|
||||||
|
- one `risk_accepted` finding with expiring governance
|
||||||
|
- one `risk_accepted` finding with expired governance
|
||||||
|
- one `risk_accepted` finding with revoked governance
|
||||||
|
- one `risk_accepted` finding with rejected governance where that state is operator-visible
|
||||||
|
- one `risk_accepted` finding with missing-support governance
|
||||||
|
- one `resolved` finding
|
||||||
|
- one `closed` finding
|
||||||
|
4. Have two user roles available for validation:
|
||||||
|
- tenant operator or manager who can inspect findings and exceptions
|
||||||
|
- readonly tenant member for capability-safe inspection checks
|
||||||
|
- workspace approver with exception-approval scope for canonical queue validation
|
||||||
|
|
||||||
|
## Findings List Validation
|
||||||
|
|
||||||
|
1. Open the tenant findings list.
|
||||||
|
2. Confirm that the active open finding is visibly distinct from the healthy accepted-risk finding.
|
||||||
|
3. Confirm that accepted-risk findings with expiring, expired, revoked, rejected where operator-visible, or missing-support governance are visibly distinct from the healthy accepted-risk finding.
|
||||||
|
4. Confirm that the overdue open finding is visually prioritized relative to non-overdue active work.
|
||||||
|
5. Confirm that the historical resolved or closed finding does not read like a healthy accepted-risk state.
|
||||||
|
6. Confirm that list filters and scanability still work while the added governance cues are present.
|
||||||
|
|
||||||
|
## Finding Detail Validation
|
||||||
|
|
||||||
|
1. Open the active finding detail page.
|
||||||
|
2. Confirm that the first visible zone shows lifecycle state, severity, owner or assignee, due urgency, and next-action context before IDs or raw diagnostics.
|
||||||
|
3. Open the healthy accepted-risk finding detail page.
|
||||||
|
4. Confirm that accepted risk remains visible as accepted risk and includes a healthy governance cue.
|
||||||
|
5. Open the lapsed-governance accepted-risk finding detail page.
|
||||||
|
6. Confirm that the leading zone surfaces the governance warning before fingerprints, run links, raw evidence, or diff sections.
|
||||||
|
7. Open the resolved finding detail page.
|
||||||
|
8. Confirm that the copy reads as workflow or historical state and does not imply technically verified permanent remediation.
|
||||||
|
9. Confirm that any relevant historical governance warning from the exception trail remains visible as secondary context instead of being erased by the resolved state.
|
||||||
|
10. Open the closed finding detail page.
|
||||||
|
11. Confirm that the copy reads as workflow or historical state and does not imply technically verified permanent remediation.
|
||||||
|
12. Confirm that any relevant historical governance warning from the exception trail remains visible as secondary context instead of being erased by the closed state.
|
||||||
|
|
||||||
|
## Exception Surface Parity Validation
|
||||||
|
|
||||||
|
1. Open the tenant risk-exception register.
|
||||||
|
2. Confirm that valid, expiring, expired, revoked, rejected where operator-visible, and missing-support governance states remain visibly distinguishable.
|
||||||
|
3. Navigate from a tenant finding or tenant exception record into the canonical exception queue.
|
||||||
|
4. Confirm that the canonical queue opens prefiltered to the active tenant.
|
||||||
|
5. Confirm that the operator can only broaden filters within their authorized tenant set and cannot use filter changes to infer out-of-scope tenants.
|
||||||
|
6. Open the same records through the canonical exception queue when entitled.
|
||||||
|
7. Confirm that the queue and the tenant register communicate the same governance truth as the linked finding surfaces.
|
||||||
|
|
||||||
|
## Summary Surface Validation
|
||||||
|
|
||||||
|
1. Open the tenant dashboard.
|
||||||
|
2. Confirm that `NeedsAttention` surfaces overdue findings or unhealthy governance even when the tenant has no `new` findings.
|
||||||
|
3. Open the baseline-compare landing page.
|
||||||
|
4. Confirm that the findings summary and no-findings language do not imply an all-clear when overdue findings or unhealthy governance still exist.
|
||||||
|
5. Confirm that healthy fallback copy appears only when no operator-critical finding or governance attention remains.
|
||||||
|
|
||||||
|
## Authorization Checks
|
||||||
|
|
||||||
|
1. Verify that a readonly tenant member can inspect surfaces they are entitled to see but cannot execute disabled mutation actions.
|
||||||
|
2. Verify that a non-member or wrong-tenant user receives deny-as-not-found behavior for tenant findings, tenant exceptions, and tenant summary surfaces.
|
||||||
|
3. Verify that the canonical exception queue remains accessible only to appropriately entitled workspace approvers.
|
||||||
|
|
||||||
|
## Suggested Focused Test Runs
|
||||||
|
|
||||||
|
1. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListDefaultsTest.php`
|
||||||
|
2. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
3. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||||
|
4. `vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingExceptionRegisterTest.php`
|
||||||
|
5. `vendor/bin/sail artisan test --compact tests/Feature/Filament/NeedsAttentionWidgetTest.php`
|
||||||
|
6. `vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
7. `vendor/bin/sail artisan test --compact tests/Unit/Badges/FindingBadgesTest.php`
|
||||||
|
|
||||||
|
## Timed Acceptance Checks
|
||||||
|
|
||||||
|
1. Time SC-166-001 by opening the tenant findings list on a seeded tenant and measuring how long it takes a reviewer to correctly classify healthy accepted risk, lapsed accepted risk, active work, and historical workflow state.
|
||||||
|
2. Time SC-166-004 by opening finding detail and measuring how long it takes the reviewer to identify lifecycle state, governance health, due urgency, owner or assignee, and next action before interacting with lower diagnostic sections.
|
||||||
61
specs/166-finding-governance-health/research.md
Normal file
61
specs/166-finding-governance-health/research.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Research: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
## Decision 1: Reuse existing governance truth instead of introducing a new semantic layer
|
||||||
|
|
||||||
|
**Decision**: Drive surface hardening from existing `Finding.status`, `FindingException.current_validity_state`, `FindingRiskGovernanceResolver`, and centralized badge domains rather than introducing a new persisted governance-health field or a presenter framework.
|
||||||
|
|
||||||
|
**Rationale**: The codebase already distinguishes valid, expiring, expired, revoked, rejected, and missing-support governance states, and it already exposes warning messages through `FindingRiskGovernanceResolver`. The operator problem is primarily under-communication on major surfaces, not missing underlying truth. Reusing the current truth sources satisfies the constitution's proportionality and no-new-layer constraints.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new stored `governance_health` field on findings: rejected because it would duplicate exception truth and require sync logic.
|
||||||
|
- Introduce a new presenter or explanation framework: rejected because this slice needs clearer surfaces, not another interpretation layer.
|
||||||
|
|
||||||
|
## Decision 2: Keep workflow lifecycle and governance validity as separate visible dimensions
|
||||||
|
|
||||||
|
**Decision**: Findings surfaces should show workflow lifecycle and governance validity as distinct operator-visible dimensions instead of collapsing them into a single synthetic super-status.
|
||||||
|
|
||||||
|
**Rationale**: A finding can be `risk_accepted` while governance is healthy, expiring, expired, revoked, or unsupported. A finding can also be `resolved` without proving permanent remediation. Separating lifecycle from governance validity prevents false calm and matches the constitution rule that distinct status dimensions should stay distinct when they matter.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Replace current states with one combined surface enum such as `accepted-risk-warning`: rejected because it would blur workflow truth and governance truth together.
|
||||||
|
- Leave lifecycle badges unchanged and hide governance only in detail: rejected because the list and summary surfaces are where prioritization happens.
|
||||||
|
|
||||||
|
## Decision 3: Harden finding detail by reordering existing infolist truth into a leading status zone
|
||||||
|
|
||||||
|
**Decision**: Implement the detail-page change as a leading status and governance zone built from existing infolist data, leaving raw identifiers, run links, diffs, and evidence in secondary sections.
|
||||||
|
|
||||||
|
**Rationale**: The current finding detail already contains most of the required truth, including status, severity, due date, owner, assignee, and risk-governance entries. The main change needed is information hierarchy. Reordering existing truth is narrower and safer than introducing a new detail-specific read model.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Build a separate read model or DTO for the view page: rejected because the current infolist already has the required fields.
|
||||||
|
- Leave the current section order and add more warnings lower on the page: rejected because it does not solve the 5 to 10 second operator-read requirement.
|
||||||
|
|
||||||
|
## Decision 4: Propagate governance attention to summary surfaces through existing DB-backed widget queries
|
||||||
|
|
||||||
|
**Decision**: Extend current tenant summary surfaces such as `NeedsAttention` and baseline-compare summary outputs with DB-backed aggregates for overdue findings, lapsed governance, and expiring governance instead of adding background jobs or a new tenant-governance dashboard.
|
||||||
|
|
||||||
|
**Rationale**: The dashboard widget and baseline-compare landing already compute DB-backed summaries without external calls. Adding a small set of operator-critical aggregates keeps these surfaces honest while preserving their glance-first character and avoiding a new subsystem.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add scheduled reminder jobs or alerting in this slice: rejected because the spec explicitly leaves proactive automation to a follow-up spec.
|
||||||
|
- Create a dedicated governance-health dashboard: rejected because the current scope is surface hardening, not a new reporting domain.
|
||||||
|
|
||||||
|
## Decision 5: Communicate `resolved` and `closed` cautiously, with optional secondary observation context
|
||||||
|
|
||||||
|
**Decision**: Treat `resolved` and `closed` as historical workflow states in primary UI language, and only show `no longer observed` or similar context as secondary explanatory information when it can be derived from current truth.
|
||||||
|
|
||||||
|
**Rationale**: The spec's central risk is that operators may read `resolved` as technical proof. The safest narrow fix is copy and layout: make workflow meaning explicit and reserve any observation-based explanation for clearly secondary context.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Reinterpret `resolved` as `no longer observed` everywhere: rejected because it rewrites current workflow truth.
|
||||||
|
- Add a new persistence field to separate resolution origin immediately: rejected because the spec explicitly defers that to a follow-up if needed.
|
||||||
|
|
||||||
|
## Decision 6: Anchor testing in existing Livewire, Filament, Findings, and Badge suites
|
||||||
|
|
||||||
|
**Decision**: Extend the current Pest suites for findings list, finding detail, exception register, exception queue, dashboard widgets, baseline-compare summary, evidence integration, and badge semantics rather than creating a new presentation-only test framework.
|
||||||
|
|
||||||
|
**Rationale**: The repository already has strong coverage anchors for findings workflow, governance projection, exception lifecycle, needs-attention widgets, and baseline-compare explanation surfaces. Cross-surface consistency can be enforced most efficiently by extending those suites with operator-truth assertions.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add screenshot-heavy UI snapshot coverage as the main protection: rejected because the business truth is semantic distinction, not pixel fidelity.
|
||||||
|
- Test a new intermediate presenter layer: rejected because the plan intentionally avoids creating one.
|
||||||
254
specs/166-finding-governance-health/spec.md
Normal file
254
specs/166-finding-governance-health/spec.md
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
# Feature Specification: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Feature Branch**: `166-finding-governance-health`
|
||||||
|
**Created**: 2026-03-27
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 166 — Finding Governance Health & Resolution Semantics Surface Hardening"
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant + canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- `/admin/t/{tenant}/findings` as the tenant-context findings queue where operators scan active work, accepted risk, and governance health
|
||||||
|
- `/admin/t/{tenant}/findings/{finding}` as the tenant-context finding detail surface where status, governance health, ownership, due context, and next action must become operator-first
|
||||||
|
- `/admin/t/{tenant}/exceptions` as the tenant-context risk-exception register that must stay semantically aligned with finding-level governance truth
|
||||||
|
- `/admin/finding-exceptions/queue` as the canonical workspace approval and review queue for finding exceptions across entitled tenants
|
||||||
|
- `/admin/t/{tenant}` and `/admin/t/{tenant}/baseline-compare-landing` as existing tenant summary surfaces that currently carry finding-related attention and must become more honest about overdue and governance-risk states
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Tenant-owned: findings, finding workflow metadata, ownership, SLA and due state, linked tenant-scoped exception state, and surface-visible governance warnings derived for one tenant's findings
|
||||||
|
- Workspace-owned but tenant-filtered: canonical approval queue visibility, workspace review counts, and summary surfaces that aggregate finding governance state across entitled tenants without changing tenant ownership of the underlying findings or exceptions
|
||||||
|
- Existing audit history, evidence summaries, review-pack outputs, and compare summaries remain downstream consumers of the same findings and governance truth and are not redefined here
|
||||||
|
- **RBAC**:
|
||||||
|
- Workspace membership remains required for all findings and exception surfaces
|
||||||
|
- Tenant entitlement remains required for tenant-context findings, finding detail, tenant exception register, and tenant dashboard surfaces
|
||||||
|
- Existing finding and finding-exception capabilities remain the enforcement source for inspection and mutation actions
|
||||||
|
- The canonical exception queue remains available only to entitled workspace members with exception-approval scope
|
||||||
|
- Non-members or out-of-scope users remain deny-as-not-found, while in-scope members lacking the relevant capability remain forbidden
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: When an operator reaches the canonical exception queue from a tenant finding or tenant exception register, the canonical queue opens prefiltered to the active tenant. The operator may only broaden filters within their authorized tenant set.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical queue rows, counts, tenant labels, filter options, related finding labels, governance-warning summaries, and drill-down links must only be assembled after workspace membership and tenant entitlement checks. Unauthorized users must not learn whether another tenant has expired governance, pending approvals, or overdue findings.
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list | Tenant operator | List | What needs action right now, and which accepted risks are no longer safe to ignore? | Finding status family, severity, governance health for accepted risk, overdue or due-soon state, owner or assignee, top-level warning context | Fingerprints, raw evidence provenance, run identifiers, deep diff payloads | workflow lifecycle, governance validity, due urgency, ownership completeness | TenantPilot workflow and governance actions for one tenant | View finding, filter, scan workflow state, request or inspect exception | Existing workflow actions such as resolve, close, reopen, and exception decisions remain confirmation-gated where applicable |
|
||||||
|
| Tenant finding detail | Tenant operator | Detail | What is the current state, why does it matter, and what should happen next? | Leading status and governance zone with lifecycle state, severity or priority, governance health, due or SLA, owner or assignee, and next-action guidance | IDs, fingerprints, run links, diff sections, raw evidence JSON, historical low-level metadata | workflow lifecycle, governance validity, due urgency, ownership completeness, secondary resolution context | TenantPilot workflow and governance actions for one tenant | Open related record, inspect exception, request or renew exception, workflow actions | Existing revoke, renew, reject, close, resolve, and similar destructive-like actions remain confirmation-gated and capability-gated |
|
||||||
|
| Tenant risk-exception register | Tenant governance operator | List/detail | Which exceptions are healthy, expiring, expired, revoked, or otherwise problematic for this tenant? | Exception status, validity family, governance warning, owner, approver, review due, expiry timing, linked finding summary | Decision payload detail, evidence-reference payloads, low-level IDs | exception lifecycle, governance validity, due urgency | TenantPilot exception workflow for one tenant | View exception, renew exception, revoke exception | Revoke remains destructive and requires confirmation |
|
||||||
|
| Canonical finding-exceptions queue | Workspace approver or auditor | Queue/detail | Which tenants have pending or unhealthy governance that requires workspace attention? | Pending approvals, expired or expiring governance, selected exception detail, tenant filter context, related finding access | Full decision history payloads and deep evidence metadata | exception lifecycle, governance validity, tenant review scope | TenantPilot governance review across entitled tenants | Approve exception, reject exception, open tenant register, open finding | Approve and reject remain per-record and confirmation-gated |
|
||||||
|
| Tenant dashboard needs-attention widget | Tenant operator | Summary widget | Is there governance or overdue work that needs immediate attention? | Overdue findings, lapsed governance, expiring governance, severe active findings, baseline posture attention | Diagnostic cause chains and low-level evidence gaps beyond the top message | attention state, governance health, due urgency, active issue severity | Read-only summary with drill-down navigation | Open dashboard destinations and related lists | None |
|
||||||
|
| Baseline compare landing finding summary | Tenant operator | Summary panel | Does the tenant currently have actionable finding risk, including governance debt? | Findings count, honest no-findings language, governance-attention cues, route into findings list | Run internals and raw compare payloads | compare summary state, active findings attention, governance attention | Read-only summary with drill-down navigation | View findings | None |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: No. The spec reuses existing findings workflow truth, finding-exception truth, and governance-validity derivation.
|
||||||
|
- **New persisted entity/table/artifact?**: No. This spec explicitly avoids schema additions and new durable artifacts.
|
||||||
|
- **New abstraction?**: No. It should be implemented by tightening existing surface mappings and shared badge/governance presentation, not by introducing a new resolver, presenter stack, or framework.
|
||||||
|
- **New enum/state/reason family?**: No. The work relies on existing workflow and governance families and makes them more visible and less misleading.
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: No. The hardening stays local to findings and exceptions surfaces and must not turn governance semantics into a reusable interpretive framework.
|
||||||
|
- **Current operator problem**: Operators can currently misread accepted-risk findings as safe terminal states when governance has lapsed, and can miss overdue or governance-problematic work on summary surfaces.
|
||||||
|
- **Existing structure is insufficient because**: Current surfaces under-express distinctions that already exist in the underlying truth, especially the difference between healthy accepted risk and lapsed governance, and they can overstate what `resolved` or `closed` means.
|
||||||
|
- **Narrowest correct implementation**: Reorder and harden existing list, detail, queue, and summary surfaces so they expose existing governance and workflow truth directly, using current badge domains and existing data sources rather than adding new persistence or new semantic layers.
|
||||||
|
- **Ownership cost**: The main cost is cross-surface copy, layout, and regression-test maintenance to keep findings, exception, and summary surfaces semantically aligned.
|
||||||
|
- **Alternative intentionally rejected**: Introducing a new governance-health persistence field, new workflow enum, or new presenter/taxonomy framework was rejected because the needed distinctions are already derivable from existing truth and a narrower surface hardening solves the current-release problem.
|
||||||
|
- **Release truth**: Current-release truth. The problem already affects the shipped operator workflow and does not depend on speculative future domains.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Distinguish safe accepted risk from governance drift (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want the findings list and finding detail to tell me immediately whether an accepted risk is still backed by valid governance, so that I do not mistake an expired or revoked exception for a safe terminal state.
|
||||||
|
|
||||||
|
**Why this priority**: The highest product risk is false calm. A `risk_accepted` finding without valid governance is governance debt, not closure.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by comparing findings with valid, expiring, expired, revoked, and missing-support governance states across list and detail surfaces and verifying that each state remains visibly distinct.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is `risk_accepted` and backed by valid exception governance, **When** an authorized operator opens the list or detail surface, **Then** the UI shows accepted risk with a healthy governance signal rather than a warning state.
|
||||||
|
2. **Given** a finding is `risk_accepted` but its exception governance is expired, revoked, or unsupported, **When** an authorized operator opens the list or detail surface, **Then** the UI shows a warning or problem state that is visibly different from healthy accepted risk.
|
||||||
|
3. **Given** two accepted-risk findings differ only in governance validity, **When** the operator scans the findings list, **Then** the list makes that difference visible without requiring a click into deep detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Read resolved and closed states without false reassurance (Priority: P1)
|
||||||
|
|
||||||
|
As an operator reviewing historical findings, I want `resolved` and `closed` to read as workflow outcomes rather than automatic proof of technical remediation, so that I do not overstate the current technical truth.
|
||||||
|
|
||||||
|
**Why this priority**: The second major product risk is semantic overclaim. A workflow state must not imply more certainty than the underlying observation supports.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by rendering resolved and closed findings on list, detail, and summary surfaces and verifying that the UI does not describe them as permanently fixed or technically guaranteed absent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is `resolved`, **When** an authorized operator views it, **Then** the UI frames the state as workflow completion or historical closure and avoids language that implies verified permanent remediation.
|
||||||
|
2. **Given** the system can infer that a finding is no longer observed, **When** an authorized operator views a resolved finding, **Then** that information appears as secondary context rather than replacing the workflow meaning of `resolved`.
|
||||||
|
3. **Given** a dashboard or summary surface references resolved findings, **When** the operator scans the summary, **Then** the summary does not present them as equivalent to healthy governance or absence of risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - See governance and overdue problems in summary surfaces (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant operator or workspace approver, I want dashboard and attention surfaces to surface overdue findings and unhealthy governance, so that critical review work is not hidden just because no finding is currently `new`.
|
||||||
|
|
||||||
|
**Why this priority**: Summary surfaces are where operators decide whether a tenant needs attention. If they hide overdue or lapsed governance states, the product underreports operational risk.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by seeding tenants with overdue findings, expiring governance, and lapsed accepted risk, then verifying those states appear on at least one summary or attention surface without requiring detail-page inspection.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a tenant has no `new` findings but has overdue active findings, **When** the operator opens the dashboard, **Then** a summary surface still marks the tenant as requiring attention.
|
||||||
|
2. **Given** a tenant has accepted-risk findings with lapsed governance, **When** the operator opens summary and attention surfaces, **Then** those surfaces show governance attention rather than a healthy state.
|
||||||
|
3. **Given** a tenant has expiring governance that remains valid for now, **When** the operator scans attention surfaces, **Then** the UI distinguishes it from both healthy governance and already-lapsed governance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Get operator-first detail before diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
As an operator opening a finding, I want the page to start with status, governance, ownership, due state, and next action before raw identifiers and deep diagnostics, so that I can decide what to do within seconds.
|
||||||
|
|
||||||
|
**Why this priority**: Detail surfaces are currently rich in truth but not yet ordered around operator actionability.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by opening a finding detail page and verifying that the first visible zone communicates operator state, ownership, and next-step guidance before raw IDs, evidence payloads, or diff internals.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding is open or in progress, **When** the operator opens detail, **Then** ownership, due context, severity, and next-action guidance appear before IDs and raw evidence.
|
||||||
|
2. **Given** a finding is `risk_accepted` with governance attention needed, **When** the operator opens detail, **Then** the warning appears in the leading zone rather than only in a lower governance section.
|
||||||
|
3. **Given** a finding is historical, **When** the operator opens detail, **Then** the detail still shows the lifecycle meaning clearly without hiding governance or recurrence context behind diagnostics.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding remains in `risk_accepted` status after its governing exception expired, was revoked, or is otherwise unsupported; the UI must not render it as calm or completed.
|
||||||
|
- A finding has no current exception record but its status is `risk_accepted`; the surface must treat this as missing-support governance rather than as a healthy accepted risk.
|
||||||
|
- A tenant has no `new` findings, but has overdue `triaged`, `in_progress`, or `reopened` findings; summary surfaces must still mark the tenant as needing attention.
|
||||||
|
- A finding is `resolved` or `closed` while historical governance warnings still exist in the exception trail; the detail surface must not erase that context.
|
||||||
|
- A workspace approver sees a tenant-filtered canonical queue; queue counts and filters must not reveal other tenants' governance states.
|
||||||
|
- Existing workflow, assign, due, renew, revoke, approve, and reject actions must keep their current capability and confirmation behavior while the surfaces around them become semantically clearer.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new long-running jobs, and no required schema changes. It hardens operator-facing findings and exception surfaces by propagating existing workflow and governance truth more clearly. Existing findings, exceptions, and governance resolver outputs remain the source of truth. The feature must describe how unhealthy governance, overdue work, and historical workflow states become visible across list, detail, queue, and summary surfaces without inventing a competing state model.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature is intentionally narrow. It introduces no new persistence, no new abstraction layer, no new state family, and no new cross-domain taxonomy. The implementation must make existing truth easier to read on operator surfaces and must not solve the problem by adding a new stored governance field, a presenter stack, or a secondary semantic framework.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** No new `OperationRun` type is introduced and the Ops-UX 3-surface feedback contract is unchanged. This feature is about existing list, detail, queue, and dashboard semantics rather than new background operations.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature affects the tenant/admin plane for findings, finding detail, tenant exception register, tenant dashboard, and baseline-compare summary, plus the workspace-admin plane for the canonical finding-exceptions queue. Cross-plane access remains deny-as-not-found. Non-members and out-of-scope users receive `404`. In-scope members lacking the relevant capability continue to receive `403`. Server-side authorization remains mandatory for workflow actions and exception decisions surfaced from these pages. Existing destructive-like actions such as approve, reject, revoke, resolve, close, and similar mutations remain confirmation-gated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Finding status, finding severity, finding-exception status, and finding risk-governance validity remain centralized badge domains. This feature may change where and how often these badge families appear, but must not introduce page-local mappings or duplicate status vocabularies. Tests must cover the newly emphasized healthy, expiring, expired, revoked, rejected, and missing-support governance states where they become operator-visible.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature reuses native Filament sections, infolist entries, badges, notifications, widgets, and existing shared badge primitives for status and governance communication. Local replacement markup for core status language should be avoided. Any special emphasis for governance warnings must still derive from shared badge domains or existing warning primitives rather than page-local color language.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects are findings and finding exceptions. Primary operator-facing terms remain `Findings`, `Risk exceptions`, `Approve exception`, `Reject exception`, `Renew exception`, `Revoke exception`, `Resolve`, `Close`, and `Reopen`. New or revised summary copy must preserve the distinction between `accepted risk`, `governance warning`, `resolved`, `closed`, and `no longer observed` context, and must avoid implementation-first phrases such as `resolver state`, `status enum`, or `unsupported row`.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** This feature materially refactors operator-facing findings, exception, and summary surfaces. Default-visible content must remain operator-first: lifecycle state, governance health, due urgency, ownership, and next action appear before IDs, fingerprints, run references, or raw evidence detail. Diagnostics remain secondary. Status dimensions must stay distinct: workflow lifecycle is not governance validity, and governance validity is not the same as compare posture or technical absence. Existing mutations keep their current scope semantics and confirmation patterns.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** This feature must not introduce an explanation-builder layer, a new governance presenter framework, or duplicate truth across findings, exceptions, summaries, and helper objects. Surfaces should map existing domain truth directly into operator-visible cues. Tests must focus on the business consequence that operators can distinguish healthy accepted risk, lapsed governance, overdue work, and historical workflow states rather than snapshotting thin presentation indirection for its own sake.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** This feature modifies the tenant Findings Resource, tenant Finding Exception Resource, workspace Finding Exceptions Queue, and dashboard-related Filament surfaces. The Action Surface Contract remains satisfied because primary inspection and mutation affordances stay explicit, confirmation-gated where required, and capability-aware. Dashboard and summary widgets remain read-only surfaces with drill-down navigation, not mutation surfaces.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** Findings and exception detail surfaces must remain sectioned, operator-first inspection pages. The finding detail page specifically must promote a leading status and governance zone before diagnostics. Existing tables must remain searchable, sortable, and filterable for core workflow and governance dimensions. Summary surfaces should add attention truth without becoming full diagnostic dashboards.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-166-001**: A finding in `risk_accepted` status MUST never be shown on relevant operator surfaces without an accompanying governance-validity signal.
|
||||||
|
- **FR-166-002**: Governance-validity presentation for accepted risk MUST distinguish at minimum healthy valid governance, expiring governance, expired governance, revoked governance, rejected governance where relevant, and missing or otherwise unsupported governance.
|
||||||
|
- **FR-166-003**: A `risk_accepted` finding with lapsed, revoked, rejected, or missing-support governance MUST be rendered as an attention or warning condition rather than as a calm terminal state.
|
||||||
|
- **FR-166-004**: The tenant findings list MUST make these operator-relevant state families visibly distinguishable: active issue, accepted risk with healthy governance, accepted risk with governance attention needed, and historical resolved or closed workflow state.
|
||||||
|
- **FR-166-005**: The tenant findings list MUST remain scan-friendly and filterable while exposing the extra governance-health distinction.
|
||||||
|
- **FR-166-006**: The finding detail page MUST include a leading status and governance zone that makes visible the current finding lifecycle state, severity or priority, governance health when relevant, owner or assignee, due or SLA urgency, and primary next-action guidance.
|
||||||
|
- **FR-166-007**: The leading finding-detail zone MUST appear before IDs, fingerprints, run references, diff payloads, raw evidence JSON, and other diagnostic-first material.
|
||||||
|
- **FR-166-008**: If the governance resolver or linked exception truth indicates warning, unguided, lapsed, or fresh-decision-needed status, the finding detail surface MUST surface that warning above purely diagnostic sections.
|
||||||
|
- **FR-166-009**: `resolved` and `closed` findings MUST be communicated as workflow or historical states and MUST NOT be presented as implicit proof of permanent remediation, compliance clearance, or currently confirmed technical absence.
|
||||||
|
- **FR-166-010**: If existing data can support a secondary `no longer observed` or equivalent context, the UI SHOULD show it as secondary explanatory context without changing the primary meaning of the workflow status.
|
||||||
|
- **FR-166-011**: Ownership, assignee, due date, and overdue state MUST be visually promoted on relevant findings surfaces when a finding is active or governance-problematic.
|
||||||
|
- **FR-166-012**: Overdue findings MUST be visibly prioritized on at least the findings list and at least one summary or attention surface.
|
||||||
|
- **FR-166-013**: At least one tenant summary or attention surface MUST reflect overdue findings, accepted-risk findings with lapsed governance, and expiring governance rather than only counting `new` findings.
|
||||||
|
- **FR-166-014**: Summary surfaces MUST NOT imply that a tenant is healthy merely because it lacks `new` findings when overdue or governance-problematic findings still exist.
|
||||||
|
- **FR-166-015**: The tenant dashboard needs-attention surface MUST treat lapsed governance, expiring governance, and overdue findings as attention-worthy conditions when present.
|
||||||
|
- **FR-166-016**: The baseline-compare landing or equivalent tenant summary surface MUST use honest copy when findings attention remains due to overdue or unhealthy governance states.
|
||||||
|
- **FR-166-017**: The tenant exception register, canonical exception queue, findings list, finding detail, and summary surfaces MUST not contradict one another about the same underlying governance truth.
|
||||||
|
- **FR-166-018**: The canonical exception queue and tenant exception register MUST continue to expose exception lifecycle and governance validity in ways that support the same healthy vs attention-needed distinction used on findings surfaces.
|
||||||
|
- **FR-166-019**: Surface-level hardening MUST rely on existing findings workflow truth, finding-exception truth, and governance-resolver outputs rather than requiring a new workflow enum, new persistence field, or competing resolver layer.
|
||||||
|
- **FR-166-020**: If a required semantic distinction cannot be stably derived from existing truth, the implementation MUST document the gap as follow-up foundation work rather than silently flattening the state.
|
||||||
|
- **FR-166-021**: Existing finding and exception mutation actions, capability gates, and confirmation behavior MUST continue to function without regression.
|
||||||
|
- **FR-166-022**: Existing centralized badge domains and filter catalogs MUST remain the semantic source for status and governance vocabulary so surfaces do not drift into contradictory local language.
|
||||||
|
- **FR-166-023**: Cross-surface tests MUST verify that healthy accepted risk, expiring governance, lapsed governance, active findings, overdue findings, and historical resolved findings remain distinguishable where intended.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Findings list | `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing tenant findings actions such as `Backfill findings lifecycle` and `Triage all matching`; no new header mutation required for this spec | Clickable row into finding detail | `View` plus existing workflow entry points | Existing grouped workflow bulk actions remain; no new bulk governance action is introduced | Existing no-create empty state remains because findings are generated, not manually created | N/A | N/A | Existing workflow mutations remain audited where already defined | Spec 166 changes surface semantics and prioritization, not the action inventory |
|
||||||
|
| Finding detail | `app/Filament/Resources/FindingResource/Pages/ViewFinding.php` | `Open related record` and existing `Actions` workflow group | N/A | N/A | None | N/A | Existing workflow actions and related-record navigation remain | N/A | Existing workflow and exception actions remain audited where already defined | The main change is reordering and emphasis of status, governance, due, and ownership context |
|
||||||
|
| Tenant risk-exception register | `app/Filament/Resources/FindingExceptionResource.php` | Existing header navigation back to findings origins | Clickable row into exception detail | Direct record actions such as renew or revoke remain state-aware | Bulk actions remain intentionally omitted in v1 | Existing empty state explains that requests start from finding detail | Existing detail actions remain state-aware | N/A | Yes for existing exception decisions | No new bulk governance concept is introduced |
|
||||||
|
| Canonical finding-exceptions queue | `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php` | `Clear filters`, `View tenant register`, selected-record actions such as `Approve exception`, `Reject exception`, `Open tenant detail`, `Open finding` | View action and selected-record detail | `Approve exception`, `Reject exception` via selected-record workflow | None | Existing queue empty state remains | Selected detail actions remain in header | N/A | Yes for approve and reject decisions | Spec 166 aligns queue semantics with findings surfaces; it does not expand mutation scope |
|
||||||
|
| Tenant dashboard attention widgets | `app/Filament/Widgets/Dashboard/NeedsAttention.php` and related tenant dashboard composition | None | Drill-down via existing linked destinations where available | None | None | Healthy state remains read-only | N/A | N/A | No new writes | Read-only operator summary surface; included because semantics materially change |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Finding**: A tenant-scoped governance or drift issue with workflow lifecycle, severity, ownership, due state, and history.
|
||||||
|
- **Finding exception**: A tenant-scoped risk-governance record that can make accepted risk currently valid, expiring, expired, revoked, rejected, or unsupported.
|
||||||
|
- **Governance validity**: The surface-visible truth of whether accepted risk is currently backed by healthy governance, nearing review, or already problematic.
|
||||||
|
- **Finding surface state family**: The normalized operator-facing distinction between active work, accepted risk with healthy governance, accepted risk needing attention, historical workflow closure, and overdue or ownership attention.
|
||||||
|
- **Summary attention state**: The dashboard or summary-level signal that tells an operator whether a tenant still needs action because of overdue work, unhealthy governance, or severe active findings.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-166-001**: In usability review on a seeded tenant, an operator can distinguish within 10 seconds between active issue, accepted risk with healthy governance, accepted risk with governance attention needed, and historical resolved or closed state on the findings list.
|
||||||
|
- **SC-166-002**: In regression coverage, healthy accepted risk and lapsed or unsupported accepted risk render as different operator-visible states on both finding list and finding detail surfaces in 100% of tested cases.
|
||||||
|
- **SC-166-003**: In regression coverage, at least one tenant summary or attention surface surfaces overdue findings and at least one unhealthy-governance condition even when the tenant has zero `new` findings.
|
||||||
|
- **SC-166-004**: In acceptance review, a finding detail page allows an operator to identify status, governance health, due urgency, owner or assignee, and next action before reading diagnostics in under 15 seconds.
|
||||||
|
- **SC-166-005**: In regression coverage, `resolved` findings are not labeled or described as technically fixed, permanently absent, or compliance-cleared on the hardened surfaces.
|
||||||
|
- **SC-166-006**: Cross-surface tests show no contradiction between finding list, finding detail, tenant exception surfaces, and canonical exception queue for the same governance-validity condition.
|
||||||
|
- **SC-166-007**: The feature ships without requiring a new schema field, new workflow enum, or mandatory persistence migration.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing findings workflow semantics from the findings workflow specs remain the source of truth for lifecycle status names and allowed transitions.
|
||||||
|
- Existing finding-exception lifecycle and governance-validity semantics from the risk-acceptance specs remain the source of truth for valid, expiring, expired, revoked, rejected, and missing-support governance states.
|
||||||
|
- Existing dashboard and baseline-compare summary infrastructure can carry additional attention truth without becoming a new standalone governance dashboard.
|
||||||
|
- Any deeper distinction between workflow resolution and strong technical absence may require a later foundation spec if the current data model cannot reliably support it on every surface.
|
||||||
|
|
||||||
|
**Implementation note (2026-03-27)**: This implementation did not uncover any new semantic distinction that required a new persisted truth source or a new foundation abstraction. Existing `Finding`, `FindingException`, and baseline summary truth were sufficient for this slice, so the previously identified resolution-origin follow-up candidate remains unchanged.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introducing a new database table, new required schema field, or new workflow enum for findings or exceptions
|
||||||
|
- Replacing the findings workflow model with a new resolution-origin model in this slice
|
||||||
|
- Redesigning exception scope beyond finding-level risk governance
|
||||||
|
- Introducing proactive email, notification, or alert automation as a new subsystem
|
||||||
|
- Building a full tenant-overview governance program beyond the targeted findings and exception surfaces
|
||||||
|
- Rewriting recurrence or reopen engine internals beyond what existing surface truth already exposes
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Existing Findings Resource, Finding detail, and findings workflow surfaces
|
||||||
|
- Existing Finding Exception Resource and canonical Finding Exceptions Queue
|
||||||
|
- Existing finding risk governance resolver and centralized badge domains
|
||||||
|
- Existing tenant dashboard attention infrastructure and baseline-compare summary surfaces
|
||||||
|
- Existing RBAC and tenant-isolation enforcement for tenant-context and canonical workspace views
|
||||||
|
|
||||||
|
## Follow-up Spec Candidates
|
||||||
|
|
||||||
|
- **Spec 167 — Finding Resolution Origin & Workflow Truth Foundation** if the current data model cannot stably distinguish workflow resolution from no-longer-observed truth on all required surfaces
|
||||||
|
- **Spec 168 — Exception Expiry Alerts & Governance Notifications** for proactive alerting around expiring or expired governance
|
||||||
|
- **Spec 169 — Finding Detail Workflow History & Reopen Context** for deeper recurrence, prior-resolution, and historical workflow storytelling
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
Spec 166 is complete when:
|
||||||
|
|
||||||
|
- accepted-risk findings without valid governance no longer render as calm terminal states,
|
||||||
|
- finding list and finding detail visibly carry governance health,
|
||||||
|
- at least one summary or attention surface surfaces overdue findings and unhealthy governance even without `new` findings,
|
||||||
|
- `resolved` and `closed` are communicated as workflow or historical states rather than implicit technical proof,
|
||||||
|
- list, detail, exception, and summary surfaces do not contradict one another about governance validity,
|
||||||
|
- and the hardening is achievable with existing truth sources and without a mandatory model or schema refactor.
|
||||||
258
specs/166-finding-governance-health/tasks.md
Normal file
258
specs/166-finding-governance-health/tasks.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# Tasks: Finding Governance Health & Resolution Semantics Surface Hardening
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/166-finding-governance-health/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED for this feature because it changes runtime operator behavior across Filament resources, widgets, authorization-sensitive surfaces, and shared badge semantics.
|
||||||
|
**Operations**: This feature introduces no new `OperationRun`; tasks focus on DB-backed surface hardening, existing confirmation behavior, and semantic consistency across findings, exceptions, and summary surfaces.
|
||||||
|
**RBAC**: This feature changes tenant and canonical read surfaces, so tasks must preserve capability-registry enforcement, `404` vs `403` semantics, tenant-safe filtering, and cross-tenant leakage guards.
|
||||||
|
**Filament UI**: This feature modifies Filament resources, pages, and widgets, so tasks include Action Surface Contract, BADGE-001, OPSURF-001, and UX-001 regression coverage.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Prepare shared seeded scenarios and reusable verification entry points for all stories.
|
||||||
|
|
||||||
|
- [ ] T001 Extend shared seeded findings scenarios for overdue, historical, healthy accepted-risk, and lapsed-governance cases, including expiring, expired, revoked, rejected where operator-visible, and missing-support governance states, in `database/factories/FindingFactory.php` and `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`
|
||||||
|
- [ ] T002 [P] Refresh shared dashboard and baseline-compare verification fixtures for governance-attention scenarios in `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared semantic primitives that every story depends on before surface-specific work begins.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Normalize derived governance-attention, warning-message, and resolution-context behavior in `app/Services/Findings/FindingRiskGovernanceResolver.php`
|
||||||
|
- [X] T004 [P] Centralize the hardened governance and lifecycle vocabulary in `app/Support/Badges/BadgeCatalog.php`, `app/Support/Badges/BadgeRenderer.php`, `app/Support/Badges/Domains/FindingRiskGovernanceValidityBadge.php`, and `app/Support/Badges/Domains/FindingStatusBadge.php`
|
||||||
|
- [X] T005 [P] Extend shared lifecycle and governance filter definitions for findings and exception surfaces in `app/Support/Filament/FilterOptionCatalog.php`, `app/Filament/Resources/FindingResource.php`, and `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [ ] T006 Add foundational semantic regression coverage for resolver, badges, and filter truth in `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`, `tests/Unit/Badges/FindingBadgesTest.php`, and `tests/Unit/Badges/BadgeCatalogTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Shared governance semantics are ready. User story work can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Distinguish Safe Accepted Risk From Governance Drift (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make tenant findings and exception surfaces visibly distinguish healthy accepted risk from accepted risk that has governance attention or drift.
|
||||||
|
|
||||||
|
**Independent Test**: Seed healthy, expiring, expired, revoked, rejected where operator-visible, and missing-support accepted-risk findings and verify that findings list, finding detail, tenant exception register, and canonical queue distinguish the relevant states without contradictory semantics.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Add findings-list coverage for healthy versus expiring, expired, revoked, rejected where operator-visible, or missing-support accepted risk, overdue prioritization, owner or assignee promotion, and governance-aware filters in `tests/Feature/Findings/FindingsListDefaultsTest.php` and `tests/Feature/Findings/FindingsListFiltersTest.php`
|
||||||
|
- [X] T008 [P] [US1] Add tenant-register and canonical-queue parity coverage for governance-validity visibility across expiring, expired, revoked, rejected where operator-visible, and missing-support states, plus tenant-prefilter handoff and authorized tenant-filter broadening in `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, and `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||||
|
- [ ] T009 [P] [US1] Add positive and negative authorization coverage for tenant findings and canonical exception governance states, including `404` versus `403` outcomes and no-regression capability gating for existing mutation actions in `tests/Feature/Findings/FindingRbacTest.php` and `tests/Feature/Findings/FindingExceptionAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Harden tenant findings list lifecycle badges, governance cues, overdue emphasis, owner or assignee promotion, and related filters in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
- [X] T011 [US1] Align finding detail governance warnings and healthy accepted-risk messaging with shared resolver truth in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T012 [US1] Align tenant exception register and canonical exception queue governance semantics, tenant-prefilter entry behavior, and authorized tenant-filter broadening with finding surfaces in `app/Filament/Resources/FindingExceptionResource.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`, `app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `app/Support/Navigation/RelatedNavigationResolver.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and closes the primary false-calm risk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Read Resolved And Closed States Without False Reassurance (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Present `resolved` and `closed` as historical workflow states rather than implicit proof of permanent remediation.
|
||||||
|
|
||||||
|
**Independent Test**: Render resolved and closed findings on list, detail, and baseline-compare summary surfaces and verify that copy stays historically accurate, with any `no longer observed` context remaining secondary.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [ ] T013 [P] [US2] Add list and detail coverage that `resolved` and `closed` remain historical workflow states instead of technical proof while preserving relevant historical governance warnings as secondary context in `tests/Feature/Findings/FindingWorkflowViewActionsTest.php` and `tests/Feature/Filament/FindingViewRbacEvidenceTest.php`
|
||||||
|
- [ ] T014 [P] [US2] Add baseline-compare and summary-copy coverage for historical findings without false all-clear language in `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php` and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Revise resolved and closed list/detail copy plus secondary `no longer observed` context while preserving relevant historical governance warnings from the exception trail in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T016 [US2] Carry cautious historical semantics into baseline-compare assessment and explanation logic in `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, `app/Support/Baselines/BaselineCompareSummaryAssessment.php`, and `app/Support/Baselines/BaselineCompareExplanationRegistry.php`
|
||||||
|
- [X] T017 [US2] Update baseline-compare landing presentation so historical findings never read as healthy governance or technical clearance in `app/Filament/Pages/BaselineCompareLanding.php` and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and removes semantic overclaim from historical findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - See Governance And Overdue Problems In Summary Surfaces (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make tenant dashboard and baseline-compare summary surfaces surface overdue findings and unhealthy governance even when no findings are `new`.
|
||||||
|
|
||||||
|
**Independent Test**: Seed a tenant with overdue open findings, expiring governance, and lapsed governance, then verify that dashboard and baseline-compare surfaces still show attention-required states and route operators into the right lists.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US3] Add tenant-dashboard coverage for overdue findings plus expiring and lapsed governance attention in `tests/Feature/Filament/NeedsAttentionWidgetTest.php` and `tests/Feature/Filament/TenantDashboardDbOnlyTest.php`
|
||||||
|
- [ ] T019 [P] [US3] Add baseline-compare widget and landing parity coverage for overdue and governance-attention summaries in `tests/Feature/Filament/BaselineCompareNowWidgetTest.php`, `tests/Feature/Baselines/BaselineCompareFindingsTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T020 [US3] Extend tenant attention aggregates for overdue findings and unhealthy governance in `app/Filament/Widgets/Dashboard/NeedsAttention.php`, `resources/views/filament/widgets/dashboard/needs-attention.blade.php`, and `app/Filament/Pages/TenantDashboard.php`
|
||||||
|
- [X] T021 [US3] Extend baseline-compare stats and assessment logic for overdue and governance-attention conditions in `app/Support/Baselines/BaselineCompareStats.php`, `app/Support/Baselines/BaselineCompareSummaryAssessor.php`, and `app/Support/Baselines/BaselineCompareReasonCode.php`
|
||||||
|
- [X] T022 [US3] Surface the new attention states in baseline-compare widgets and landing UI in `app/Filament/Widgets/Dashboard/BaselineCompareNow.php`, `resources/views/filament/widgets/dashboard/baseline-compare-now.blade.php`, `app/Filament/Widgets/Tenant/BaselineCompareCoverageBanner.php`, `resources/views/filament/widgets/tenant/baseline-compare-coverage-banner.blade.php`, `app/Filament/Pages/BaselineCompareLanding.php`, and `resources/views/filament/pages/baseline-compare-landing.blade.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and summary surfaces no longer underreport overdue or governance-risk work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Get Operator-First Detail Before Diagnostics (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Reorder finding detail so status, governance, ownership, due context, and next action appear before IDs, evidence payloads, and lower-level diagnostics.
|
||||||
|
|
||||||
|
**Independent Test**: Open an active finding, a healthy accepted-risk finding, and a lapsed-governance accepted-risk finding and verify that the first visible zone communicates lifecycle, governance, due urgency, ownership, and next-action guidance before diagnostic sections.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [ ] T023 [P] [US4] Add finding-detail information-order and leading-zone coverage in `tests/Feature/Filament/FindingViewRbacEvidenceTest.php` and `tests/Feature/Findings/FindingRelatedNavigationTest.php`
|
||||||
|
- [ ] T024 [P] [US4] Add action-surface and table-contract regression coverage for the hardened finding and exception screens, including no-regression confirmation requirements for existing destructive actions, in `tests/Feature/Guards/ActionSurfaceContractTest.php` and `tests/Feature/Guards/FilamentTableRiskExceptionsGuardTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T025 [US4] Reorder finding detail into an operator-first status and governance zone ahead of diagnostics in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T026 [US4] Tighten next-action guidance and related-record navigation from the leading finding-detail zone in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, `app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`, and `app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 4 is independently functional and the detail page becomes operator-first instead of diagnostics-first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6b: Queue Surface Hardening — Exception Register Enterprise UX (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Make the tenant risk-exception register (Finding Exceptions list) enterprise-grade by adding severity visibility, actionable finding titles, relative expiry context, KPI stats, and segmented quick-tabs.
|
||||||
|
|
||||||
|
**Independent Test**: Open the tenant exception register and verify that severity badges, descriptive finding titles, relative time descriptions, stats overview, and quick-tab segmentation are visible and functional.
|
||||||
|
|
||||||
|
### Implementation for Queue Surface Hardening
|
||||||
|
|
||||||
|
- [X] T032 [US1+US3] Add finding severity badge column to exception register table using centralized `FindingSeverity` badge domain in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T033 [US1] Improve finding summary in exception register to show subject display name plus finding type instead of bare `Finding #ID` in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T034 [US3] Add relative time descriptions (e.g. "In 3 days", "2 days ago") to review_due_at and expires_at columns in `app/Filament/Resources/FindingExceptionResource.php`
|
||||||
|
- [X] T035 [US3] Create `FindingExceptionStatsOverview` widget showing Active/Expiring/Expired/Pending counts above the exception register table in `app/Filament/Widgets/Tenant/FindingExceptionStatsOverview.php` and `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`
|
||||||
|
- [X] T036 [US1+US3] Add segmented quick-tabs (All / Needs action / Active / Historical) to exception register list page with badge count on Needs action tab in `app/Filament/Resources/FindingExceptionResource/Pages/ListFindingExceptions.php`
|
||||||
|
- [X] T037 Add finding severity filter option catalog entry in `app/Support/Filament/FilterOptionCatalog.php`
|
||||||
|
- [X] T038 [P] Add exception register enterprise-UX coverage for severity column, finding title, relative time, stats widget, and tab segmentation in `tests/Feature/Findings/FindingExceptionRegisterTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Exception register is enterprise-grade with severity, titles, relative timing, stats, and segmentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Lock in cross-surface consistency, tenant isolation, and final verification.
|
||||||
|
|
||||||
|
- [ ] T027 [P] Add cross-surface consistency coverage for the same governance state across findings list, finding detail, exception register, canonical queue, tenant dashboard attention, and baseline-compare summary surfaces in `tests/Feature/Findings/FindingRiskGovernanceProjectionTest.php`, `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Monitoring/FindingExceptionsQueueTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareSummaryConsistencyTest.php`
|
||||||
|
- [X] T028 [P] Refresh tenant-owned query and cross-tenant leakage guards for hardened findings, summaries, and exception surfaces in `tests/Feature/Guards/TenantOwnedQueryGuardTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, and `tests/Feature/Filament/TenantDashboardTenantScopeTest.php`
|
||||||
|
- [X] T029 [P] Refresh no-ad-hoc status and warning badge guards for the new governance-attention states in `tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
|
||||||
|
- [X] T030 Validate the Spec 166 quickstart scenarios and focused Pest pack in `specs/166-finding-governance-health/quickstart.md`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Findings/FindingExceptionRegisterTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `tests/Feature/Filament/BaselineCompareLandingWhyNoFindingsTest.php`
|
||||||
|
- [X] T031 Document any semantic distinction that cannot be stably derived from existing truth as follow-up foundation work in `specs/166-finding-governance-health/spec.md` and `specs/166-finding-governance-health/plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion; recommended MVP slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and benefits from User Story 1 because hardened list/detail semantics are reused.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from User Story 1 and User Story 2 because summary attention logic should reuse the same governance and historical semantics.
|
||||||
|
- **User Story 4 (Phase 6)**: Depends on Foundational completion and benefits from User Story 1 because the leading zone reuses the hardened governance cues.
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependency on other stories after Foundational; this is the MVP.
|
||||||
|
- **US2**: Can start after Foundational, but should land after or alongside US1 so findings surfaces reuse the same accepted-risk semantics.
|
||||||
|
- **US3**: Can start after Foundational, but should follow US1 and US2 for consistent governance and historical messaging on summaries.
|
||||||
|
- **US4**: Can start after Foundational, but is safest after US1 because the detail leading zone depends on the hardened governance cues.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and confirmed failing before implementation tasks begin.
|
||||||
|
- Shared semantic helpers and badges must be updated before surface-specific copy or layout work closes.
|
||||||
|
- Resource and page behavior should land before final guard and parity coverage is refreshed.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002`, `T004`, and `T005` can run in parallel once `T001` and `T003` establish the seeded scenarios and semantic baseline.
|
||||||
|
- In each user story, all `[P]` test tasks can run in parallel.
|
||||||
|
- `T020` and `T021` can run in parallel during US3 once summary test expectations are in place.
|
||||||
|
- `T027`, `T028`, and `T029` can run in parallel in the polish phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 1 test tasks in parallel:
|
||||||
|
T007 Add findings-list coverage for healthy versus expiring or lapsed accepted risk.
|
||||||
|
T008 Add tenant-register and canonical-queue parity coverage for governance-validity visibility.
|
||||||
|
T009 Add positive and negative authorization coverage for tenant findings and canonical exception governance states.
|
||||||
|
|
||||||
|
# Then implement the shared surface hardening in sequence:
|
||||||
|
T010 Harden tenant findings list lifecycle badges, governance cues, overdue emphasis, and filters.
|
||||||
|
T011 Align finding detail governance warnings and healthy accepted-risk messaging.
|
||||||
|
T012 Align tenant exception register and canonical exception queue governance semantics.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 2 test tasks in parallel:
|
||||||
|
T013 Add list and detail coverage for cautious resolved and closed semantics.
|
||||||
|
T014 Add baseline-compare and summary-copy coverage for historical findings.
|
||||||
|
|
||||||
|
# Then implement the historical-state hardening in sequence:
|
||||||
|
T015 Revise resolved and closed list/detail copy.
|
||||||
|
T016 Carry cautious historical semantics into baseline-compare assessment logic.
|
||||||
|
T017 Update baseline-compare landing presentation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 3 test tasks in parallel:
|
||||||
|
T018 Add tenant-dashboard attention coverage.
|
||||||
|
T019 Add baseline-compare widget and landing parity coverage.
|
||||||
|
|
||||||
|
# Then implement the summary-surface work in sequence:
|
||||||
|
T020 Extend tenant attention aggregates.
|
||||||
|
T021 Extend baseline-compare stats and assessment logic.
|
||||||
|
T022 Surface the new attention states in widgets and landing UI.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Story 4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the User Story 4 test tasks in parallel:
|
||||||
|
T023 Add finding-detail information-order and leading-zone coverage.
|
||||||
|
T024 Add action-surface and table-contract regression coverage.
|
||||||
|
|
||||||
|
# Then implement the detail-page reordering in sequence:
|
||||||
|
T025 Reorder finding detail into an operator-first status and governance zone.
|
||||||
|
T026 Tighten next-action guidance and related-record navigation.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1 and Phase 2.
|
||||||
|
2. Deliver User Story 1 as the MVP so accepted risk no longer hides governance drift.
|
||||||
|
3. Confirm the main false-calm risk is closed before broadening summary and detail refinements.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Add User Story 2 to remove false reassurance from `resolved` and `closed` surfaces.
|
||||||
|
2. Add User Story 3 so dashboard and baseline-compare summaries report overdue and unhealthy governance honestly.
|
||||||
|
3. Add User Story 4 to make the detail page operator-first and reduce time-to-decision.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- **Recommended MVP**: Phases 1 through 3 only.
|
||||||
|
- **Why**: This delivers the highest-risk business correction first by making governance drift immediately visible on the primary findings workflow surfaces.
|
||||||
@ -199,3 +199,50 @@ function createAssignedBaselineTenant(): array
|
|||||||
->and($assessment->findingsVisibleCount)->toBe(1)
|
->and($assessment->findingsVisibleCount)->toBe(1)
|
||||||
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats overdue workflow and lapsed governance as action required even with zero compare findings', function (): void {
|
||||||
|
[$tenant, $profile, $snapshot] = createAssignedBaselineTenant();
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::NoDriftDetected->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
'proof' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$assessment = BaselineCompareStats::forTenant($tenant)->summaryAssessment();
|
||||||
|
|
||||||
|
expect($assessment->stateFamily)->toBe(BaselineCompareSummaryAssessment::STATE_ACTION_REQUIRED)
|
||||||
|
->and($assessment->overdueOpenFindingsCount)->toBe(1)
|
||||||
|
->and($assessment->lapsedGovernanceCount)->toBe(1)
|
||||||
|
->and($assessment->headline)->toContain('Accepted-risk governance has lapsed')
|
||||||
|
->and($assessment->nextActionTarget())->toBe(BaselineCompareSummaryAssessment::NEXT_TARGET_FINDINGS);
|
||||||
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -170,3 +171,25 @@ function createNeedsAttentionTenant(): array
|
|||||||
->assertSee('Open Baseline Compare')
|
->assertSee('Open Baseline Compare')
|
||||||
->assertDontSee('Current dashboard signals look trustworthy.');
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces overdue and lapsed-governance findings even when there are no new findings', function (): void {
|
||||||
|
[$user, $tenant] = createNeedsAttentionTenant();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setCurrentPanel(Filament::getPanel('tenant'));
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(NeedsAttention::class)
|
||||||
|
->assertSee('Overdue findings')
|
||||||
|
->assertSee('Lapsed accepted-risk governance')
|
||||||
|
->assertDontSee('Current dashboard signals look trustworthy.');
|
||||||
|
});
|
||||||
|
|||||||
@ -209,7 +209,7 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
|
|||||||
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggleable())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggleable())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('standardizes the monitoring operations view through the operation-run resource table contract', function (): void {
|
it('standardizes the monitoring operations view through the operation-run resource table contract', function (): void {
|
||||||
|
|||||||
@ -58,8 +58,9 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Status')
|
->assertSee('Execution state')
|
||||||
->assertSee('Completed')
|
->assertSee('Run finished')
|
||||||
|
->assertSee('Outcome')
|
||||||
->assertSee('Tenant lifecycle')
|
->assertSee('Tenant lifecycle')
|
||||||
->assertSee('Onboarding')
|
->assertSee('Onboarding')
|
||||||
->assertSee('Tenant selector context')
|
->assertSee('Tenant selector context')
|
||||||
|
|||||||
@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
|
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -124,3 +128,221 @@
|
|||||||
->assertSee('No exceptions match this view')
|
->assertSee('No exceptions match this view')
|
||||||
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
|
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('bridges tenant approval queue links into the admin workspace context', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'owner', workspaceRole: 'owner');
|
||||||
|
|
||||||
|
$otherWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($viewer)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $otherWorkspace->getKey()])
|
||||||
|
->get(route('admin.finding-exceptions.open-queue', ['tenant' => (string) $tenant->external_id]))
|
||||||
|
->assertRedirect(
|
||||||
|
\App\Filament\Pages\Monitoring\FindingExceptionsQueue::getUrl([
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
], panel: 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $tenant->workspace_id)
|
||||||
|
->and(session(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
|
||||||
|
->toHaveKey((string) $tenant->workspace_id, (int) $tenant->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Enterprise UX Hardening (Spec 166 Phase 6b) ---
|
||||||
|
|
||||||
|
it('shows finding severity badge in exception register table', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create(['severity' => Finding::SEVERITY_HIGH]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Test severity badge',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertTableColumnExists('finding.severity')
|
||||||
|
->assertSee('High');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows descriptive finding title instead of bare Finding #ID', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'subject_external_id' => 'test-policy-id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Test finding title',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertSee('Drift');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expires_at column with relative time description', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create();
|
||||||
|
|
||||||
|
$exception = FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $finding->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => FindingException::STATUS_EXPIRING,
|
||||||
|
'current_validity_state' => FindingException::VALIDITY_EXPIRING,
|
||||||
|
'request_reason' => 'Test relative time',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(3),
|
||||||
|
'expires_at' => now()->addDays(5),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertCanSeeTableRecords([$exception])
|
||||||
|
->assertTableColumnExists('expires_at');
|
||||||
|
|
||||||
|
// Verify the relative time helper directly (column descriptions aren't in Livewire test HTML)
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()))->toBe('Today');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDay()))->toBe('Tomorrow');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDays(3)))->toBe('In 3 days');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(now()->addDays(14)))->toBe('In 14 days');
|
||||||
|
expect(FindingExceptionResource::relativeTimeDescription(null))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stats overview widget above exception register table', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindingExceptions::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeLivewire(FindingExceptionStatsOverview::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct stats counts for current tenant', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) Finding::factory()->for($tenant)->create()->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => $status,
|
||||||
|
'current_validity_state' => $validity,
|
||||||
|
'request_reason' => 'Stats test',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$createException(FindingException::STATUS_EXPIRING, FindingException::VALIDITY_EXPIRING);
|
||||||
|
$createException(FindingException::STATUS_EXPIRED, FindingException::VALIDITY_EXPIRED);
|
||||||
|
$createException(FindingException::STATUS_PENDING, FindingException::VALIDITY_MISSING_SUPPORT);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$stats = FindingExceptionResource::exceptionStatsForCurrentTenant();
|
||||||
|
|
||||||
|
expect($stats)->toMatchArray([
|
||||||
|
'active' => 2,
|
||||||
|
'expiring' => 1,
|
||||||
|
'expired' => 1,
|
||||||
|
'pending' => 1,
|
||||||
|
'total' => 5,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('segments exception register with quick-tabs for needs-action, active, and historical', function (): void {
|
||||||
|
[$viewer, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$createException = fn (string $status, string $validity) => FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) Finding::factory()->for($tenant)->create()->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'status' => $status,
|
||||||
|
'current_validity_state' => $validity,
|
||||||
|
'request_reason' => 'Tab test',
|
||||||
|
'requested_at' => now(),
|
||||||
|
'review_due_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$active = $createException(FindingException::STATUS_ACTIVE, FindingException::VALIDITY_VALID);
|
||||||
|
$expiring = $createException(FindingException::STATUS_EXPIRING, FindingException::VALIDITY_EXPIRING);
|
||||||
|
$rejected = $createException(FindingException::STATUS_REJECTED, FindingException::VALIDITY_REJECTED);
|
||||||
|
|
||||||
|
$this->actingAs($viewer);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$list = Livewire::test(ListFindingExceptions::class);
|
||||||
|
|
||||||
|
// All tab shows everything
|
||||||
|
$list->assertCanSeeTableRecords([$active, $expiring, $rejected]);
|
||||||
|
|
||||||
|
// Needs action tab shows expiring (pending, expiring, expired)
|
||||||
|
$list->set('activeTab', 'needs_action')
|
||||||
|
->assertCanSeeTableRecords([$expiring])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $rejected]);
|
||||||
|
|
||||||
|
// Active tab shows only active
|
||||||
|
$list->set('activeTab', 'active')
|
||||||
|
->assertCanSeeTableRecords([$active])
|
||||||
|
->assertCanNotSeeTableRecords([$expiring, $rejected]);
|
||||||
|
|
||||||
|
// Historical tab shows rejected/revoked/superseded
|
||||||
|
$list->set('activeTab', 'historical')
|
||||||
|
->assertCanSeeTableRecords([$rejected])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $expiring]);
|
||||||
|
});
|
||||||
|
|||||||
@ -18,7 +18,7 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
it('defaults to open findings across all finding types', function (): void {
|
it('defaults to a cross-lifecycle findings view across all finding types', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
@ -54,8 +54,7 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(ListFindings::class)
|
Livewire::test(ListFindings::class)
|
||||||
->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened])
|
->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened, $resolved, $closed, $riskAccepted]);
|
||||||
->assertCanNotSeeTableRecords([$resolved, $closed, $riskAccepted]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps findings list defaults calm with explicit sortability and hidden forensic detail', function (): void {
|
it('keeps findings list defaults calm with explicit sortability and hidden forensic detail', function (): void {
|
||||||
@ -71,12 +70,15 @@ function findingsDefaultIndicatorLabels($component): array
|
|||||||
expect($table->getDefaultSortDirection())->toBe('desc');
|
expect($table->getDefaultSortDirection())->toBe('desc');
|
||||||
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
|
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
|
||||||
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
|
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
|
||||||
|
expect($table->getColumn('governance_validity'))->not->toBeNull();
|
||||||
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
|
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
|
||||||
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
|
||||||
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
|
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
|
||||||
|
expect($component->instance()->getTable()->getFilter('workflow_family'))->not->toBeNull();
|
||||||
|
expect($component->instance()->getTable()->getFilter('governance_validity'))->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defines created date-range narrowing with active indicators on the findings table', function (): void {
|
it('defines created date-range narrowing with active indicators on the findings table', function (): void {
|
||||||
|
|||||||
149
tests/Feature/Findings/FindingsListEnterpriseUxTest.php
Normal file
149
tests/Feature/Findings/FindingsListEnterpriseUxTest.php
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Filament\Widgets\Tenant\FindingStatsOverview;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('uses badge-formatted finding type labels instead of raw enum values', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_PERMISSION_POSTURE,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertSee('Permission posture');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows subject display name as an early visible column', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListFindings::class);
|
||||||
|
$table = $component->instance()->getTable();
|
||||||
|
$columns = array_keys($table->getVisibleColumns());
|
||||||
|
|
||||||
|
$subjectIndex = array_search('subject_display_name', $columns);
|
||||||
|
$statusIndex = array_search('status', $columns);
|
||||||
|
|
||||||
|
expect($subjectIndex)->toBeLessThan($statusIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places severity column before governance in column order', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListFindings::class);
|
||||||
|
$columns = array_keys($component->instance()->getTable()->getVisibleColumns());
|
||||||
|
|
||||||
|
$severityIndex = array_search('severity', $columns);
|
||||||
|
$governanceIndex = array_search('governance_validity', $columns);
|
||||||
|
|
||||||
|
expect($severityIndex)->toBeLessThan($governanceIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the finding stats overview widget above the findings table', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSeeLivewire(FindingStatsOverview::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct finding stats counts for current tenant', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_IN_PROGRESS,
|
||||||
|
'severity' => Finding::SEVERITY_HIGH,
|
||||||
|
'due_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'severity' => Finding::SEVERITY_LOW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = FindingResource::findingStatsForCurrentTenant();
|
||||||
|
|
||||||
|
expect($stats['open'])->toBe(2);
|
||||||
|
expect($stats['overdue'])->toBe(1);
|
||||||
|
expect($stats['high_severity'])->toBe(1);
|
||||||
|
expect($stats['risk_accepted'])->toBe(1);
|
||||||
|
expect($stats['total'])->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('segments findings with quick-tabs for needs-action, overdue, risk-accepted, and resolved', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$open = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$overdue = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_TRIAGED,
|
||||||
|
'due_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$riskAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolved = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$list = Livewire::test(ListFindings::class);
|
||||||
|
|
||||||
|
// All tab shows everything
|
||||||
|
$list->assertCanSeeTableRecords([$open, $overdue, $riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Needs action tab shows open findings
|
||||||
|
$list->set('activeTab', 'needs_action')
|
||||||
|
->assertCanSeeTableRecords([$open, $overdue])
|
||||||
|
->assertCanNotSeeTableRecords([$riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Overdue tab shows only overdue open findings
|
||||||
|
$list->set('activeTab', 'overdue')
|
||||||
|
->assertCanSeeTableRecords([$overdue])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $riskAccepted, $resolved]);
|
||||||
|
|
||||||
|
// Risk accepted tab
|
||||||
|
$list->set('activeTab', 'risk_accepted')
|
||||||
|
->assertCanSeeTableRecords([$riskAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $overdue, $resolved]);
|
||||||
|
|
||||||
|
// Resolved tab
|
||||||
|
$list->set('activeTab', 'resolved')
|
||||||
|
->assertCanSeeTableRecords([$resolved])
|
||||||
|
->assertCanNotSeeTableRecords([$open, $overdue, $riskAccepted]);
|
||||||
|
});
|
||||||
@ -44,6 +44,66 @@ function findingFilterIndicatorLabels($component): array
|
|||||||
->assertCanNotSeeTableRecords([$notOverdueOpen, $overdueTerminal]);
|
->assertCanNotSeeTableRecords([$notOverdueOpen, $overdueTerminal]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters findings by workflow family and governance validity', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
[$requester] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$active = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$healthyAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
\App\Models\FindingException::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'finding_id' => (int) $healthyAccepted->getKey(),
|
||||||
|
'requested_by_user_id' => (int) $requester->getKey(),
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'approved_by_user_id' => (int) $approver->getKey(),
|
||||||
|
'status' => \App\Models\FindingException::STATUS_ACTIVE,
|
||||||
|
'current_validity_state' => \App\Models\FindingException::VALIDITY_VALID,
|
||||||
|
'request_reason' => 'Healthy governance',
|
||||||
|
'approval_reason' => 'Approved',
|
||||||
|
'requested_at' => now()->subDays(5),
|
||||||
|
'approved_at' => now()->subDays(4),
|
||||||
|
'effective_from' => now()->subDays(4),
|
||||||
|
'review_due_at' => now()->addDays(7),
|
||||||
|
'expires_at' => now()->addDays(14),
|
||||||
|
'evidence_summary' => ['reference_count' => 0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lapsedAccepted = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$historical = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RESOLVED,
|
||||||
|
'resolved_at' => now()->subDay(),
|
||||||
|
'resolved_reason' => 'no_longer_drifting',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->filterTable('workflow_family', 'historical')
|
||||||
|
->assertCanSeeTableRecords([$historical])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $lapsedAccepted])
|
||||||
|
->removeTableFilters()
|
||||||
|
->filterTable('governance_validity', \App\Models\FindingException::VALIDITY_VALID)
|
||||||
|
->assertCanSeeTableRecords([$healthyAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $lapsedAccepted, $historical])
|
||||||
|
->removeTableFilters()
|
||||||
|
->filterTable('governance_validity', \App\Models\FindingException::VALIDITY_MISSING_SUPPORT)
|
||||||
|
->assertCanSeeTableRecords([$lapsedAccepted])
|
||||||
|
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $historical]);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters findings by high severity quick filter', function (): void {
|
it('filters findings by high severity quick filter', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -598,6 +598,35 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
it('does not render visibility sync Livewire updates for tenantless runs', 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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => null,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'created_at' => now()->subSeconds(5),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get("/admin/operations/{$run->getKey()}")
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee("opsUxIsTabHidden', document.hidden", escape: false)
|
||||||
|
->assertDontSee('visibilitychange.window', escape: false)
|
||||||
|
->assertSee('wire:poll.1s', escape: false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not render polling markup for terminal tenantless runs', function (string $outcome): void {
|
it('does not render polling markup for terminal tenantless runs', function (string $outcome): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@ -9,11 +9,11 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
it('renders resolved status badge with success color', function (): void {
|
it('renders resolved status badge with neutral color', function (): void {
|
||||||
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RESOLVED);
|
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_RESOLVED);
|
||||||
|
|
||||||
expect($spec->label)->toBe('Resolved')
|
expect($spec->label)->toBe('Resolved')
|
||||||
->and($spec->color)->toBe('success')
|
->and($spec->color)->toBe('gray')
|
||||||
->and($spec->icon)->toBe('heroicon-o-check-circle');
|
->and($spec->icon)->toBe('heroicon-o-check-circle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -44,9 +44,31 @@
|
|||||||
expect($reopened->label)->toBe('Reopened');
|
expect($reopened->label)->toBe('Reopened');
|
||||||
expect($reopened->color)->toBe('danger');
|
expect($reopened->color)->toBe('danger');
|
||||||
|
|
||||||
|
$resolved = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'resolved');
|
||||||
|
expect($resolved->label)->toBe('Resolved');
|
||||||
|
expect($resolved->color)->toBe('gray');
|
||||||
|
|
||||||
$closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'closed');
|
$closed = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'closed');
|
||||||
expect($closed->label)->toBe('Closed');
|
expect($closed->label)->toBe('Closed');
|
||||||
|
|
||||||
$riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'risk_accepted');
|
$riskAccepted = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'risk_accepted');
|
||||||
expect($riskAccepted->label)->toBe('Risk accepted');
|
expect($riskAccepted->label)->toBe('Risk accepted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps finding governance validity values to operator-facing attention colors', function (): void {
|
||||||
|
$valid = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'valid');
|
||||||
|
expect($valid->label)->toBe('Valid');
|
||||||
|
expect($valid->color)->toBe('success');
|
||||||
|
|
||||||
|
$expiring = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'expiring');
|
||||||
|
expect($expiring->label)->toBe('Expiring');
|
||||||
|
expect($expiring->color)->toBe('warning');
|
||||||
|
|
||||||
|
$rejected = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'rejected');
|
||||||
|
expect($rejected->label)->toBe('Rejected');
|
||||||
|
expect($rejected->color)->toBe('danger');
|
||||||
|
|
||||||
|
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, 'missing_support');
|
||||||
|
expect($missingSupport->label)->toBe('Missing support');
|
||||||
|
expect($missingSupport->color)->toBe('danger');
|
||||||
|
});
|
||||||
|
|||||||
@ -39,5 +39,5 @@
|
|||||||
|
|
||||||
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_MISSING_SUPPORT);
|
$missingSupport = BadgeCatalog::spec(BadgeDomain::FindingRiskGovernanceValidity, FindingException::VALIDITY_MISSING_SUPPORT);
|
||||||
expect($missingSupport->label)->toBe('Missing support')
|
expect($missingSupport->label)->toBe('Missing support')
|
||||||
->and($missingSupport->color)->toBe('gray');
|
->and($missingSupport->color)->toBe('danger');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user