Compare commits
1 Commits
dev
...
152-livewi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2d6a8942 |
28
.github/agents/copilot-instructions.md
vendored
28
.github/agents/copilot-instructions.md
vendored
@ -90,28 +90,6 @@ ## Active Technologies
|
|||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
||||||
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
||||||
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
|
||||||
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
|
||||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
|
||||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
|
||||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
|
||||||
- PostgreSQL-backed existing domain records; no new business-domain table is required for the first slice; shared taxonomy reference will live in repository documentation and code-level metadata (156-operator-outcome-taxonomy)
|
|
||||||
- PostgreSQL-backed existing records such as `operation_runs`, tenant governance records, onboarding workflow state, and provider connection state; no new business-domain table is required for the first slice (157-reason-code-translation)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer` / `OperatorOutcomeTaxonomy`, `ReasonPresenter`, `OperationRunService`, `TenantReviewReadinessGate`, existing baseline/evidence/review/review-pack resources and canonical pages (158-artifact-truth-semantics)
|
|
||||||
- PostgreSQL with existing JSONB-backed `summary`, `summary_jsonb`, and `context` payloads on baseline snapshots, evidence snapshots, tenant reviews, review packs, and operation runs; no new primary storage required for the first slice (158-artifact-truth-semantics)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, Laravel queue workers, existing `OperationRunService`, `TrackOperationRun`, `OperationUxPresenter`, `ReasonPresenter`, `BadgeCatalog` domain badges, and current Operations Monitoring pages (160-operation-lifecycle-guarantees)
|
|
||||||
- PostgreSQL for `operation_runs`, `jobs`, and `failed_jobs`; JSONB-backed `context`, `summary_counts`, and `failure_summary`; configuration in `config/queue.php` and `config/tenantpilot.php` (160-operation-lifecycle-guarantees)
|
|
||||||
- PostgreSQL (via Sail) plus existing read models persisted in application tables (161-operator-explanation-layer)
|
|
||||||
- PHP 8.4 / Laravel 12, Blade, Alpine via Filament, Tailwind CSS v4 + Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `OperationRun` and baseline compare services (162-baseline-gap-details)
|
|
||||||
- PostgreSQL with JSONB-backed `operation_runs.context`; no new tables required (162-baseline-gap-details)
|
|
||||||
- PostgreSQL via existing application tables, especially `operation_runs.context` and baseline snapshot summary JSON (163-baseline-subject-resolution)
|
|
||||||
- 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)
|
|
||||||
- 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)
|
|
||||||
- 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)
|
||||||
|
|
||||||
@ -131,8 +109,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
|
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||||
- 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`
|
- 151-findings-workflow-backstop: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||||
- 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`
|
- 150-tenant-owned-query-canon-and-wrong-tenant-guards: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,34 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.13.0 → 1.14.0
|
- Version change: 1.11.0 → 1.12.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Governance / Scope & Compliance → Governance / Scope, Compliance, and Review Expectations
|
- Scope & Ownership Clarification (SCOPE-001)
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Proportionality First (PROP-001)
|
- None
|
||||||
- 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
|
- None
|
||||||
- ✅ .specify/templates/spec-template.md
|
|
||||||
- ✅ .specify/templates/plan-template.md
|
|
||||||
- ✅ .specify/templates/tasks-template.md
|
|
||||||
- ✅ docs/product/standards/README.md
|
|
||||||
- ✅ docs/HANDOVER.md
|
|
||||||
- ✅ docs/product/principles.md
|
|
||||||
- ✅ Agents.md
|
|
||||||
- Commands checked:
|
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
|
|
||||||
- Follow-up TODOs:
|
- Follow-up TODOs:
|
||||||
- None.
|
- None.
|
||||||
-->
|
-->
|
||||||
@ -57,73 +37,6 @@ ### 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).
|
||||||
@ -417,65 +330,6 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
|||||||
- The visible run label for that action MUST be `Policy sync`.
|
- The visible run label for that action MUST be `Policy sync`.
|
||||||
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
- The audit prose for that action MUST be `{actor} queued policy sync`.
|
||||||
|
|
||||||
### Operator Surface Principles (OPSURF-001)
|
|
||||||
|
|
||||||
Goal: operator-facing surfaces MUST optimize for the primary working audience rather than raw implementation visibility.
|
|
||||||
|
|
||||||
Operator-first default surfaces
|
|
||||||
- `/admin` is operator-first.
|
|
||||||
- Default-visible content MUST use operator-facing language, clear scope, and actionable status communication.
|
|
||||||
- Raw implementation details MUST NOT be default-visible on primary operator surfaces.
|
|
||||||
|
|
||||||
Progressive disclosure for diagnostics
|
|
||||||
- Diagnostic detail MAY be available where needed, but it MUST be secondary and explicitly revealed.
|
|
||||||
- JSON payloads, raw IDs, internal field names, provider error details, and low-level technical metadata belong in diagnostics surfaces, drawers, tabs, accordions, or modals rather than primary content.
|
|
||||||
- A surface MUST NOT require operators to parse raw JSON or provider-specific field names to understand the primary state or next action.
|
|
||||||
|
|
||||||
Distinct status dimensions
|
|
||||||
- Operator-facing surfaces MUST distinguish at least the following dimensions when they all exist in the domain:
|
|
||||||
- execution outcome
|
|
||||||
- data completeness
|
|
||||||
- governance result
|
|
||||||
- lifecycle or readiness state
|
|
||||||
- These dimensions MUST NOT be collapsed into a single ambiguous status model.
|
|
||||||
- If a surface summarizes multiple status dimensions, the default-visible presentation MUST label each dimension explicitly.
|
|
||||||
|
|
||||||
Explicit mutation scope
|
|
||||||
- Every action that changes state MUST communicate before execution whether it affects:
|
|
||||||
- TenantPilot only
|
|
||||||
- the Microsoft tenant
|
|
||||||
- simulation only
|
|
||||||
- Mutation scope MUST be understandable from the action label, helper text, confirmation copy, preview, or nearby status copy before the operator commits.
|
|
||||||
- A mutating action MUST NOT rely on hidden implementation knowledge to communicate its blast radius.
|
|
||||||
|
|
||||||
Safe execution for dangerous actions
|
|
||||||
- Dangerous actions MUST follow a consistent safe-execution pattern:
|
|
||||||
- configuration
|
|
||||||
- safety checks or simulation
|
|
||||||
- preview
|
|
||||||
- hard confirmation where required
|
|
||||||
- execute
|
|
||||||
- One-click destructive actions are not acceptable for high-blast-radius operations.
|
|
||||||
- When a full multi-step flow is not feasible, the spec MUST document the explicit exemption and the replacement safeguards.
|
|
||||||
|
|
||||||
Explicit workspace and tenant context
|
|
||||||
- Workspace and tenant scope MUST remain explicit in navigation, actions, and page semantics.
|
|
||||||
- Tenant-scoped surfaces MUST NOT silently expose workspace-wide actions.
|
|
||||||
- Canonical workspace views that reference tenant-owned records MUST make the workspace and tenant context legible before the operator acts.
|
|
||||||
|
|
||||||
Page contract requirement
|
|
||||||
- Every new or materially refactored operator-facing page MUST define:
|
|
||||||
- primary persona
|
|
||||||
- surface type
|
|
||||||
- primary operator question
|
|
||||||
- default-visible information
|
|
||||||
- diagnostics-only information
|
|
||||||
- status dimensions used
|
|
||||||
- mutation scope
|
|
||||||
- primary actions
|
|
||||||
- dangerous actions
|
|
||||||
- This page contract MUST be recorded in the governing spec and kept in sync when the page semantics materially change.
|
|
||||||
|
|
||||||
Spec Scope Fields (SCOPE-002)
|
Spec Scope Fields (SCOPE-002)
|
||||||
|
|
||||||
- Every feature spec MUST declare:
|
- Every feature spec MUST declare:
|
||||||
@ -498,39 +352,6 @@ ### Badge Semantics Are Centralized (BADGE-001)
|
|||||||
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
- Introducing or changing a status-like value MUST include updating the relevant badge mapper and adding/updating tests for the mapping.
|
||||||
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
- Tag/category chips (e.g., type/platform/environment) are not status-like and are not governed by BADGE-001.
|
||||||
|
|
||||||
### Filament Native First / No Ad-hoc Styling (UI-FIL-001)
|
|
||||||
- Admin and operator-facing surfaces MUST use native Filament components, existing shared UI primitives, and centralized design patterns first.
|
|
||||||
- If Filament already provides the required semantic element, feature code MUST use the Filament-native component instead of a locally assembled replacement.
|
|
||||||
- Preferred native elements include `x-filament::badge`, `x-filament::button`, `x-filament::icon`, and Filament Forms, Infolists, Tables, Sections, Tabs, Grids, and Actions.
|
|
||||||
|
|
||||||
Forbidden local replacements
|
|
||||||
- Feature code MUST NOT hand-build badges, pills, status chips, alert cards, or action buttons from raw `<span>`, `<div>`, or `<button>` markup plus Tailwind classes when Filament-native or shared project primitives can express the same meaning.
|
|
||||||
- Feature code MUST NOT introduce page-local visual status languages for status, risk, outcome, drift, trust, importance, or severity.
|
|
||||||
- Feature code MUST NOT make local color, border, rounding, or emphasis decisions for semantic UI states using ad-hoc classes such as `bg-danger-100`, `text-warning-900`, `border-dashed`, or `rounded-full` when the same state can be expressed through Filament props or shared primitives.
|
|
||||||
|
|
||||||
Shared primitive before local override
|
|
||||||
- If the same UI pattern can recur, it MUST use an existing shared primitive or introduce a new central primitive instead of reassembling the pattern inside a Blade view.
|
|
||||||
- Central badge and status catalogs remain the canonical source for status semantics; local views MUST consume them rather than re-map them.
|
|
||||||
|
|
||||||
Upgrade-safe preference
|
|
||||||
- Update-safe, framework-native implementations take priority over page-local styling shortcuts.
|
|
||||||
- Filament props such as `color`, `icon`, and standard component composition are the default mechanism for semantic emphasis.
|
|
||||||
- Publishing or emulating Filament internals for cosmetic speed is not acceptable when native composition or shared primitives are sufficient.
|
|
||||||
|
|
||||||
Exception rule
|
|
||||||
- Ad-hoc markup or styling is allowed only when all of the following are true:
|
|
||||||
- native Filament components cannot express the required semantics,
|
|
||||||
- no suitable shared primitive exists,
|
|
||||||
- and the deviation is justified briefly in code and in the governing spec or PR.
|
|
||||||
- Approved exceptions MUST stay layout-neutral, use the minimum local classes necessary, and MUST NOT invent a new page-local status language.
|
|
||||||
|
|
||||||
Review and enforcement
|
|
||||||
- Every UI review MUST answer:
|
|
||||||
- which native Filament element or shared primitive was used,
|
|
||||||
- why an existing component was insufficient if an exception was taken,
|
|
||||||
- and whether any ad-hoc status or emphasis styling was introduced.
|
|
||||||
- UI work is not Done if it introduces ad-hoc status styling or framework-foreign replacement components where a native Filament or shared UI solution was viable.
|
|
||||||
|
|
||||||
### Incremental UI Standards Enforcement (UI-STD-001)
|
### Incremental UI Standards Enforcement (UI-STD-001)
|
||||||
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
- UI consistency is enforced incrementally, not by recurring cleanup passes.
|
||||||
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
- New tables, filters, and list surfaces MUST follow established Filament-native standards from the first implementation.
|
||||||
@ -552,12 +373,9 @@ ## Quality Gates
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
### Scope, Compliance, and Review Expectations
|
### Scope & Compliance
|
||||||
- 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`.
|
||||||
@ -569,4 +387,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.14.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-27
|
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10
|
||||||
|
|||||||
@ -48,22 +48,8 @@ ## 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
|
|
||||||
- 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
|
||||||
- Operator surfaces (OPSURF-001): `/admin` defaults are operator-first; default-visible content avoids raw implementation detail; diagnostics are explicitly revealed secondarily
|
|
||||||
- Operator surfaces (OPSURF-001): execution outcome, data completeness, governance result, and lifecycle/readiness are modeled as distinct status dimensions when all apply; they are not collapsed into one ambiguous status
|
|
||||||
- Operator surfaces (OPSURF-001): every mutating action communicates whether it changes TenantPilot only, the Microsoft tenant, or simulation only before execution
|
|
||||||
- Operator surfaces (OPSURF-001): dangerous actions follow configuration → safety checks/simulation → preview → hard confirmation where required → execute, unless a spec documents an explicit exemption and replacement safeguards
|
|
||||||
- Operator surfaces (OPSURF-001): workspace and tenant context remain explicit in navigation, actions, and page semantics; tenant surfaces do not silently expose workspace-wide actions
|
|
||||||
- Operator surfaces (OPSURF-001): each new or materially refactored operator-facing page defines a page contract covering persona, surface type, operator question, default-visible info, diagnostics-only info, status dimensions, mutation scope, primary actions, and dangerous actions
|
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@ -129,20 +115,9 @@ # [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
|||||||
|
|
||||||
## Complexity Tracking
|
## Complexity Tracking
|
||||||
|
|
||||||
> **Fill when Constitution Check has violations that must be justified OR when BLOAT-001 is triggered by new persistence, abstractions, states, or semantic frameworks.**
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
| 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]
|
|
||||||
|
|||||||
@ -17,35 +17,6 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
If this feature adds a new operator-facing page or materially refactors one, fill out one row per affected page/surface.
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 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)*
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@ -123,16 +94,6 @@ ## 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`),
|
||||||
@ -158,12 +119,6 @@ ## Requirements *(mandatory)*
|
|||||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
**Constitution alignment (UI-FIL-001):** If this feature adds or changes Filament or Blade UI for admin/operator surfaces, the spec MUST describe:
|
|
||||||
- which native Filament components or shared UI primitives are used,
|
|
||||||
- whether any local replacement markup was avoided for badges, alerts, buttons, or status surfaces,
|
|
||||||
- how semantic emphasis is expressed through Filament props or central primitives rather than page-local color/border classes,
|
|
||||||
- and any exception where Filament or a shared primitive was insufficient, including why the exception is necessary and how it avoids introducing a new local status language.
|
|
||||||
|
|
||||||
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
**Constitution alignment (UI-NAMING-001):** If this feature adds or changes operator-facing buttons, header actions, run titles,
|
||||||
notifications, audit prose, or related helper copy, the spec MUST describe:
|
notifications, audit prose, or related helper copy, the spec MUST describe:
|
||||||
- the target object,
|
- the target object,
|
||||||
@ -172,26 +127,9 @@ ## Requirements *(mandatory)*
|
|||||||
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
- how the same domain vocabulary is preserved across button labels, modal titles, run titles, notifications, and audit prose,
|
||||||
- and how implementation-first terms are kept out of primary operator-facing labels.
|
- and how implementation-first terms are kept out of primary operator-facing labels.
|
||||||
|
|
||||||
**Constitution alignment (OPSURF-001):** If this feature adds or materially refactors an operator-facing surface, the spec MUST describe:
|
|
||||||
- how the default-visible content stays operator-first on `/admin` and avoids raw implementation detail,
|
|
||||||
- which diagnostics are secondary and how they are explicitly revealed,
|
|
||||||
- which status dimensions are shown separately (execution outcome, data completeness, governance result, lifecycle/readiness) and why,
|
|
||||||
- how each mutating action communicates its mutation scope before execution (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
|
||||||
- how dangerous actions follow the safe-execution pattern (configuration, safety checks/simulation, preview, hard confirmation where required, execute),
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
**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.
|
||||||
The same section MUST also state whether UI-FIL-001 is satisfied and identify any approved exception.
|
|
||||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** If this feature adds or modifies any Filament screen,
|
||||||
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
the spec MUST describe compliance with UX-001: Create/Edit uses Main/Aside layout (3-col grid), all fields inside Sections/Cards
|
||||||
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
(no naked inputs), View pages use Infolists (not disabled edit forms), status badges use BADGE-001, empty states have a specific
|
||||||
|
|||||||
@ -38,13 +38,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- using source/domain terms only where same-screen disambiguation is required,
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
- removing implementation-first wording from primary operator-facing copy.
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
**Operator Surfaces**: If this feature adds or materially refactors an operator-facing page or flow, tasks MUST include:
|
|
||||||
- filling the spec’s Operator Surface Contract for every affected page,
|
|
||||||
- making default-visible content operator-first and moving JSON payloads, raw IDs, internal field names, provider error details, and low-level metadata into explicitly revealed diagnostics surfaces,
|
|
||||||
- modeling execution outcome, data completeness, governance result, and lifecycle/readiness as distinct status dimensions when applicable,
|
|
||||||
- making mutation scope legible before execution for every state-changing action (`TenantPilot only`, `Microsoft tenant`, or `simulation only`),
|
|
||||||
- implementing the safe-execution flow for dangerous actions (configuration, safety checks/simulation, preview, hard confirmation where required, execute) or documenting an approved exemption,
|
|
||||||
- keeping workspace and tenant context explicit in navigation, actions, and page semantics so tenant pages do not silently expose workspace-wide actions.
|
|
||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
@ -53,9 +46,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
- adding `AuditLog` entries for relevant mutations,
|
- adding `AuditLog` entries for relevant mutations,
|
||||||
- using native Filament components or shared UI primitives before any local Blade/Tailwind assembly for badges, alerts, buttons, and semantic status surfaces,
|
|
||||||
- avoiding page-local semantic color, border, rounding, or highlight styling when Filament props or shared primitives can express the same state,
|
|
||||||
- documenting any UI-FIL-001 exception with rationale in the spec/PR,
|
|
||||||
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
- adding/updated tests that enforce the contract and block merge on violations, OR documenting an explicit exemption with rationale.
|
||||||
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
**Filament UI UX-001 (Layout & IA)**: If this feature adds/modifies any Filament screen, tasks MUST include:
|
||||||
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
- ensuring Create/Edit pages use Main/Aside layout (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)),
|
||||||
@ -67,13 +57,6 @@ # 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.
|
||||||
|
|
||||||
@ -220,7 +203,6 @@ ## 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,14 +25,12 @@ ## Scope Reference
|
|||||||
- Tenant-scoped RBAC and audit logs
|
- Tenant-scoped RBAC and audit logs
|
||||||
|
|
||||||
## Workflow (Spec Kit)
|
## Workflow (Spec Kit)
|
||||||
1. Read `.specify/memory/constitution.md`
|
1. Read `.specify/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)
|
||||||
|
|||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class PurgeLegacyBaselineGapRuns extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
|
|
||||||
{--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
|
|
||||||
{--tenant=* : Limit cleanup to tenant ids or tenant external ids}
|
|
||||||
{--workspace=* : Limit cleanup to workspace ids}
|
|
||||||
{--limit=500 : Maximum candidate runs to inspect}
|
|
||||||
{--force : Actually delete matched legacy runs}';
|
|
||||||
|
|
||||||
protected $description = 'Purge development-only baseline compare/capture runs that still use legacy broad gap payloads.';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
if (! app()->environment(['local', 'testing'])) {
|
|
||||||
$this->error('This cleanup command is limited to local and testing environments.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$types = $this->normalizedTypes();
|
|
||||||
$workspaceIds = array_values(array_filter(
|
|
||||||
array_map(
|
|
||||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
|
||||||
(array) $this->option('workspace'),
|
|
||||||
),
|
|
||||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
|
||||||
));
|
|
||||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
|
||||||
$limit = max(1, (int) $this->option('limit'));
|
|
||||||
$dryRun = ! (bool) $this->option('force');
|
|
||||||
|
|
||||||
$query = OperationRun::query()
|
|
||||||
->whereIn('type', $types)
|
|
||||||
->orderBy('id')
|
|
||||||
->limit($limit);
|
|
||||||
|
|
||||||
if ($workspaceIds !== []) {
|
|
||||||
$query->whereIn('workspace_id', $workspaceIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenantIds !== []) {
|
|
||||||
$query->whereIn('tenant_id', $tenantIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = $query->get();
|
|
||||||
$matched = $candidates
|
|
||||||
->filter(static fn (OperationRun $run): bool => $run->hasLegacyBaselineGapPayload())
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if ($matched->isEmpty()) {
|
|
||||||
$this->info('No legacy baseline gap runs matched the current filters.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->table(
|
|
||||||
['Run', 'Type', 'Tenant', 'Workspace', 'Legacy signal'],
|
|
||||||
$matched
|
|
||||||
->map(fn (OperationRun $run): array => [
|
|
||||||
'Run' => (string) $run->getKey(),
|
|
||||||
'Type' => (string) $run->type,
|
|
||||||
'Tenant' => $run->tenant_id !== null ? (string) $run->tenant_id : '—',
|
|
||||||
'Workspace' => $run->workspace_id !== null ? (string) $run->workspace_id : '—',
|
|
||||||
'Legacy signal' => $this->legacySignal($run),
|
|
||||||
])
|
|
||||||
->all(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->warn(sprintf(
|
|
||||||
'Dry run: matched %d legacy baseline run(s). Re-run with --force to delete them.',
|
|
||||||
$matched->count(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationRun::query()
|
|
||||||
->whereKey($matched->modelKeys())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$this->info(sprintf('Deleted %d legacy baseline run(s).', $matched->count()));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function normalizedTypes(): array
|
|
||||||
{
|
|
||||||
$types = array_values(array_unique(array_filter(
|
|
||||||
array_map(
|
|
||||||
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
|
|
||||||
(array) $this->option('type'),
|
|
||||||
),
|
|
||||||
)));
|
|
||||||
|
|
||||||
if ($types === []) {
|
|
||||||
return ['baseline_compare', 'baseline_capture'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter(
|
|
||||||
$types,
|
|
||||||
static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $tenantIdentifiers
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
|
||||||
{
|
|
||||||
if ($tenantIdentifiers === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = [];
|
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
|
||||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$tenantIds[] = (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($tenantIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function legacySignal(OperationRun $run): string
|
|
||||||
{
|
|
||||||
$byReason = $run->baselineGapEnvelope()['by_reason'] ?? null;
|
|
||||||
$byReason = is_array($byReason) ? $byReason : [];
|
|
||||||
|
|
||||||
if (array_key_exists('policy_not_found', $byReason)) {
|
|
||||||
return 'legacy_reason_code';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'legacy_subject_shape';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||||
|
|
||||||
public function handle(
|
public function handle(OperationRunService $operationRunService): int
|
||||||
OperationRunService $operationRunService,
|
{
|
||||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
|
||||||
): int {
|
|
||||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
@ -99,9 +96,31 @@ public function handle(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$change = $operationLifecycleReconciler->reconcileRun($operationRun, $dryRun);
|
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operationRun->status === 'running') {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'backup_schedule.stalled',
|
||||||
|
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($change !== null) {
|
|
||||||
$reconciled++;
|
$reconciled++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Operations\OperationLifecycleReconciler;
|
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class TenantpilotReconcileOperationRuns extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'tenantpilot:operation-runs:reconcile
|
|
||||||
{--type=* : Limit reconciliation to one or more covered operation types}
|
|
||||||
{--tenant=* : Limit reconciliation to tenant_id or tenant external_id}
|
|
||||||
{--workspace=* : Limit reconciliation to workspace ids}
|
|
||||||
{--limit=100 : Maximum number of active runs to inspect}
|
|
||||||
{--dry-run : Report the changes without writing them}';
|
|
||||||
|
|
||||||
protected $description = 'Reconcile stale covered operation runs back to deterministic terminal truth.';
|
|
||||||
|
|
||||||
public function handle(
|
|
||||||
OperationLifecycleReconciler $reconciler,
|
|
||||||
OperationLifecyclePolicy $policy,
|
|
||||||
): int {
|
|
||||||
$types = array_values(array_filter(
|
|
||||||
(array) $this->option('type'),
|
|
||||||
static fn (mixed $type): bool => is_string($type) && trim($type) !== '',
|
|
||||||
));
|
|
||||||
$workspaceIds = array_values(array_filter(
|
|
||||||
array_map(
|
|
||||||
static fn (mixed $workspaceId): int => is_numeric($workspaceId) ? (int) $workspaceId : 0,
|
|
||||||
(array) $this->option('workspace'),
|
|
||||||
),
|
|
||||||
static fn (int $workspaceId): bool => $workspaceId > 0,
|
|
||||||
));
|
|
||||||
$tenantIds = $this->resolveTenantIds(array_values(array_filter((array) $this->option('tenant'))));
|
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
|
|
||||||
if ($types === []) {
|
|
||||||
$types = $policy->coveredTypeNames();
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $reconciler->reconcile([
|
|
||||||
'types' => $types,
|
|
||||||
'tenant_ids' => $tenantIds,
|
|
||||||
'workspace_ids' => $workspaceIds,
|
|
||||||
'limit' => max(1, (int) $this->option('limit')),
|
|
||||||
'dry_run' => $dryRun,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rows = collect($result['changes'] ?? [])
|
|
||||||
->map(static function (array $change): array {
|
|
||||||
return [
|
|
||||||
'Run' => (string) ($change['operation_run_id'] ?? '—'),
|
|
||||||
'Type' => (string) ($change['type'] ?? '—'),
|
|
||||||
'Reason' => (string) ($change['reason_code'] ?? '—'),
|
|
||||||
'Applied' => (($change['applied'] ?? false) === true) ? 'yes' : 'no',
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($rows !== []) {
|
|
||||||
$this->table(['Run', 'Type', 'Reason', 'Applied'], $rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Inspected %d run(s); reconciled %d; skipped %d.',
|
|
||||||
(int) ($result['candidates'] ?? 0),
|
|
||||||
(int) ($result['reconciled'] ?? 0),
|
|
||||||
(int) ($result['skipped'] ?? 0),
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->comment('Dry-run: no changes written.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $tenantIdentifiers
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
|
||||||
{
|
|
||||||
if ($tenantIdentifiers === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = [];
|
|
||||||
|
|
||||||
foreach ($tenantIdentifiers as $identifier) {
|
|
||||||
$tenant = Tenant::query()->forTenant($identifier)->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$tenantIds[] = (int) $tenant->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($tenantIds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use App\Services\Evidence\EvidenceResolutionResult;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class ReviewPackEvidenceResolutionException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly EvidenceResolutionResult $result,
|
|
||||||
?string $message = null,
|
|
||||||
) {
|
|
||||||
parent::__construct($message ?? self::defaultMessage($result));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function defaultMessage(EvidenceResolutionResult $result): string
|
|
||||||
{
|
|
||||||
return match ($result->outcome) {
|
|
||||||
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
|
||||||
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
|
||||||
default => 'Evidence snapshot resolution failed.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -60,8 +59,6 @@ class BaselineCompareLanding extends Page
|
|||||||
|
|
||||||
public ?int $duplicateNamePoliciesCount = null;
|
public ?int $duplicateNamePoliciesCount = null;
|
||||||
|
|
||||||
public ?int $duplicateNameSubjectsCount = null;
|
|
||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $findingsCount = null;
|
public ?int $findingsCount = null;
|
||||||
@ -89,24 +86,9 @@ class BaselineCompareLanding extends Page
|
|||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $evidenceGapsTopReasons = null;
|
public ?array $evidenceGapsTopReasons = null;
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $evidenceGapSummary = null;
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>>|null */
|
|
||||||
public ?array $evidenceGapBuckets = null;
|
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $baselineCompareDiagnostics = null;
|
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $rbacRoleDefinitionSummary = null;
|
public ?array $rbacRoleDefinitionSummary = null;
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $operatorExplanation = null;
|
|
||||||
|
|
||||||
/** @var array<string, mixed>|null */
|
|
||||||
public ?array $summaryAssessment = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -141,7 +123,6 @@ public function refreshStats(): void
|
|||||||
$this->profileId = $stats->profileId;
|
$this->profileId = $stats->profileId;
|
||||||
$this->snapshotId = $stats->snapshotId;
|
$this->snapshotId = $stats->snapshotId;
|
||||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||||
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
|
||||||
$this->operationRunId = $stats->operationRunId;
|
$this->operationRunId = $stats->operationRunId;
|
||||||
$this->findingsCount = $stats->findingsCount;
|
$this->findingsCount = $stats->findingsCount;
|
||||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||||
@ -158,18 +139,7 @@ public function refreshStats(): void
|
|||||||
|
|
||||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||||
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
$this->evidenceGapsTopReasons = $stats->evidenceGapsTopReasons !== [] ? $stats->evidenceGapsTopReasons : null;
|
||||||
$this->evidenceGapSummary = is_array($stats->evidenceGapDetails['summary'] ?? null)
|
|
||||||
? $stats->evidenceGapDetails['summary']
|
|
||||||
: null;
|
|
||||||
$this->evidenceGapBuckets = is_array($stats->evidenceGapDetails['buckets'] ?? null) && $stats->evidenceGapDetails['buckets'] !== []
|
|
||||||
? $stats->evidenceGapDetails['buckets']
|
|
||||||
: null;
|
|
||||||
$this->baselineCompareDiagnostics = $stats->baselineCompareDiagnostics !== []
|
|
||||||
? $stats->baselineCompareDiagnostics
|
|
||||||
: null;
|
|
||||||
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
$this->rbacRoleDefinitionSummary = $stats->rbacRoleDefinitionSummary !== [] ? $stats->rbacRoleDefinitionSummary : null;
|
||||||
$this->operatorExplanation = $stats->operatorExplanation()->toArray();
|
|
||||||
$this->summaryAssessment = $stats->summaryAssessment()->toArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,32 +152,26 @@ public function refreshStats(): void
|
|||||||
*/
|
*/
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
|
||||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||||
? (int) $evidenceGapSummary['count']
|
|
||||||
: (int) ($this->evidenceGapsCount ?? 0);
|
|
||||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||||
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
&& array_sum($this->rbacRoleDefinitionSummary) > 0;
|
||||||
$evidenceGapDetailState = is_string($evidenceGapSummary['detail_state'] ?? null)
|
|
||||||
? (string) $evidenceGapSummary['detail_state']
|
|
||||||
: 'no_gaps';
|
|
||||||
$hasEvidenceGapDetailSection = $evidenceGapDetailState !== 'no_gaps';
|
|
||||||
$hasEvidenceGapDiagnostics = is_array($this->baselineCompareDiagnostics) && $this->baselineCompareDiagnostics !== [];
|
|
||||||
|
|
||||||
$evidenceGapsSummary = null;
|
$evidenceGapsSummary = null;
|
||||||
$evidenceGapsTooltip = null;
|
$evidenceGapsTooltip = null;
|
||||||
|
|
||||||
if ($hasEvidenceGaps) {
|
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||||
$parts = array_map(
|
$parts = [];
|
||||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
|
||||||
BaselineCompareEvidenceGapDetails::topReasons(
|
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||||
5,
|
continue;
|
||||||
),
|
}
|
||||||
);
|
|
||||||
|
$parts[] = $reason.' ('.((int) $count).')';
|
||||||
|
}
|
||||||
|
|
||||||
if ($parts !== []) {
|
if ($parts !== []) {
|
||||||
$evidenceGapsSummary = implode(', ', $parts);
|
$evidenceGapsSummary = implode(', ', $parts);
|
||||||
@ -243,16 +207,12 @@ protected function getViewData(): array
|
|||||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||||
'hasWarnings' => $hasWarnings,
|
'hasWarnings' => $hasWarnings,
|
||||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
|
||||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
|
||||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
|
||||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||||
'findingsColorClass' => $findingsColorClass,
|
'findingsColorClass' => $findingsColorClass,
|
||||||
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
'whyNoFindingsMessage' => $whyNoFindingsMessage,
|
||||||
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
'whyNoFindingsFallback' => $whyNoFindingsFallback,
|
||||||
'whyNoFindingsColor' => $whyNoFindingsColor,
|
'whyNoFindingsColor' => $whyNoFindingsColor,
|
||||||
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,22 +307,9 @@ private function compareNowAction(): Action
|
|||||||
$result = $service->startCompare($tenant, $user);
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
|
||||||
|
|
||||||
$message = match ($reasonCode) {
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
|
||||||
default => 'Reason: '.$reasonCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
->body($message)
|
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,6 @@
|
|||||||
use Filament\Tables\Contracts\HasTable;
|
use Filament\Tables\Contracts\HasTable;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -83,16 +82,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->authorizePageAccess();
|
$this->authorizePageAccess();
|
||||||
$requestedEventId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
$this->selectedAuditLogId = is_numeric(request()->query('event')) ? (int) request()->query('event') : null;
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
app(CanonicalAdminTenantFilterState::class)->sync($this->getTableFiltersSessionKey(), request: request());
|
||||||
|
|
||||||
$this->mountInteractsWithTable();
|
$this->mountInteractsWithTable();
|
||||||
|
|
||||||
if ($requestedEventId !== null) {
|
if ($this->selectedAuditLogId !== null) {
|
||||||
$this->resolveAuditLog($requestedEventId);
|
$this->selectedAuditLog();
|
||||||
$this->selectedAuditLogId = $requestedEventId;
|
|
||||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,10 +98,31 @@ public function mount(): void
|
|||||||
*/
|
*/
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return app(OperateHubShell::class)->headerActions(
|
$actions = app(OperateHubShell::class)->headerActions(
|
||||||
scopeActionName: 'operate_hub_scope_audit_log',
|
scopeActionName: 'operate_hub_scope_audit_log',
|
||||||
returnActionName: 'operate_hub_return_audit_log',
|
returnActionName: 'operate_hub_return_audit_log',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($this->selectedAuditLog() instanceof AuditLogModel) {
|
||||||
|
$actions[] = Action::make('clear_selected_audit_event')
|
||||||
|
->label('Close details')
|
||||||
|
->color('gray')
|
||||||
|
->action(function (): void {
|
||||||
|
$this->clearSelectedAuditLog();
|
||||||
|
});
|
||||||
|
|
||||||
|
$relatedLink = $this->selectedAuditLink();
|
||||||
|
|
||||||
|
if (is_array($relatedLink)) {
|
||||||
|
$actions[] = Action::make('open_selected_audit_target')
|
||||||
|
->label($relatedLink['label'])
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->url($relatedLink['url']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
@ -177,19 +195,9 @@ public function table(Table $table): Table
|
|||||||
->label('Inspect event')
|
->label('Inspect event')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->before(function (AuditLogModel $record): void {
|
->action(function (AuditLogModel $record): void {
|
||||||
$this->selectedAuditLogId = (int) $record->getKey();
|
$this->selectedAuditLogId = (int) $record->getKey();
|
||||||
})
|
}),
|
||||||
->slideOver()
|
|
||||||
->stickyModalHeader()
|
|
||||||
->modalSubmitAction(false)
|
|
||||||
->modalCancelAction(fn (Action $action): Action => $action->label('Close details'))
|
|
||||||
->modalHeading(fn (AuditLogModel $record): string => $record->summaryText())
|
|
||||||
->modalDescription(fn (AuditLogModel $record): ?string => $record->recorded_at?->toDayDateTimeString())
|
|
||||||
->modalContent(fn (AuditLogModel $record): View => view('filament.pages.monitoring.partials.audit-log-inspect-event', [
|
|
||||||
'selectedAudit' => $record,
|
|
||||||
'selectedAuditLink' => $this->auditTargetLink($record),
|
|
||||||
])),
|
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No audit events match this view')
|
->emptyStateHeading('No audit events match this view')
|
||||||
@ -201,11 +209,48 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
|
$this->selectedAuditLogId = null;
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function clearSelectedAuditLog(): void
|
||||||
|
{
|
||||||
|
$this->selectedAuditLogId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectedAuditLog(): ?AuditLogModel
|
||||||
|
{
|
||||||
|
if (! is_numeric($this->selectedAuditLogId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->auditBaseQuery()
|
||||||
|
->whereKey((int) $this->selectedAuditLogId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, url: string}|null
|
||||||
|
*/
|
||||||
|
public function selectedAuditLink(): ?array
|
||||||
|
{
|
||||||
|
$record = $this->selectedAuditLog();
|
||||||
|
|
||||||
|
if (! $record instanceof AuditLogModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, Tenant>
|
* @return array<int, Tenant>
|
||||||
*/
|
*/
|
||||||
@ -278,54 +323,6 @@ private function auditBaseQuery(): Builder
|
|||||||
->latestFirst();
|
->latestFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveAuditLog(int $auditLogId): AuditLogModel
|
|
||||||
{
|
|
||||||
$record = $this->auditBaseQuery()
|
|
||||||
->whereKey($auditLogId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof AuditLogModel) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedAuditRecord(): ?AuditLogModel
|
|
||||||
{
|
|
||||||
if (! is_int($this->selectedAuditLogId) || $this->selectedAuditLogId <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $this->resolveAuditLog($this->selectedAuditLogId);
|
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}|null
|
|
||||||
*/
|
|
||||||
public function selectedAuditTargetLink(): ?array
|
|
||||||
{
|
|
||||||
$record = $this->selectedAuditRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof AuditLogModel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->auditTargetLink($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label: string, url: string}|null
|
|
||||||
*/
|
|
||||||
private function auditTargetLink(AuditLogModel $record): ?array
|
|
||||||
{
|
|
||||||
return app(RelatedNavigationResolver::class)->auditTargetLink($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,136 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Auth\AuthenticationException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EvidenceOverview extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Evidence Overview';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public array $rows = [];
|
|
||||||
|
|
||||||
public ?int $tenantFilter = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
throw new AuthenticationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceContext = app(WorkspaceContext::class);
|
|
||||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
|
||||||
$workspaceId = (int) $workspace->getKey();
|
|
||||||
|
|
||||||
$accessibleTenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get()
|
|
||||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
|
||||||
|
|
||||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
|
||||||
|
|
||||||
$query = EvidenceSnapshot::query()
|
|
||||||
->with('tenant')
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->whereIn('tenant_id', $tenantIds)
|
|
||||||
->where('status', 'active')
|
|
||||||
->latest('generated_at');
|
|
||||||
|
|
||||||
if ($this->tenantFilter !== null) {
|
|
||||||
$query->where('tenant_id', $this->tenantFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
|
||||||
$truth = app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($snapshot);
|
|
||||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
|
||||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
|
||||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
|
||||||
'artifact_truth' => [
|
|
||||||
'label' => $truth->primaryLabel,
|
|
||||||
'color' => $truth->primaryBadgeSpec()->color,
|
|
||||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
|
||||||
'explanation' => $truth->primaryExplanation,
|
|
||||||
],
|
|
||||||
'freshness' => [
|
|
||||||
'label' => $freshnessSpec->label,
|
|
||||||
'color' => $freshnessSpec->color,
|
|
||||||
'icon' => $freshnessSpec->icon,
|
|
||||||
],
|
|
||||||
'next_step' => $truth->nextStepText(),
|
|
||||||
'view_url' => $snapshot->tenant
|
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
|
||||||
: null,
|
|
||||||
];
|
|
||||||
})->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
|
||||||
->url(route('admin.evidence.overview')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,539 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingExceptionsQueue extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
public ?int $selectedFindingExceptionId = null;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Finding exceptions';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'finding-exceptions/queue';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Finding Exceptions Queue';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
|
||||||
moreGroupLabel: 'More',
|
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
|
||||||
))
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->whereKey($workspaceId)->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 function mount(): void
|
|
||||||
{
|
|
||||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
|
|
||||||
if ($this->selectedFindingExceptionId !== null) {
|
|
||||||
$this->selectedFindingException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$actions = app(OperateHubShell::class)->headerActions(
|
|
||||||
scopeActionName: 'operate_hub_scope_finding_exceptions',
|
|
||||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
|
||||||
);
|
|
||||||
|
|
||||||
$actions[] = Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
|
||||||
->action(function (): void {
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->removeTableFilter('status');
|
|
||||||
$this->removeTableFilter('current_validity_state');
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
$this->resetTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('view_tenant_register')
|
|
||||||
->label('View tenant register')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
|
||||||
->url(function (): ?string {
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('clear_selected_exception')
|
|
||||||
->label('Close details')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->action(function (): void {
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_exception')
|
|
||||||
->label('Open tenant detail')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
|
||||||
|
|
||||||
$actions[] = Action::make('approve_selected_exception')
|
|
||||||
->label('Approve exception')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
DateTimePicker::make('effective_from')
|
|
||||||
->label('Effective from')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Expires at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
Textarea::make('approval_reason')
|
|
||||||
->label('Approval reason')
|
|
||||||
->rows(3)
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasRenewalRequest = $record->isPendingRenewal();
|
|
||||||
$updated = $service->approve($record, $user, $data);
|
|
||||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
|
||||||
$this->resetTable();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('reject_selected_exception')
|
|
||||||
->label('Reject exception')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('rejection_reason')
|
|
||||||
->label('Rejection reason')
|
|
||||||
->rows(3)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasRenewalRequest = $record->isPendingRenewal();
|
|
||||||
$updated = $service->reject($record, $user, $data);
|
|
||||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
|
||||||
$this->resetTable();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->queueBaseQuery())
|
|
||||||
->defaultSort('requested_at', 'asc')
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
|
||||||
TextColumn::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant')
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('finding_summary')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
|
||||||
->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')
|
|
||||||
->label('Requested by')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('owner.name')
|
|
||||||
->label('Owner')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('review_due_at')
|
|
||||||
->label('Review due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('expires_at')
|
|
||||||
->label('Expires')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('requested_at')
|
|
||||||
->label('Requested')
|
|
||||||
->dateTime()
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->searchable(),
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
|
||||||
SelectFilter::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('inspect_exception')
|
|
||||||
->label('Inspect exception')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (FindingException $record): void {
|
|
||||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No exceptions match this queue')
|
|
||||||
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
|
|
||||||
->emptyStateIcon('heroicon-o-shield-check')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (): void {
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->removeTableFilter('status');
|
|
||||||
$this->removeTableFilter('current_validity_state');
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedFindingException(): ?FindingException
|
|
||||||
{
|
|
||||||
if (! is_int($this->selectedFindingExceptionId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $this->queueBaseQuery()
|
|
||||||
->whereKey($this->selectedFindingExceptionId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedExceptionUrl(): ?string
|
|
||||||
{
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedFindingUrl(): ?string
|
|
||||||
{
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->authorizedTenants = $tenants
|
|
||||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueBaseQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
$tenantIds = array_values(array_map(
|
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
||||||
$this->authorizedTenants(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return FindingException::query()
|
|
||||||
->with([
|
|
||||||
'tenant',
|
|
||||||
'requester',
|
|
||||||
'owner',
|
|
||||||
'approver',
|
|
||||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
|
||||||
'decisions.actor',
|
|
||||||
'evidenceReferences',
|
|
||||||
])
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return Collection::make($this->authorizedTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$tenantId = $this->currentTenantFilterId();
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
|
||||||
{
|
|
||||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasActiveQueueFilters(): bool
|
|
||||||
{
|
|
||||||
return $this->currentTenantFilterId() !== null
|
|
||||||
|| is_string(data_get($this->tableFilters, 'status.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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\OperationLifecyclePolicy;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -166,68 +165,6 @@ public function table(Table $table): Table
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{likely_stale:int,reconciled:int}
|
|
||||||
*/
|
|
||||||
public function lifecycleVisibilitySummary(): array
|
|
||||||
{
|
|
||||||
$baseQuery = $this->scopedSummaryQuery();
|
|
||||||
|
|
||||||
if (! $baseQuery instanceof Builder) {
|
|
||||||
return [
|
|
||||||
'likely_stale' => 0,
|
|
||||||
'reconciled' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reconciled = (clone $baseQuery)
|
|
||||||
->whereNotNull('context->reconciliation->reconciled_at')
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$policy = app(OperationLifecyclePolicy::class);
|
|
||||||
$likelyStale = (clone $baseQuery)
|
|
||||||
->whereIn('status', [
|
|
||||||
OperationRunStatus::Queued->value,
|
|
||||||
OperationRunStatus::Running->value,
|
|
||||||
])
|
|
||||||
->where(function (Builder $query) use ($policy): void {
|
|
||||||
foreach ($policy->coveredTypeNames() as $type) {
|
|
||||||
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
||||||
$typeQuery
|
|
||||||
->where('type', $type)
|
|
||||||
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
||||||
$stateQuery
|
|
||||||
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
||||||
$queuedQuery
|
|
||||||
->where('status', OperationRunStatus::Queued->value)
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
||||||
})
|
|
||||||
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
||||||
$runningQuery
|
|
||||||
->where('status', OperationRunStatus::Running->value)
|
|
||||||
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
|
||||||
$startedAtQuery
|
|
||||||
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
||||||
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
||||||
$fallbackQuery
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'likely_stale' => $likelyStale,
|
|
||||||
'reconciled' => $reconciled,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyActiveTab(Builder $query): Builder
|
private function applyActiveTab(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return match ($this->activeTab) {
|
return match ($this->activeTab) {
|
||||||
@ -235,9 +172,6 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
OperationRunStatus::Queued->value,
|
OperationRunStatus::Queued->value,
|
||||||
OperationRunStatus::Running->value,
|
OperationRunStatus::Running->value,
|
||||||
]),
|
]),
|
||||||
'blocked' => $query
|
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
|
||||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
|
||||||
'succeeded' => $query
|
'succeeded' => $query
|
||||||
->where('status', OperationRunStatus::Completed->value)
|
->where('status', OperationRunStatus::Completed->value)
|
||||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||||
@ -250,26 +184,4 @@ private function applyActiveTab(Builder $query): Builder
|
|||||||
default => $query,
|
default => $query,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private function scopedSummaryQuery(): ?Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! $workspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
||||||
}
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->when(
|
|
||||||
is_numeric($tenantFilter),
|
|
||||||
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,13 +19,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -54,6 +51,8 @@ class TenantlessOperationRunViewer extends Page
|
|||||||
*/
|
*/
|
||||||
public ?array $navigationContextPayload = null;
|
public ?array $navigationContextPayload = null;
|
||||||
|
|
||||||
|
public bool $opsUxIsTabHidden = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<Action|ActionGroup>
|
* @return array<Action|ActionGroup>
|
||||||
*/
|
*/
|
||||||
@ -170,56 +169,24 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
$reasonCode = data_get($context, 'reason_code');
|
||||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
|
||||||
? array_values(array_filter([
|
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||||
$operatorExplanation->headline,
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||||
$operatorExplanation->dominantCauseExplanation,
|
}
|
||||||
]))
|
|
||||||
: ($reasonEnvelope?->toBodyLines(false) ?? [
|
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
$message = $this->run->failure_summary[0]['message'] ?? null;
|
||||||
]);
|
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Execution blocked',
|
||||||
'body' => implode(' ', array_values(array_unique($lines))),
|
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
|
||||||
*/
|
|
||||||
public function lifecycleBanner(): ?array
|
|
||||||
{
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attention = OperationUxPresenter::lifecycleAttentionSummary($this->run);
|
|
||||||
|
|
||||||
if ($attention === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$detail = OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'Lifecycle truth needs operator review.';
|
|
||||||
|
|
||||||
return match ($this->run->freshnessState()->value) {
|
|
||||||
'likely_stale' => [
|
|
||||||
'tone' => 'amber',
|
|
||||||
'title' => 'Likely stale run',
|
|
||||||
'body' => $detail,
|
|
||||||
],
|
|
||||||
'reconciled_failed' => [
|
|
||||||
'tone' => 'rose',
|
|
||||||
'title' => 'Automatically reconciled',
|
|
||||||
'body' => $detail,
|
|
||||||
],
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
*/
|
*/
|
||||||
@ -284,6 +251,10 @@ 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;
|
||||||
}
|
}
|
||||||
@ -450,13 +421,4 @@ private function relatedLinksTenant(): ?Tenant
|
|||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
lane: TenantInteractionLane::StandardActiveOperating,
|
||||||
)->allowed ? $tenant : null;
|
)->allowed ? $tenant : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function governanceOperatorExplanation(): ?OperatorExplanationPattern
|
|
||||||
{
|
|
||||||
if (! isset($this->run) || ! $this->run->supportsOperatorExplanation()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ArtifactTruthPresenter::class)->forOperationRun($this->run)?->operatorExplanation;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,328 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Reviews;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class ReviewRegister extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Reviews';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Review Register';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.reviews.review-register';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorizePageAccess();
|
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
['status', 'published_state', 'completeness_state'],
|
|
||||||
request(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
|
||||||
->action(function (): void {
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->registerQuery())
|
|
||||||
->defaultSort('generated_at', 'desc')
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
||||||
TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryLabel)
|
|
||||||
->color(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (TenantReview $record): ?string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->headline ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
TextColumn::make('publication_truth')
|
|
||||||
->label('Publication')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->label)
|
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
app(ArtifactTruthPresenter::class)->forTenantReview($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => app(ArtifactTruthPresenter::class)->forTenantReview($record)->operatorExplanation?->nextActionText ?? app(ArtifactTruthPresenter::class)->forTenantReview($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
|
||||||
->searchable(),
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
'draft' => 'Draft',
|
|
||||||
'ready' => 'Ready',
|
|
||||||
'published' => 'Published',
|
|
||||||
'archived' => 'Archived',
|
|
||||||
'superseded' => 'Superseded',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
|
||||||
SelectFilter::make('published_state')
|
|
||||||
->label('Published state')
|
|
||||||
->options([
|
|
||||||
'published' => 'Published',
|
|
||||||
'unpublished' => 'Not published',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
return match ($data['value'] ?? null) {
|
|
||||||
'published' => $query->whereNotNull('published_at'),
|
|
||||||
'unpublished' => $query->whereNull('published_at'),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
|
||||||
Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No review records match this view')
|
|
||||||
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('clear_filters_empty')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->action(fn (): mixed => $this->resetTable()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->authorizedTenants() === []) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function registerQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return TenantReview::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->authorizedTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultTenantFilter(): ?string
|
|
||||||
{
|
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
||||||
|
|
||||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
||||||
? (string) $tenantId
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasActiveFilters(): bool
|
|
||||||
{
|
|
||||||
$filters = array_filter((array) $this->tableFilters);
|
|
||||||
|
|
||||||
return $filters !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
return is_numeric($workspaceId)
|
|
||||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2882,12 +2882,9 @@ public function startVerification(): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -6,28 +6,19 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Models\BaselineTenantAssignment;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -297,32 +288,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->placeholder('None'),
|
->placeholder('None'),
|
||||||
])
|
])
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Section::make('Baseline truth')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('current_snapshot_truth')
|
|
||||||
->label('Current snapshot')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record)),
|
|
||||||
TextEntry::make('latest_attempted_snapshot_truth')
|
|
||||||
->label('Latest attempt')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record)),
|
|
||||||
TextEntry::make('compare_readiness')
|
|
||||||
->label('Compare readiness')
|
|
||||||
->badge()
|
|
||||||
->state(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
||||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
||||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record)),
|
|
||||||
TextEntry::make('baseline_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->state(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
->label('Created by')
|
->label('Created by')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
|
TextEntry::make('activeSnapshot.captured_at')
|
||||||
|
->label('Last snapshot')
|
||||||
|
->dateTime()
|
||||||
|
->placeholder('No snapshot yet'),
|
||||||
TextEntry::make('created_at')
|
TextEntry::make('created_at')
|
||||||
->dateTime(),
|
->dateTime(),
|
||||||
TextEntry::make('updated_at')
|
TextEntry::make('updated_at')
|
||||||
@ -381,27 +355,10 @@ public static function table(Table $table): Table
|
|||||||
TextColumn::make('tenant_assignments_count')
|
TextColumn::make('tenant_assignments_count')
|
||||||
->label('Assigned tenants')
|
->label('Assigned tenants')
|
||||||
->counts('tenantAssignments'),
|
->counts('tenantAssignments'),
|
||||||
TextColumn::make('current_snapshot_truth')
|
TextColumn::make('activeSnapshot.captured_at')
|
||||||
->label('Current snapshot')
|
->label('Last snapshot')
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
->dateTime()
|
||||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
->placeholder('No snapshot'),
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('latest_attempted_snapshot_truth')
|
|
||||||
->label('Latest attempt')
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::latestAttemptedSnapshotLabel($record))
|
|
||||||
->description(fn (BaselineProfile $record): ?string => self::latestAttemptedSnapshotDescription($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('compare_readiness')
|
|
||||||
->label('Compare readiness')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::compareReadinessLabel($record))
|
|
||||||
->color(fn (BaselineProfile $record): string => self::compareReadinessColor($record))
|
|
||||||
->icon(fn (BaselineProfile $record): ?string => self::compareReadinessIcon($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('baseline_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (BaselineProfile $record): string => self::profileNextStep($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -588,167 +545,4 @@ private static function archiveTableAction(?Workspace $workspace): Action
|
|||||||
|
|
||||||
return $action;
|
return $action;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function currentSnapshotLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
$snapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
|
||||||
return 'No complete snapshot';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::snapshotReference($snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentSnapshotDescription(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$snapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $snapshot->captured_at?->toDayDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshotLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
||||||
return 'No capture attempts yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
||||||
return 'Matches current snapshot';
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::snapshotReference($latestAttempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshotDescription(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$latestAttempt = self::latestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if (! $latestAttempt instanceof BaselineSnapshot) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effectiveSnapshot = self::effectiveSnapshot($profile);
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() === (int) $latestAttempt->getKey()) {
|
|
||||||
return 'No newer attempt is pending.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $latestAttempt->captured_at?->toDayDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessLabel(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessColor(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
|
||||||
null => 'success',
|
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
|
||||||
default => 'warning',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
|
||||||
null => 'heroicon-m-check-badge',
|
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
|
||||||
default => 'heroicon-m-exclamation-triangle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function profileNextStep(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function effectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return app(BaselineSnapshotTruthResolver::class)->resolveEffectiveSnapshot($profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function latestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareAvailabilityReason(BaselineProfile $profile): ?string
|
|
||||||
{
|
|
||||||
$status = $profile->status instanceof BaselineProfileStatus
|
|
||||||
? $profile->status
|
|
||||||
: BaselineProfileStatus::tryFrom((string) $profile->status);
|
|
||||||
|
|
||||||
if ($status !== BaselineProfileStatus::Active) {
|
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolution = app(BaselineSnapshotTruthResolver::class)->resolveCompareSnapshot($profile);
|
|
||||||
$reasonCode = $resolution['reason_code'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
|
||||||
return trim($reasonCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! self::hasEligibleCompareTarget($profile)) {
|
|
||||||
return BaselineReasonCodes::COMPARE_NO_ELIGIBLE_TARGET;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compareAvailabilityEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
|
|
||||||
{
|
|
||||||
$reasonCode = self::compareAvailabilityReason($profile);
|
|
||||||
|
|
||||||
if (! is_string($reasonCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function snapshotReference(BaselineSnapshot $snapshot): string
|
|
||||||
{
|
|
||||||
$lifecycleLabel = BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value)->label;
|
|
||||||
|
|
||||||
return sprintf('Snapshot #%d (%s)', (int) $snapshot->getKey(), $lifecycleLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hasEligibleCompareTarget(BaselineProfile $profile): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantIds = BaselineTenantAssignment::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->pluck('tenant_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($tenantIds === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
return Tenant::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->whereIn('id', $tenantIds)
|
|
||||||
->get(['id'])
|
|
||||||
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -183,7 +183,7 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
$modalDescription = $captureMode === BaselineCaptureMode::FullContent
|
||||||
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
? 'Select the target tenant. This will refresh content evidence on demand (redacted) before comparing.'
|
||||||
: 'Select the target tenant to compare its current inventory against the effective current baseline snapshot.';
|
: 'Select the target tenant to compare its current inventory against the active baseline snapshot.';
|
||||||
|
|
||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
|||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -256,11 +256,7 @@ private function compareNowAction(): Action
|
|||||||
$message = match ($reasonCode) {
|
$message = match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -399,12 +395,4 @@ private function hasManageCapability(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function profileHasConsumableSnapshot(): bool
|
|
||||||
{
|
|
||||||
/** @var BaselineProfile $profile */
|
|
||||||
$profile = $this->getRecord();
|
|
||||||
|
|
||||||
return $profile->resolveCurrentConsumableSnapshot() !== null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,7 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
@ -23,8 +18,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -172,39 +165,15 @@ public static function table(Table $table): Table
|
|||||||
->label('Captured')
|
->label('Captured')
|
||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(static fn (BaselineSnapshot $record): ?string => self::truthEnvelope($record)->operatorExplanation?->headline ?? self::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('lifecycle_state')
|
|
||||||
->label('Lifecycle')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->label)
|
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::lifecycleSpec($record)->color)
|
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->icon)
|
|
||||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::lifecycleSpec($record)->iconColor)
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('current_truth')
|
|
||||||
->label('Current truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::currentTruthLabel($record))
|
|
||||||
->color(static fn (BaselineSnapshot $record): string => self::currentTruthColor($record))
|
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::currentTruthIcon($record))
|
|
||||||
->description(static fn (BaselineSnapshot $record): ?string => self::currentTruthDescription($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('fidelity_summary')
|
TextColumn::make('fidelity_summary')
|
||||||
->label('Fidelity')
|
->label('Fidelity')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('artifact_next_step')
|
TextColumn::make('snapshot_state')
|
||||||
->label('Next step')
|
->label('State')
|
||||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
->badge()
|
||||||
->wrap(),
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::stateLabel($record))
|
||||||
|
->color(static fn (BaselineSnapshot $record): string => self::hasGaps($record) ? 'warning' : 'success'),
|
||||||
])
|
])
|
||||||
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
->recordUrl(static fn (BaselineSnapshot $record): ?string => static::canView($record)
|
||||||
? static::getUrl('view', ['record' => $record])
|
? static::getUrl('view', ['record' => $record])
|
||||||
@ -214,10 +183,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Baseline')
|
->label('Baseline')
|
||||||
->options(static::baselineProfileOptions())
|
->options(static::baselineProfileOptions())
|
||||||
->searchable(),
|
->searchable(),
|
||||||
SelectFilter::make('lifecycle_state')
|
SelectFilter::make('snapshot_state')
|
||||||
->label('Lifecycle')
|
->label('State')
|
||||||
->options(static::lifecycleOptions())
|
->options(static::snapshotStateOptions())
|
||||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
@ -278,9 +247,12 @@ private static function baselineProfileOptions(): array
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private static function lifecycleOptions(): array
|
private static function snapshotStateOptions(): array
|
||||||
{
|
{
|
||||||
return BadgeCatalog::options(BadgeDomain::BaselineSnapshotLifecycle, BaselineSnapshotLifecycleState::values());
|
return [
|
||||||
|
'complete' => 'Complete',
|
||||||
|
'with_gaps' => 'Captured with gaps',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resolveWorkspace(): ?Workspace
|
public static function resolveWorkspace(): ?Workspace
|
||||||
@ -318,13 +290,7 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
|||||||
{
|
{
|
||||||
$counts = self::fidelityCounts($snapshot);
|
$counts = self::fidelityCounts($snapshot);
|
||||||
|
|
||||||
return sprintf(
|
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||||
'%s %d, %s %d',
|
|
||||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
|
||||||
(int) ($counts['content'] ?? 0),
|
|
||||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
|
||||||
(int) ($counts['meta'] ?? 0),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||||
@ -332,17 +298,6 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
|||||||
$summary = self::summary($snapshot);
|
$summary = self::summary($snapshot);
|
||||||
$gaps = $summary['gaps'] ?? null;
|
$gaps = $summary['gaps'] ?? null;
|
||||||
$gaps = is_array($gaps) ? $gaps : [];
|
$gaps = is_array($gaps) ? $gaps : [];
|
||||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
|
||||||
|
|
||||||
if ($byReason !== []) {
|
|
||||||
return array_sum(array_map(
|
|
||||||
static fn (mixed $count, string $reason): int => in_array($reason, ['meta_fallback'], true) || ! is_numeric($count)
|
|
||||||
? 0
|
|
||||||
: (int) $count,
|
|
||||||
$byReason,
|
|
||||||
array_keys($byReason),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = $gaps['count'] ?? 0;
|
$count = $gaps['count'] ?? 0;
|
||||||
|
|
||||||
@ -354,86 +309,32 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
|||||||
return self::gapsCount($snapshot) > 0;
|
return self::gapsCount($snapshot) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function lifecycleSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
private static function stateLabel(BaselineSnapshot $snapshot): string
|
||||||
{
|
{
|
||||||
return BadgeCatalog::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
return self::hasGaps($snapshot) ? 'Captured with gaps' : 'Complete';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function applyLifecycleFilter(Builder $query, mixed $value): Builder
|
private static function applySnapshotStateFilter(Builder $query, mixed $value): Builder
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->where('lifecycle_state', trim($value));
|
$gapCountExpression = self::gapCountExpression($query);
|
||||||
|
|
||||||
|
return match ($value) {
|
||||||
|
'complete' => $query->whereRaw("{$gapCountExpression} = 0"),
|
||||||
|
'with_gaps' => $query->whereRaw("{$gapCountExpression} > 0"),
|
||||||
|
default => $query,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapCountExpression(Builder $query): string
|
private static function gapCountExpression(Builder $query): string
|
||||||
{
|
{
|
||||||
return match ($query->getConnection()->getDriverName()) {
|
return match ($query->getConnection()->getDriverName()) {
|
||||||
'sqlite' => "MAX(0, COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0) - COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS INTEGER), 0))",
|
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
||||||
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::int, 0))",
|
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0)",
|
||||||
default => "GREATEST(0, COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS SIGNED), 0) - COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.by_reason.meta_fallback') AS SIGNED), 0))",
|
default => "COALESCE(CAST(JSON_EXTRACT(summary_jsonb, '$.gaps.count') AS UNSIGNED), 0)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function gapSpec(BaselineSnapshot $snapshot): \App\Support\Badges\BadgeSpec
|
|
||||||
{
|
|
||||||
return BadgeCatalog::spec(
|
|
||||||
BadgeDomain::BaselineSnapshotGapStatus,
|
|
||||||
self::hasGaps($snapshot) ? 'gaps_present' : 'clear',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function truthEnvelope(BaselineSnapshot $snapshot): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentTruthLabel(BaselineSnapshot $snapshot): string
|
|
||||||
{
|
|
||||||
return match (self::currentTruthState($snapshot)) {
|
|
||||||
'current' => 'Current baseline',
|
|
||||||
'historical' => 'Historical trace',
|
|
||||||
default => 'Not compare input',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentTruthDescription(BaselineSnapshot $snapshot): ?string
|
|
||||||
{
|
|
||||||
return match (self::currentTruthState($snapshot)) {
|
|
||||||
'current' => 'Compare resolves to this snapshot as the current baseline truth.',
|
|
||||||
'historical' => 'A newer complete snapshot is now the current baseline truth for this profile.',
|
|
||||||
default => self::truthEnvelope($snapshot)->primaryExplanation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentTruthColor(BaselineSnapshot $snapshot): string
|
|
||||||
{
|
|
||||||
return match (self::currentTruthState($snapshot)) {
|
|
||||||
'current' => 'success',
|
|
||||||
'historical' => 'gray',
|
|
||||||
default => 'warning',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentTruthIcon(BaselineSnapshot $snapshot): ?string
|
|
||||||
{
|
|
||||||
return match (self::currentTruthState($snapshot)) {
|
|
||||||
'current' => 'heroicon-m-check-badge',
|
|
||||||
'historical' => 'heroicon-m-clock',
|
|
||||||
default => 'heroicon-m-exclamation-triangle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function currentTruthState(BaselineSnapshot $snapshot): string
|
|
||||||
{
|
|
||||||
if (! $snapshot->isConsumable()) {
|
|
||||||
return 'unusable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(BaselineSnapshotTruthResolver::class)->isHistoricallySuperseded($snapshot)
|
|
||||||
? 'historical'
|
|
||||||
: 'current';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,6 @@ public function mount(int|string $record): void
|
|||||||
$snapshot = $this->getRecord();
|
$snapshot = $this->getRecord();
|
||||||
|
|
||||||
if ($snapshot instanceof BaselineSnapshot) {
|
if ($snapshot instanceof BaselineSnapshot) {
|
||||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
|
||||||
|
|
||||||
$relatedContext = app(RelatedNavigationResolver::class)
|
$relatedContext = app(RelatedNavigationResolver::class)
|
||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||||
|
|
||||||
|
|||||||
@ -1,676 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\EvidenceSnapshotItem;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Panel;
|
|
||||||
use Filament\Resources\Pages\PageRegistration;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EvidenceSnapshotResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static ?string $model = EvidenceSnapshot::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'evidence';
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Evidence';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 55;
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $record instanceof EvidenceSnapshot
|
|
||||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (EvidenceSnapshot $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Snapshot')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('operationRun.id')
|
|
||||||
->label('Operation run')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
||||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Summary')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value))->placeholder('—'),
|
|
||||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Evidence dimensions')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('items')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('dimension_key')->label('Dimension')
|
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
||||||
TextEntry::make('state')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
||||||
TextEntry::make('source_kind')->label('Source')
|
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
||||||
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
|
||||||
ViewEntry::make('summary_payload_highlights')
|
|
||||||
->label('Summary')
|
|
||||||
->view('filament.infolists.entries.evidence-dimension-summary')
|
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
ViewEntry::make('summary_payload_raw')
|
|
||||||
->label('Raw summary JSON')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(4),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (EvidenceSnapshot $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Missing->value)),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (EvidenceSnapshot $record): string => static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceSnapshotStatus, EvidenceSnapshotStatus::values())),
|
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
|
||||||
->options(BadgeCatalog::options(BadgeDomain::EvidenceCompleteness, EvidenceCompletenessState::values())),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_snapshot')
|
|
||||||
->label('View snapshot')
|
|
||||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('expire')
|
|
||||||
->label('Expire snapshot')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (EvidenceSnapshot $record): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
|
||||||
}),
|
|
||||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No evidence snapshots yet')
|
|
||||||
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
|
||||||
->emptyStateActions([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_first_snapshot')
|
|
||||||
->label('Create first snapshot')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(fn (): mixed => static::executeGeneration([])),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
|
||||||
'view' => new PageRegistration(
|
|
||||||
page: Pages\ViewEvidenceSnapshot::class,
|
|
||||||
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
|
||||||
->whereNumber('record')
|
|
||||||
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
|
||||||
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
|
||||||
{
|
|
||||||
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
|
||||||
|
|
||||||
return match ($item->dimension_key) {
|
|
||||||
'findings_summary' => static::findingsSummaryPresentation($payload),
|
|
||||||
'permission_posture' => static::permissionPosturePresentation($payload),
|
|
||||||
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
|
||||||
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
|
||||||
'operations_summary' => static::operationsSummaryPresentation($payload),
|
|
||||||
default => static::genericSummaryPresentation($payload),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function findingsSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$count = (int) ($payload['count'] ?? 0);
|
|
||||||
$openCount = (int) ($payload['open_count'] ?? 0);
|
|
||||||
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
|
||||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Findings', 'value' => (string) $count],
|
|
||||||
['label' => 'Open findings', 'value' => (string) $openCount],
|
|
||||||
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
|
||||||
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
|
||||||
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
|
||||||
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
|
||||||
],
|
|
||||||
'items' => collect($entries)
|
|
||||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function permissionPosturePresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
|
||||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
|
||||||
$postureScore = $payload['posture_score'] ?? null;
|
|
||||||
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
|
||||||
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
|
||||||
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
|
||||||
],
|
|
||||||
'items' => static::namedItemsFromArray(
|
|
||||||
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
|
||||||
'No missing permission details captured.'
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function entraAdminRolesPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$roleCount = (int) ($payload['role_count'] ?? 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Role count', 'value' => (string) $roleCount],
|
|
||||||
],
|
|
||||||
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function baselineDriftPosturePresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
|
||||||
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
|
||||||
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
|
||||||
],
|
|
||||||
'items' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function operationsSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
|
||||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
|
||||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
|
||||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
||||||
$actionSummary = $failedCount === 0 && $partialCount === 0
|
|
||||||
? 'No action needed.'
|
|
||||||
: sprintf('%d execution failures, %d need follow-up.', $failedCount, $partialCount);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d operations in the last 30 days. %s', $operationCount, $actionSummary),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
|
||||||
['label' => 'Execution failures', 'value' => (string) $failedCount],
|
|
||||||
['label' => 'Needs follow-up', 'value' => (string) $partialCount],
|
|
||||||
],
|
|
||||||
'items' => collect($entries)
|
|
||||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function genericSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$highlights = collect($payload)
|
|
||||||
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
|
||||||
->take(6)
|
|
||||||
->map(fn (mixed $value, string|int $key): array => [
|
|
||||||
'label' => Str::headline((string) $key),
|
|
||||||
'value' => static::stringifySummaryValue($value),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
|
||||||
'highlights' => $highlights,
|
|
||||||
'items' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
|
||||||
{
|
|
||||||
if (! is_array($items) || $items === []) {
|
|
||||||
return [$emptyFallback];
|
|
||||||
}
|
|
||||||
|
|
||||||
$labels = collect($items)
|
|
||||||
->map(function (mixed $item): ?string {
|
|
||||||
if (is_string($item)) {
|
|
||||||
return trim($item) !== '' ? $item : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($item)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
|
||||||
$value = $item[$key] ?? null;
|
|
||||||
|
|
||||||
if (is_string($value) && trim($value) !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $labels === [] ? [$emptyFallback] : $labels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function findingEntryLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$title = $entry['title'] ?? null;
|
|
||||||
$severity = $entry['severity'] ?? null;
|
|
||||||
$status = $entry['status'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($title) || trim($title) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [trim($title)];
|
|
||||||
|
|
||||||
if (is_string($severity) && trim($severity) !== '') {
|
|
||||||
$parts[] = Str::headline($severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($status) && trim($status) !== '') {
|
|
||||||
$parts[] = Str::headline($status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' · ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function operationEntryLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$type = $entry['type'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($type) || trim($type) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [static::operationTypeLabel($type)];
|
|
||||||
|
|
||||||
$stateLabel = static::operationEntryStateLabel($entry);
|
|
||||||
|
|
||||||
if ($stateLabel !== null) {
|
|
||||||
$parts[] = $stateLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' · ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
|
||||||
{
|
|
||||||
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function operationTypeLabel(string $type): string
|
|
||||||
{
|
|
||||||
$label = OperationCatalog::label($type);
|
|
||||||
|
|
||||||
return $label === 'Unknown operation' ? 'Operation' : $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function operationEntryStateLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
|
||||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
|
||||||
|
|
||||||
return match ($status) {
|
|
||||||
OperationRunStatus::Queued->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
||||||
OperationRunStatus::Running->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
||||||
OperationRunStatus::Completed->value => match ($outcome) {
|
|
||||||
OperationRunOutcome::Pending->value => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
||||||
OperationRunOutcome::Succeeded->value,
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value,
|
|
||||||
OperationRunOutcome::Blocked->value,
|
|
||||||
OperationRunOutcome::Failed->value,
|
|
||||||
OperationRunOutcome::Cancelled->value => static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome),
|
|
||||||
default => static::badgeLabel(BadgeDomain::OperationRunStatus, $status),
|
|
||||||
},
|
|
||||||
default => $outcome !== null ? static::badgeLabel(BadgeDomain::OperationRunOutcome, $outcome) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function evidenceCompletenessCountLabel(string $state): string
|
|
||||||
{
|
|
||||||
return BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $state)->label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function badgeLabel(BadgeDomain $domain, ?string $state): ?string
|
|
||||||
{
|
|
||||||
if ($state === null || trim($state) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = BadgeCatalog::spec($domain, $state)->label;
|
|
||||||
|
|
||||||
return $label === 'Unknown' ? null : $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function truthEnvelope(EvidenceSnapshot $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forEvidenceSnapshot($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$value === null => '—',
|
|
||||||
is_bool($value) => $value ? 'Yes' : 'No',
|
|
||||||
is_scalar($value) => (string) $value,
|
|
||||||
default => '—',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function executeGeneration(array $data): void
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
|
||||||
tenant: $tenant,
|
|
||||||
user: $user,
|
|
||||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $snapshot->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Snapshot already available')
|
|
||||||
->body('A matching active snapshot already exists. No new run was started.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_snapshot')
|
|
||||||
->label('View snapshot')
|
|
||||||
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Create snapshot queued')
|
|
||||||
->body('The snapshot is being generated in the background.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
|
|
||||||
class ListEvidenceSnapshots extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_snapshot')
|
|
||||||
->label('Create snapshot')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
|
|
||||||
->form([
|
|
||||||
Section::make('Snapshot options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('allow_stale')
|
|
||||||
->label('Allow stale dimensions')
|
|
||||||
->default(false),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewEvidenceSnapshot extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
|
||||||
Actions\Action::make('view_review_pack')
|
|
||||||
->label('View review pack')
|
|
||||||
->icon('heroicon-o-document-text')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$pack = $this->latestReviewPack();
|
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('refresh_snapshot')
|
|
||||||
->label('Refresh evidence')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Refresh evidence queued')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('expire_snapshot')
|
|
||||||
->label('Expire snapshot')
|
|
||||||
->icon('heroicon-o-x-circle')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
|
||||||
$this->refreshFormData(['status', 'expires_at']);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewPack(): ?ReviewPack
|
|
||||||
{
|
|
||||||
return $this->record->reviewPacks()
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,630 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\FindingExceptionEvidenceReference;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingExceptionResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static ?string $model = FindingException::class;
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Risk exceptions';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 60;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $record instanceof FindingException
|
|
||||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()
|
|
||||||
->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
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Exception')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
|
||||||
TextEntry::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
|
||||||
TextEntry::make('governance_warning')
|
|
||||||
->label('Governance warning')
|
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
|
||||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
|
||||||
->columnSpanFull()
|
|
||||||
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('finding_summary')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
|
||||||
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
|
|
||||||
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
|
|
||||||
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
|
|
||||||
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
|
|
||||||
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Decision history')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('decisions')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('decision_type')->label('Decision'),
|
|
||||||
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
|
|
||||||
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
]),
|
|
||||||
Section::make('Evidence references')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('evidenceReferences')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('label')->label('Label'),
|
|
||||||
TextEntry::make('source_type')->label('Source'),
|
|
||||||
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
|
|
||||||
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
|
|
||||||
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('summary_payload')
|
|
||||||
->label('Summary')
|
|
||||||
->state(function (FindingExceptionEvidenceReference $record): ?string {
|
|
||||||
if ($record->summary_payload === [] || $record->summary_payload === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
|
|
||||||
})
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
])
|
|
||||||
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('requested_at', 'desc')
|
|
||||||
->paginated(TablePaginationProfiles::resource())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->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')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
|
||||||
->searchable()
|
|
||||||
->wrap()
|
|
||||||
->limit(60),
|
|
||||||
Tables\Columns\TextColumn::make('governance_warning')
|
|
||||||
->label('Governance warning')
|
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
|
||||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('requester.name')
|
|
||||||
->label('Requested by')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('owner.name')
|
|
||||||
->label('Owner')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('review_due_at')
|
|
||||||
->label('Review due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->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')
|
|
||||||
->label('Requested')
|
|
||||||
->dateTime()
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
|
||||||
SelectFilter::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->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([
|
|
||||||
Action::make('renew_exception')
|
|
||||||
->label('Renew exception')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Select::make('owner_user_id')
|
|
||||||
->label('Owner')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => static::tenantMemberOptions())
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Renewal reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Requested expiry')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->defaultItems(0)
|
|
||||||
->collapsed(),
|
|
||||||
])
|
|
||||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->renew($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request submitted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('revoke_exception')
|
|
||||||
->label('Revoke exception')
|
|
||||||
->icon('heroicon-o-no-symbol')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('revocation_reason')
|
|
||||||
->label('Revocation reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->revoke($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revocation failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revoked')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No exceptions match this view')
|
|
||||||
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
|
|
||||||
->emptyStateIcon('heroicon-o-shield-exclamation')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('open_findings')
|
|
||||||
->label('Open findings')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => FindingResource::getUrl('index')),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListFindingExceptions::route('/'),
|
|
||||||
'view' => Pages\ViewFindingException::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string|array<int|string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function relationshipsForView(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'tenant',
|
|
||||||
'requester',
|
|
||||||
'owner',
|
|
||||||
'approver',
|
|
||||||
'currentDecision',
|
|
||||||
'decisions.actor',
|
|
||||||
'evidenceReferences',
|
|
||||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private static function tenantMemberOptions(): array
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
|
||||||
->pluck('users.name', 'users.id')
|
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function findingSummary(FindingException $record): string
|
|
||||||
{
|
|
||||||
$finding = $record->finding;
|
|
||||||
|
|
||||||
if (! $finding instanceof \App\Models\Finding) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User
|
|
||||||
&& $record->tenant instanceof Tenant
|
|
||||||
&& $user->canAccessTenant($record->tenant)
|
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static 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 static function governanceWarningColor(FindingException $record): string
|
|
||||||
{
|
|
||||||
$finding = $record->relationLoaded('finding')
|
|
||||||
? $record->finding
|
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
|
||||||
|
|
||||||
if ((string) $record->current_validity_state === FindingException::VALIDITY_EXPIRING) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Filament\Widgets\Tenant\FindingExceptionStatsOverview;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class ListFindingExceptions extends ListRecords
|
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('open_findings')
|
|
||||||
->label('Open findings')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->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()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,225 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class ViewFindingException extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return FindingExceptionResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('open_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
|
||||||
->label('Renew exception')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
|
||||||
->fillForm(fn (): array => [
|
|
||||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
|
||||||
])
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Select::make('owner_user_id')
|
|
||||||
->label('Owner')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Renewal reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Requested expiry')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->defaultItems(0)
|
|
||||||
->collapsed(),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->renew($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request submitted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
|
||||||
}),
|
|
||||||
Action::make('revoke_exception')
|
|
||||||
->label('Revoke exception')
|
|
||||||
->icon('heroicon-o-no-symbol')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('revocation_reason')
|
|
||||||
->label('Revocation reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->revoke($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revocation failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revoked')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function tenantMemberOptions(): array
|
|
||||||
{
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
|
||||||
->pluck('users.name', 'users.id')
|
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageRecord(): bool
|
|
||||||
{
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
&& $record->tenant instanceof Tenant
|
|
||||||
&& $user instanceof User
|
|
||||||
&& $user->canAccessTenant($record->tenant)
|
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,14 +6,11 @@
|
|||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -38,8 +35,6 @@
|
|||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -64,11 +59,6 @@ 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';
|
||||||
@ -151,65 +141,6 @@ 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'),
|
||||||
@ -292,62 +223,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Risk governance')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('finding_governance_status')
|
|
||||||
->label('Exception status')
|
|
||||||
->badge()
|
|
||||||
->state(fn (Finding $record): ?string => $record->findingException?->status)
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('finding_governance_validity')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->state(function (Finding $record): ?string {
|
|
||||||
if ($record->findingException instanceof FindingException) {
|
|
||||||
return $record->findingException->current_validity_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $record->status === Finding::STATUS_RISK_ACCEPTED
|
|
||||||
? FindingException::VALIDITY_MISSING_SUPPORT
|
|
||||||
: null;
|
|
||||||
})
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('finding_governance_warning')
|
|
||||||
->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_governance_owner')
|
|
||||||
->label('Exception owner')
|
|
||||||
->state(fn (Finding $record): ?string => $record->findingException?->owner?->name)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('finding_governance_approver')
|
|
||||||
->label('Approver')
|
|
||||||
->state(fn (Finding $record): ?string => $record->findingException?->approver?->name)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('finding_governance_review_due')
|
|
||||||
->label('Review due')
|
|
||||||
->state(fn (Finding $record): mixed => $record->findingException?->review_due_at)
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('finding_governance_expires')
|
|
||||||
->label('Expires')
|
|
||||||
->state(fn (Finding $record): mixed => $record->findingException?->expires_at)
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->visible(fn (Finding $record): bool => $record->findingException instanceof FindingException || (string) $record->status === Finding::STATUS_RISK_ACCEPTED),
|
|
||||||
|
|
||||||
Section::make('Evidence')
|
Section::make('Evidence')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('redaction_integrity_note')
|
TextEntry::make('redaction_integrity_note')
|
||||||
@ -692,45 +567,19 @@ public static function table(Table $table): Table
|
|||||||
->persistSearchInSession()
|
->persistSearchInSession()
|
||||||
->persistSortInSession()
|
->persistSortInSession()
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('finding_type')
|
Tables\Columns\TextColumn::make('finding_type')->badge()->label('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)),
|
||||||
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
Tables\Columns\TextColumn::make('severity')
|
||||||
Tables\Columns\TextColumn::make('governance_validity')
|
|
||||||
->label('Governance')
|
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (Finding $record): ?string => static::governanceValidityState($record))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
||||||
->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()
|
||||||
@ -742,6 +591,12 @@ 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()
|
||||||
@ -751,19 +606,18 @@ 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('Active workflow')
|
->label('Open')
|
||||||
|
->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')
|
||||||
@ -791,45 +645,6 @@ 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',
|
||||||
@ -884,7 +699,9 @@ 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::getUrl('view', ['record' => $record]))
|
->recordUrl(static fn (Finding $record): ?string => static::canView($record)
|
||||||
|
? static::getUrl('view', ['record' => $record])
|
||||||
|
: null)
|
||||||
->actions([
|
->actions([
|
||||||
static::primaryRelatedAction(),
|
static::primaryRelatedAction(),
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
@ -1197,17 +1014,91 @@ public static function table(Table $table): Table
|
|||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|
||||||
|
UiEnforcement::forBulkAction(
|
||||||
|
BulkAction::make('risk_accept_selected')
|
||||||
|
->label('Risk accept selected')
|
||||||
|
->icon('heroicon-o-shield-check')
|
||||||
|
->color('warning')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->form([
|
||||||
|
Textarea::make('closed_reason')
|
||||||
|
->label('Risk acceptance reason')
|
||||||
|
->rows(3)
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reason = (string) ($data['closed_reason'] ?? '');
|
||||||
|
|
||||||
|
$acceptedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $record->hasOpenStatus()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||||
|
$workflow->riskAccept($record, $tenant, $user, $reason);
|
||||||
|
$acceptedCount++;
|
||||||
|
} catch (Throwable) {
|
||||||
|
$failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Risk accepted {$acceptedCount} finding".($acceptedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$body .= " Failed {$failedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk risk accept completed')
|
||||||
|
->body($body)
|
||||||
|
->status($failedCount > 0 ? 'warning' : 'success')
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||||
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
|
->apply(),
|
||||||
])->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 findings and governance follow-up.')
|
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.')
|
||||||
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
->emptyStateIcon('heroicon-o-exclamation-triangle');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
return static::getTenantOwnedEloquentQuery()
|
||||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||||
->withSubjectDisplayName();
|
->withSubjectDisplayName();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1216,7 +1107,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
|
|||||||
return static::resolveTenantOwnedRecordOrFail(
|
return static::resolveTenantOwnedRecordOrFail(
|
||||||
$key,
|
$key,
|
||||||
parent::getEloquentQuery()
|
parent::getEloquentQuery()
|
||||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||||
->withSubjectDisplayName(),
|
->withSubjectDisplayName(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1253,15 +1144,7 @@ private static function primaryRelatedAction(): Actions\Action
|
|||||||
|
|
||||||
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
private static function primaryRelatedEntry(Finding $record): ?RelatedContextEntry
|
||||||
{
|
{
|
||||||
$cacheKey = is_numeric($record->getKey())
|
return app(RelatedNavigationResolver::class)
|
||||||
? (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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,9 +1185,7 @@ public static function workflowActions(): array
|
|||||||
static::assignAction(),
|
static::assignAction(),
|
||||||
static::resolveAction(),
|
static::resolveAction(),
|
||||||
static::closeAction(),
|
static::closeAction(),
|
||||||
static::requestExceptionAction(),
|
static::riskAcceptAction(),
|
||||||
static::renewExceptionAction(),
|
|
||||||
static::revokeExceptionAction(),
|
|
||||||
static::reopenAction(),
|
static::reopenAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1316,7 +1197,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((string) $record->status, [
|
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
@ -1342,7 +1223,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((string) $record->status, [
|
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
@ -1367,7 +1248,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 => $record->hasOpenStatus())
|
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($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,
|
||||||
@ -1411,7 +1292,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 => $record->hasOpenStatus())
|
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
@ -1446,7 +1327,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 => $record->hasOpenStatus())
|
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
@ -1474,153 +1355,37 @@ public static function closeAction(): Actions\Action
|
|||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function requestExceptionAction(): Actions\Action
|
public static function riskAcceptAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('request_exception')
|
Actions\Action::make('risk_accept')
|
||||||
->label('Request exception')
|
->label('Risk accept')
|
||||||
->icon('heroicon-o-shield-exclamation')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Select::make('owner_user_id')
|
Textarea::make('closed_reason')
|
||||||
->label('Owner')
|
->label('Risk acceptance reason')
|
||||||
->required()
|
->rows(3)
|
||||||
->options(fn (): array => static::tenantMemberOptions())
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Request reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Expires at')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
])
|
||||||
->defaultItems(0)
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
->collapsed(),
|
static::runWorkflowMutation(
|
||||||
])
|
record: $record,
|
||||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
successTitle: 'Finding marked as risk accepted',
|
||||||
static::runExceptionRequestMutation($record, $data, $service);
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
(string) ($data['closed_reason'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function renewExceptionAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('renew_exception')
|
|
||||||
->label('Renew exception')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRenewed() ?? false)
|
|
||||||
->fillForm(fn (Finding $record): array => [
|
|
||||||
'owner_user_id' => static::loadedFindingException($record)?->owner_user_id,
|
|
||||||
])
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Select::make('owner_user_id')
|
|
||||||
->label('Owner')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => static::tenantMemberOptions())
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Renewal reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Requested expiry')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->defaultItems(0)
|
|
||||||
->collapsed(),
|
|
||||||
])
|
|
||||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
|
||||||
static::runExceptionRenewalMutation($record, $data, $service);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function revokeExceptionAction(): Actions\Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('revoke_exception')
|
|
||||||
->label('Revoke exception')
|
|
||||||
->icon('heroicon-o-no-symbol')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (Finding $record): bool => static::loadedFindingException($record)?->canBeRevoked() ?? false)
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('revocation_reason')
|
|
||||||
->label('Revocation reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
|
||||||
static::runExceptionRevocationMutation($record, $data, $service);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
@ -1633,7 +1398,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((string) $record->status))
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
@ -1697,112 +1462,6 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
private static function runExceptionRequestMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
||||||
{
|
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$createdException = $service->request($record, $tenant, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception request submitted')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_exception')
|
|
||||||
->label('View exception')
|
|
||||||
->url(static::findingExceptionViewUrl($createdException, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
private static function runExceptionRenewalMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$renewedException = $service->renew(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request submitted')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_exception')
|
|
||||||
->label('View exception')
|
|
||||||
->url(static::findingExceptionViewUrl($renewedException, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
private static function runExceptionRevocationMutation(Finding $record, array $data, FindingExceptionService $service): void
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$revokedException = $service->revoke(static::resolveCurrentFindingExceptionOrFail($record), $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revocation failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revoked')
|
|
||||||
->success()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_exception')
|
|
||||||
->label('View exception')
|
|
||||||
->url(static::findingExceptionViewUrl($revokedException, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function freshWorkflowRecord(Finding $record): Finding
|
private static function freshWorkflowRecord(Finding $record): Finding
|
||||||
{
|
{
|
||||||
return static::resolveProtectedFindingRecordOrFail($record);
|
return static::resolveProtectedFindingRecordOrFail($record);
|
||||||
@ -1824,136 +1483,6 @@ private static function resolveProtectedFindingRecordOrFail(Finding|int|string $
|
|||||||
return $resolvedRecord;
|
return $resolvedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function currentFindingException(Finding $record): ?FindingException
|
|
||||||
{
|
|
||||||
$finding = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
$exception = $finding->relationLoaded('findingException')
|
|
||||||
? $finding->findingException
|
|
||||||
: $finding->findingException()->with('currentDecision')->first();
|
|
||||||
|
|
||||||
if (! $exception instanceof FindingException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exception->loadMissing('currentDecision');
|
|
||||||
|
|
||||||
return $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveCurrentFindingExceptionOrFail(Finding $record): FindingException
|
|
||||||
{
|
|
||||||
$exception = static::currentFindingException($record);
|
|
||||||
|
|
||||||
if (! $exception instanceof FindingException) {
|
|
||||||
throw new InvalidArgumentException('This finding does not have an exception to manage.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function findingExceptionViewUrl(\App\Models\FindingException $exception, Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$panelId = Filament::getCurrentPanel()?->getId();
|
|
||||||
|
|
||||||
if ($panelId === 'admin') {
|
|
||||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'tenant', tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function governanceWarning(Finding $finding): ?string
|
|
||||||
{
|
|
||||||
return app(FindingRiskGovernanceResolver::class)
|
|
||||||
->resolveWarningMessage($finding, static::resolvedFindingException($finding));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function governanceWarningColor(Finding $finding): string
|
|
||||||
{
|
|
||||||
$exception = static::resolvedFindingException($finding);
|
|
||||||
|
|
||||||
if (static::governanceValidityState($finding) === FindingException::VALIDITY_EXPIRING) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
*/
|
*/
|
||||||
@ -1973,35 +1502,4 @@ 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,7 +5,6 @@
|
|||||||
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;
|
||||||
@ -23,7 +22,6 @@
|
|||||||
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;
|
||||||
@ -41,7 +39,7 @@ class ListFindings extends ListRecords
|
|||||||
*/
|
*/
|
||||||
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
|
||||||
{
|
{
|
||||||
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'request_exception', 'reopen'], true)) {
|
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['triage', 'start_progress', 'assign', 'resolve', 'close', 'risk_accept', 'reopen'], true)) {
|
||||||
try {
|
try {
|
||||||
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||||
} catch (ModelNotFoundException) {
|
} catch (ModelNotFoundException) {
|
||||||
@ -63,42 +61,6 @@ 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,9 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -32,23 +30,6 @@ 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')
|
||||||
|
|||||||
@ -8,10 +8,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\VerificationCheckAcknowledgement;
|
use App\Models\VerificationCheckAcknowledgement;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\FilterPresets;
|
use App\Support\Filament\FilterPresets;
|
||||||
@ -23,19 +21,13 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -131,11 +123,10 @@ public static function table(Table $table): Table
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Operation')
|
->label('Operation')
|
||||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||||
@ -158,11 +149,10 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('outcome')
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\SelectFilter::make('tenant_id')
|
Tables\Filters\SelectFilter::make('tenant_id')
|
||||||
@ -215,9 +205,13 @@ public static function table(Table $table): Table
|
|||||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
return FilterOptionCatalog::operationTypes(array_keys($types));
|
||||||
}),
|
}),
|
||||||
Tables\Filters\SelectFilter::make('status')
|
Tables\Filters\SelectFilter::make('status')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
|
->options([
|
||||||
|
OperationRunStatus::Queued->value => 'Queued',
|
||||||
|
OperationRunStatus::Running->value => 'Running',
|
||||||
|
OperationRunStatus::Completed->value => 'Completed',
|
||||||
|
]),
|
||||||
Tables\Filters\SelectFilter::make('outcome')
|
Tables\Filters\SelectFilter::make('outcome')
|
||||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
|
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
||||||
Tables\Filters\SelectFilter::make('initiator_name')
|
Tables\Filters\SelectFilter::make('initiator_name')
|
||||||
->label('Initiator')
|
->label('Initiator')
|
||||||
->options(function (): array {
|
->options(function (): array {
|
||||||
@ -257,25 +251,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
{
|
{
|
||||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||||
|
|
||||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||||
$targetScope = static::targetScopeDisplay($record) ?? 'No target scope details were recorded for this run.';
|
$targetScope = static::targetScopeDisplay($record);
|
||||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||||
: null;
|
: null;
|
||||||
$artifactTruth = $record->supportsOperatorExplanation()
|
|
||||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
|
||||||
: null;
|
|
||||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
|
||||||
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
|
|
||||||
$supportingGroups = static::supportingGroups(
|
|
||||||
record: $record,
|
|
||||||
factory: $factory,
|
|
||||||
referencedTenantLifecycle: $referencedTenantLifecycle,
|
|
||||||
operatorExplanation: $operatorExplanation,
|
|
||||||
primaryNextStep: $primaryNextStep,
|
|
||||||
);
|
|
||||||
|
|
||||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||||
@ -286,59 +268,25 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||||
],
|
],
|
||||||
keyFacts: [
|
keyFacts: [
|
||||||
$factory->keyFact('Target', $targetScope),
|
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||||
|
$factory->keyFact('Initiator', $record->initiator_name),
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
|
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
||||||
],
|
],
|
||||||
descriptionHint: 'Decision guidance and high-signal context stay ahead of diagnostic payloads and raw JSON.',
|
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
||||||
))
|
))
|
||||||
->decisionZone($factory->decisionZone(
|
->addSection(
|
||||||
facts: array_values(array_filter([
|
$factory->factsSection(
|
||||||
$factory->keyFact(
|
id: 'run_summary',
|
||||||
'Execution state',
|
kind: 'core_details',
|
||||||
$statusSpec->label,
|
title: 'Run summary',
|
||||||
badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
items: [
|
||||||
|
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
||||||
|
$factory->keyFact('Initiator', $record->initiator_name),
|
||||||
|
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||||
|
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
$factory->keyFact(
|
|
||||||
'Outcome',
|
|
||||||
$outcomeSpec->label,
|
|
||||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
|
||||||
),
|
|
||||||
static::artifactTruthFact($factory, $artifactTruth),
|
|
||||||
$operatorExplanation instanceof OperatorExplanationPattern
|
|
||||||
? $factory->keyFact(
|
|
||||||
'Result meaning',
|
|
||||||
$operatorExplanation->evaluationResultLabel(),
|
|
||||||
$operatorExplanation->headline,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
$operatorExplanation instanceof OperatorExplanationPattern
|
|
||||||
? $factory->keyFact(
|
|
||||||
'Result trust',
|
|
||||||
$operatorExplanation->trustworthinessLabel(),
|
|
||||||
static::detailHintUnlessDuplicate(
|
|
||||||
$operatorExplanation->reliabilityStatement,
|
|
||||||
$artifactTruth?->primaryExplanation,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
])),
|
|
||||||
primaryNextStep: $factory->primaryNextStep(
|
|
||||||
$primaryNextStep['text'],
|
|
||||||
$primaryNextStep['source'],
|
|
||||||
$primaryNextStep['secondaryGuidance'],
|
|
||||||
),
|
|
||||||
description: 'Start here to see how the run ended, whether the result is trustworthy enough to use, and the one primary next step.',
|
|
||||||
compactCounts: $summaryLine !== null
|
|
||||||
? $factory->countPresentation(summaryLine: $summaryLine)
|
|
||||||
: null,
|
|
||||||
attentionNote: static::decisionAttentionNote($record),
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($supportingGroups !== []) {
|
|
||||||
$builder->addSupportingGroup(...$supportingGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'related_context',
|
id: 'related_context',
|
||||||
kind: 'related_context',
|
kind: 'related_context',
|
||||||
@ -348,216 +296,14 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
||||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
)
|
||||||
id: 'artifact_truth',
|
->addSupportingCard(
|
||||||
kind: 'supporting_detail',
|
$factory->supportingFactsCard(
|
||||||
title: 'Artifact truth details',
|
kind: 'status',
|
||||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
title: 'Current state',
|
||||||
viewData: [
|
items: array_values(array_filter([
|
||||||
'artifactTruthState' => $artifactTruth?->toArray(),
|
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||||
'surface' => 'expanded',
|
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||||
],
|
|
||||||
visible: $artifactTruth !== null,
|
|
||||||
description: 'Detailed artifact-truth context explains evidence quality and caveats without repeating the top decision summary.',
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$counts = static::summaryCountFacts($record, $factory);
|
|
||||||
|
|
||||||
if ($counts !== []) {
|
|
||||||
$builder->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Count diagnostics',
|
|
||||||
entries: $counts,
|
|
||||||
description: 'Normalized run counters remain available for deeper inspection without competing with the primary decision.',
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
variant: 'diagnostic',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($record->failure_summary)) {
|
|
||||||
$builder->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
|
||||||
description: 'Detailed failure evidence stays available for investigation after the decision and supporting context.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $record->failure_summary ?? []],
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (static::reconciliationPayload($record) !== []) {
|
|
||||||
$builder->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Lifecycle reconciliation',
|
|
||||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_compare') {
|
|
||||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
|
||||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
|
||||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
|
||||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
|
||||||
$gapBuckets = is_array($gapDetails['buckets'] ?? null) ? $gapDetails['buckets'] : [];
|
|
||||||
|
|
||||||
if ($baselineCompareFacts !== []) {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->factsSection(
|
|
||||||
id: 'baseline_compare',
|
|
||||||
kind: 'type_specific_detail',
|
|
||||||
title: 'Baseline compare',
|
|
||||||
items: $baselineCompareFacts,
|
|
||||||
description: 'Type-specific comparison detail stays below the canonical decision and supporting layers.',
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'baseline_compare_gap_details',
|
|
||||||
kind: 'type_specific_detail',
|
|
||||||
title: 'Evidence gap details',
|
|
||||||
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.',
|
|
||||||
view: 'filament.infolists.entries.evidence-gap-subjects',
|
|
||||||
viewData: [
|
|
||||||
'summary' => $gapSummary,
|
|
||||||
'buckets' => $gapBuckets,
|
|
||||||
'searchId' => 'baseline-compare-gap-search-'.$record->getKey(),
|
|
||||||
],
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($baselineCompareEvidence !== []) {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'baseline_compare_evidence',
|
|
||||||
kind: 'type_specific_detail',
|
|
||||||
title: 'Baseline compare evidence',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $baselineCompareEvidence],
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $record->type === 'baseline_capture') {
|
|
||||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
|
||||||
|
|
||||||
if ($baselineCaptureEvidence !== []) {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'baseline_capture_evidence',
|
|
||||||
kind: 'type_specific_detail',
|
|
||||||
title: 'Baseline capture evidence',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => $baselineCaptureEvidence],
|
|
||||||
collapsible: true,
|
|
||||||
collapsed: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
|
||||||
$builder->addSection(
|
|
||||||
$factory->viewSection(
|
|
||||||
id: 'verification_report',
|
|
||||||
kind: 'type_specific_detail',
|
|
||||||
title: 'Verification report',
|
|
||||||
view: 'filament.components.verification-report-viewer',
|
|
||||||
viewData: static::verificationReportViewData($record),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$builder->addTechnicalSection(
|
|
||||||
$factory->technicalDetail(
|
|
||||||
title: 'Context',
|
|
||||||
entries: [
|
|
||||||
$factory->keyFact('Identity hash', $record->run_identity_hash, mono: true),
|
|
||||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
|
||||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
|
||||||
],
|
|
||||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
|
||||||
viewData: ['payload' => static::contextPayload($record)],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $builder->build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<\App\Support\Ui\EnterpriseDetail\SupportingCardData>
|
|
||||||
*/
|
|
||||||
private static function supportingGroups(
|
|
||||||
OperationRun $record,
|
|
||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
|
||||||
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
|
|
||||||
?OperatorExplanationPattern $operatorExplanation,
|
|
||||||
array $primaryNextStep,
|
|
||||||
): array {
|
|
||||||
$groups = [];
|
|
||||||
$hasElevatedLifecycleState = OperationUxPresenter::lifecycleAttentionSummary($record) !== null;
|
|
||||||
|
|
||||||
$guidanceItems = array_values(array_filter([
|
|
||||||
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
|
|
||||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
|
||||||
: null,
|
|
||||||
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->diagnosticsSummary !== null
|
|
||||||
? $factory->keyFact('Diagnostics summary', $operatorExplanation->diagnosticsSummary)
|
|
||||||
: null,
|
|
||||||
...array_map(
|
|
||||||
static fn (array $guidance): array => $factory->keyFact($guidance['label'], $guidance['text']),
|
|
||||||
array_values(array_filter(
|
|
||||||
$primaryNextStep['secondaryGuidance'] ?? [],
|
|
||||||
static fn (mixed $guidance): bool => is_array($guidance),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
static::blockedExecutionReasonCode($record) !== null
|
|
||||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionDetail($record) !== null
|
|
||||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionSource($record) !== null
|
|
||||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
|
||||||
: null,
|
|
||||||
RunDurationInsights::stuckGuidance($record) !== null
|
|
||||||
? $factory->keyFact('Queue guidance', RunDurationInsights::stuckGuidance($record))
|
|
||||||
: null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
if ($guidanceItems !== []) {
|
|
||||||
$groups[] = $factory->supportingFactsCard(
|
|
||||||
kind: 'guidance',
|
|
||||||
title: 'Guidance',
|
|
||||||
items: $guidanceItems,
|
|
||||||
description: 'Secondary guidance explains caveats and context without competing with the primary next step.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$lifecycleItems = array_values(array_filter([
|
|
||||||
$referencedTenantLifecycle !== null
|
$referencedTenantLifecycle !== null
|
||||||
? $factory->keyFact(
|
? $factory->keyFact(
|
||||||
'Tenant lifecycle',
|
'Tenant lifecycle',
|
||||||
@ -576,233 +322,126 @@ private static function supportingGroups(
|
|||||||
$referencedTenantLifecycle?->contextNote !== null
|
$referencedTenantLifecycle?->contextNote !== null
|
||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
: null,
|
: null,
|
||||||
! $hasElevatedLifecycleState && static::freshnessLabel($record) !== null
|
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
static::blockedExecutionReasonCode($record) !== null
|
||||||
|
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||||
: null,
|
: null,
|
||||||
! $hasElevatedLifecycleState && static::reconciliationHeadline($record) !== null
|
static::blockedExecutionDetail($record) !== null
|
||||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
||||||
: null,
|
: null,
|
||||||
static::reconciledAtLabel($record) !== null
|
static::blockedExecutionSource($record) !== null
|
||||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
||||||
: null,
|
: null,
|
||||||
static::reconciliationSourceLabel($record) !== null
|
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
||||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
])),
|
||||||
: null,
|
),
|
||||||
]));
|
$factory->supportingFactsCard(
|
||||||
|
kind: 'timestamps',
|
||||||
if ($lifecycleItems !== []) {
|
title: 'Timing',
|
||||||
$groups[] = $factory->supportingFactsCard(
|
items: [
|
||||||
kind: 'lifecycle',
|
|
||||||
title: 'Lifecycle',
|
|
||||||
items: $lifecycleItems,
|
|
||||||
description: 'Lifecycle context explains freshness, reconciliation, and tenant-scoped caveats.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$timingItems = [
|
|
||||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||||
];
|
|
||||||
|
|
||||||
$groups[] = $factory->supportingFactsCard(
|
|
||||||
kind: 'timing',
|
|
||||||
title: 'Timing',
|
|
||||||
items: $timingItems,
|
|
||||||
);
|
|
||||||
|
|
||||||
$metadataItems = array_values(array_filter([
|
|
||||||
$factory->keyFact('Initiator', $record->initiator_name),
|
|
||||||
RunDurationInsights::expectedHuman($record) !== null
|
|
||||||
? $factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record))
|
|
||||||
: null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
if ($metadataItems !== []) {
|
|
||||||
$groups[] = $factory->supportingFactsCard(
|
|
||||||
kind: 'metadata',
|
|
||||||
title: 'Metadata',
|
|
||||||
items: $metadataItems,
|
|
||||||
description: 'Secondary metadata remains visible without crowding the top decision surface.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* text: string,
|
|
||||||
* source: string,
|
|
||||||
* secondaryGuidance: list<array{label: string, text: string, source: string}>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private static function resolvePrimaryNextStep(
|
|
||||||
OperationRun $record,
|
|
||||||
?ArtifactTruthEnvelope $artifactTruth,
|
|
||||||
?OperatorExplanationPattern $operatorExplanation,
|
|
||||||
): array {
|
|
||||||
$candidates = [];
|
|
||||||
|
|
||||||
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
|
|
||||||
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
|
|
||||||
|
|
||||||
$opsUxSource = match (true) {
|
|
||||||
(string) $record->outcome === OperationRunOutcome::Blocked->value => 'blocked_reason',
|
|
||||||
OperationUxPresenter::lifecycleAttentionSummary($record) !== null => 'lifecycle_attention',
|
|
||||||
default => 'ops_ux',
|
|
||||||
};
|
|
||||||
|
|
||||||
static::pushNextStepCandidate($candidates, OperationUxPresenter::surfaceGuidance($record), $opsUxSource);
|
|
||||||
|
|
||||||
if ($candidates === []) {
|
|
||||||
return [
|
|
||||||
'text' => 'No action needed.',
|
|
||||||
'source' => 'none_required',
|
|
||||||
'secondaryGuidance' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$primary = $candidates[0];
|
|
||||||
$primarySource = static::normalizeGuidance($primary['text']) === 'no action needed'
|
|
||||||
? 'none_required'
|
|
||||||
: $primary['source'];
|
|
||||||
|
|
||||||
$secondaryGuidance = array_map(
|
|
||||||
static fn (array $candidate): array => [
|
|
||||||
'label' => static::guidanceLabel($candidate['source']),
|
|
||||||
'text' => $candidate['text'],
|
|
||||||
'source' => $candidate['source'],
|
|
||||||
],
|
],
|
||||||
array_slice($candidates, 1),
|
),
|
||||||
|
)
|
||||||
|
->addTechnicalSection(
|
||||||
|
$factory->technicalDetail(
|
||||||
|
title: 'Context',
|
||||||
|
entries: [
|
||||||
|
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
||||||
|
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||||
|
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||||
|
],
|
||||||
|
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => static::contextPayload($record)],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
$counts = static::summaryCountFacts($record, $factory);
|
||||||
'text' => $primary['text'],
|
|
||||||
'source' => $primarySource,
|
|
||||||
'secondaryGuidance' => $secondaryGuidance,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if ($counts !== []) {
|
||||||
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
|
$builder->addSection(
|
||||||
*/
|
$factory->factsSection(
|
||||||
private static function pushNextStepCandidate(array &$candidates, ?string $text, string $source): void
|
id: 'counts',
|
||||||
{
|
kind: 'current_status',
|
||||||
$formattedText = static::formatGuidanceText($text);
|
title: 'Counts',
|
||||||
|
items: $counts,
|
||||||
if ($formattedText === null) {
|
),
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = static::normalizeGuidance($formattedText);
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (($candidate['normalized'] ?? null) === $normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates[] = [
|
|
||||||
'text' => $formattedText,
|
|
||||||
'source' => $source,
|
|
||||||
'normalized' => $normalized,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function formatGuidanceText(?string $text): ?string
|
|
||||||
{
|
|
||||||
if (! is_string($text)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = trim($text);
|
|
||||||
|
|
||||||
if ($text === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/[.!?]$/', $text) === 1) {
|
|
||||||
return $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $text.'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalizeGuidance(string $text): string
|
|
||||||
{
|
|
||||||
$normalized = mb_strtolower(trim($text));
|
|
||||||
$normalized = preg_replace('/^next step:\s*/', '', $normalized) ?? $normalized;
|
|
||||||
|
|
||||||
return trim($normalized, " \t\n\r\0\x0B.!?");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function guidanceLabel(string $source): string
|
|
||||||
{
|
|
||||||
return match ($source) {
|
|
||||||
'operator_explanation' => 'Operator guidance',
|
|
||||||
'artifact_truth' => 'Artifact guidance',
|
|
||||||
'blocked_reason' => 'Blocked prerequisite',
|
|
||||||
'lifecycle_attention' => 'Lifecycle guidance',
|
|
||||||
default => 'General guidance',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private static function artifactTruthFact(
|
|
||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
|
||||||
?ArtifactTruthEnvelope $artifactTruth,
|
|
||||||
): ?array {
|
|
||||||
if (! $artifactTruth instanceof ArtifactTruthEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$badge = $artifactTruth->primaryBadgeSpec();
|
|
||||||
|
|
||||||
return $factory->keyFact(
|
|
||||||
'Artifact truth',
|
|
||||||
$artifactTruth->primaryLabel,
|
|
||||||
$artifactTruth->primaryExplanation,
|
|
||||||
$factory->statusBadge($badge->label, $badge->color, $badge->icon, $badge->iconColor),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function decisionAttentionNote(OperationRun $record): ?string
|
if (! empty($record->failure_summary)) {
|
||||||
{
|
$builder->addSection(
|
||||||
return null;
|
$factory->viewSection(
|
||||||
|
id: 'failures',
|
||||||
|
kind: 'operational_context',
|
||||||
|
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $record->failure_summary ?? []],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function detailHintUnlessDuplicate(?string $hint, ?string $duplicateOf): ?string
|
if ((string) $record->type === 'baseline_compare') {
|
||||||
{
|
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||||
$normalizedHint = static::normalizeDetailText($hint);
|
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||||
|
|
||||||
if ($normalizedHint === null) {
|
if ($baselineCompareFacts !== []) {
|
||||||
return null;
|
$builder->addSection(
|
||||||
|
$factory->factsSection(
|
||||||
|
id: 'baseline_compare',
|
||||||
|
kind: 'operational_context',
|
||||||
|
title: 'Baseline compare',
|
||||||
|
items: $baselineCompareFacts,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($normalizedHint === static::normalizeDetailText($duplicateOf)) {
|
if ($baselineCompareEvidence !== []) {
|
||||||
return null;
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_compare_evidence',
|
||||||
|
kind: 'operational_context',
|
||||||
|
title: 'Baseline compare evidence',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $baselineCompareEvidence],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return trim($hint ?? '');
|
if ((string) $record->type === 'baseline_capture') {
|
||||||
|
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||||
|
|
||||||
|
if ($baselineCaptureEvidence !== []) {
|
||||||
|
$builder->addSection(
|
||||||
|
$factory->viewSection(
|
||||||
|
id: 'baseline_capture_evidence',
|
||||||
|
kind: 'operational_context',
|
||||||
|
title: 'Baseline capture evidence',
|
||||||
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
|
viewData: ['payload' => $baselineCaptureEvidence],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function normalizeDetailText(?string $value): ?string
|
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
||||||
{
|
$builder->addSection(
|
||||||
if (! is_string($value)) {
|
$factory->viewSection(
|
||||||
return null;
|
id: 'verification_report',
|
||||||
|
kind: 'operational_context',
|
||||||
|
title: 'Verification report',
|
||||||
|
view: 'filament.components.verification-report-viewer',
|
||||||
|
viewData: static::verificationReportViewData($record),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalized = trim((string) preg_replace('/\s+/', ' ', $value));
|
return $builder->build();
|
||||||
|
|
||||||
if ($normalized === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mb_strtolower($normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -815,42 +454,20 @@ private static function summaryCountFacts(
|
|||||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||||
|
|
||||||
return array_map(
|
return array_map(
|
||||||
static fn (string $key, int $value): array => $factory->keyFact(
|
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value),
|
||||||
SummaryCountsNormalizer::label($key),
|
|
||||||
$value,
|
|
||||||
tone: self::countTone($key, $value),
|
|
||||||
),
|
|
||||||
array_keys($counts),
|
array_keys($counts),
|
||||||
array_values($counts),
|
array_values($counts),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function countTone(string $key, int $value): ?string
|
|
||||||
{
|
|
||||||
if (in_array($key, ['failed', 'errors_recorded', 'findings_reopened'], true)) {
|
|
||||||
return $value > 0 ? 'danger' : 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($key === 'succeeded' && $value > 0) {
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
||||||
{
|
{
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->operatorLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||||
?? data_get($context, 'reason_code')
|
?? data_get($context, 'reason_code')
|
||||||
?? data_get($record->failure_summary, '0.reason_code');
|
?? data_get($record->failure_summary, '0.reason_code');
|
||||||
@ -864,12 +481,6 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
return $reasonEnvelope->shortExplanation;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = data_get($record->failure_summary, '0.message');
|
$message = data_get($record->failure_summary, '0.message');
|
||||||
|
|
||||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||||
@ -902,8 +513,6 @@ private static function baselineCompareFacts(
|
|||||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||||
): array {
|
): array {
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
|
||||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
|
||||||
$facts = [];
|
$facts = [];
|
||||||
|
|
||||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||||
@ -935,30 +544,6 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
|
||||||
$facts[] = $factory->keyFact(
|
|
||||||
'Evidence gap detail',
|
|
||||||
match ($gapSummary['detail_state'] ?? 'no_gaps') {
|
|
||||||
'structured_details_recorded' => 'Structured subject details available',
|
|
||||||
'details_not_recorded' => 'Detailed rows were not recorded',
|
|
||||||
'legacy_broad_reason' => 'Legacy development payload should be regenerated',
|
|
||||||
default => 'No evidence gaps recorded',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($gapSummary['structural_count'] ?? 0) > 0) {
|
|
||||||
$facts[] = $factory->keyFact('Structural gaps', (string) (int) $gapSummary['structural_count']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($gapSummary['operational_count'] ?? 0) > 0) {
|
|
||||||
$facts[] = $factory->keyFact('Operational gaps', (string) (int) $gapSummary['operational_count']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($gapSummary['transient_count'] ?? 0) > 0) {
|
|
||||||
$facts[] = $factory->keyFact('Transient gaps', (string) (int) $gapSummary['transient_count']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($uncoveredTypes !== []) {
|
if ($uncoveredTypes !== []) {
|
||||||
sort($uncoveredTypes, SORT_STRING);
|
sort($uncoveredTypes, SORT_STRING);
|
||||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||||
@ -1099,82 +684,6 @@ private static function contextPayload(OperationRun $record): array
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{status:string,freshness_state:string}
|
|
||||||
*/
|
|
||||||
private static function statusBadgeState(OperationRun $record): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'freshness_state' => $record->freshnessState()->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{outcome:string,status:string,freshness_state:string}
|
|
||||||
*/
|
|
||||||
private static function outcomeBadgeState(OperationRun $record): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'outcome' => (string) $record->outcome,
|
|
||||||
'status' => (string) $record->status,
|
|
||||||
'freshness_state' => $record->freshnessState()->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function freshnessLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
return match ($record->freshnessState()->value) {
|
|
||||||
'fresh_active' => 'Fresh activity',
|
|
||||||
'likely_stale' => 'Likely stale',
|
|
||||||
'reconciled_failed' => 'Automatically reconciled',
|
|
||||||
'terminal_normal' => 'Terminal truth confirmed',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciliationHeadline(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
if (! $record->isLifecycleReconciled()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'TenantPilot force-resolved this run after normal lifecycle truth was lost.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciledAtLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$reconciledAt = data_get($record->reconciliation(), 'reconciled_at');
|
|
||||||
|
|
||||||
return is_string($reconciledAt) && trim($reconciledAt) !== '' ? trim($reconciledAt) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reconciliationSourceLabel(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
$source = data_get($record->reconciliation(), 'source');
|
|
||||||
|
|
||||||
if (! is_string($source) || trim($source) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (trim($source)) {
|
|
||||||
'failed_callback' => 'Direct failed() bridge',
|
|
||||||
'scheduled_reconciler' => 'Scheduled reconciler',
|
|
||||||
'adapter_reconciler' => 'Adapter reconciler',
|
|
||||||
default => ucfirst(str_replace('_', ' ', trim($source))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function reconciliationPayload(OperationRun $record): array
|
|
||||||
{
|
|
||||||
$reconciliation = $record->reconciliation();
|
|
||||||
|
|
||||||
return $reconciliation;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function formatDetailTimestamp(mixed $value): string
|
private static function formatDetailTimestamp(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||||
|
|||||||
@ -824,12 +824,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -924,12 +921,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
@ -1021,12 +1015,9 @@ public static function table(Table $table): Table
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
|
|||||||
@ -278,12 +278,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Connection check blocked')
|
->title('Connection check blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -650,12 +647,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync blocked')
|
->title('Inventory sync blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
@ -764,12 +758,9 @@ protected function getHeaderActions(): array
|
|||||||
? (string) $result->run->context['reason_code']
|
? (string) $result->run->context['reason_code']
|
||||||
: 'unknown_error';
|
: 'unknown_error';
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Compliance snapshot blocked')
|
->title('Compliance snapshot blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
|
|||||||
@ -824,10 +824,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Total')
|
->label('Total')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||||
->label('Applied')
|
->label('Succeeded')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('summary_failed')
|
Tables\Columns\TextColumn::make('summary_failed')
|
||||||
->label('Failed items')
|
->label('Failed')
|
||||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||||
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
|
||||||
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
|
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
|
||||||
@ -1261,7 +1261,7 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
$succeeded = (int) ($meta['succeeded'] ?? 0);
|
||||||
$failed = (int) ($meta['failed'] ?? 0);
|
$failed = (int) ($meta['failed'] ?? 0);
|
||||||
|
|
||||||
return sprintf('Total: %d • Applied: %d • Failed items: %d', $total, $succeeded, $failed);
|
return sprintf('Total: %d • Succeeded: %d • Failed: %d', $total, $succeeded, $failed);
|
||||||
}),
|
}),
|
||||||
Infolists\Components\TextEntry::make('is_dry_run')
|
Infolists\Components\TextEntry::make('is_dry_run')
|
||||||
->label('Dry-run')
|
->label('Dry-run')
|
||||||
|
|||||||
@ -2,15 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -20,14 +17,11 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -115,15 +109,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Status')
|
Section::make('Status')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('status')
|
TextEntry::make('status')
|
||||||
@ -179,21 +164,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
||||||
TextEntry::make('tenantReview.id')
|
|
||||||
->label('Tenant review')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (ReviewPack $record): ?string => $record->tenantReview && $record->tenant
|
|
||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
|
||||||
: null)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('summary.review_status')
|
|
||||||
->label('Review status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('operationRun.id')
|
TextEntry::make('operationRun.id')
|
||||||
->label('Operation run')
|
->label('Operation run')
|
||||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||||
@ -207,33 +177,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Evidence snapshot')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('summary.evidence_resolution.outcome')
|
|
||||||
->label('Resolution')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
|
||||||
->label('Snapshot')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
|
||||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
|
||||||
->label('Snapshot completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
|
||||||
->label('Snapshot fingerprint')
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,15 +194,6 @@ public static function table(Table $table): Table
|
|||||||
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
->icon(BadgeRenderer::icon(BadgeDomain::ReviewPackStatus))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(fn (ReviewPack $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (ReviewPack $record): ?string => static::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('tenant.name')
|
Tables\Columns\TextColumn::make('tenant.name')
|
||||||
->label('Tenant')
|
->label('Tenant')
|
||||||
->searchable(),
|
->searchable(),
|
||||||
@ -267,10 +201,6 @@ public static function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('tenantReview.id')
|
|
||||||
->label('Review')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('expires_at')
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -279,29 +209,6 @@ public static function table(Table $table): Table
|
|||||||
->label('Size')
|
->label('Size')
|
||||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('publication_truth')
|
|
||||||
->label('Publication')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->label)
|
|
||||||
->color(fn (ReviewPack $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (ReviewPack $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (ReviewPack $record): string => static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
->label('Created')
|
->label('Created')
|
||||||
->since()
|
->since()
|
||||||
@ -397,11 +304,6 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function truthEnvelope(ReviewPack $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forReviewPack($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
@ -429,23 +331,7 @@ public static function executeGeneration(array $data): void
|
|||||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
|
||||||
$reviewPack = $service->generate($tenant, $user, $options);
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
|
||||||
$reasons = $exception->result->reasons;
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->danger()
|
|
||||||
->title(match ($exception->result->outcome) {
|
|
||||||
'missing_snapshot' => 'Create snapshot required',
|
|
||||||
'snapshot_ineligible' => 'Snapshot is not eligible',
|
|
||||||
default => 'Unable to generate review pack',
|
|
||||||
})
|
|
||||||
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $reviewPack->wasRecentlyCreated) {
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -608,12 +608,9 @@ public static function table(Table $table): Table
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
@ -911,20 +908,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||||
Section::make('RBAC Details')
|
Section::make('RBAC Details')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_label')
|
|
||||||
->label('Reason')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->primaryLabel(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state)),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason_explanation')
|
|
||||||
->label('Explanation')
|
|
||||||
->state(fn (Tenant $record): ?string => app(\App\Support\ReasonTranslation\ReasonPresenter::class)
|
|
||||||
->shortExplanation(app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forRbacReason($record->rbac_status_reason, 'detail')))
|
|
||||||
->visible(fn (?string $state): bool => filled($state))
|
|
||||||
->columnSpanFull(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_status_reason')
|
Infolists\Components\TextEntry::make('rbac_status_reason')
|
||||||
->label('Diagnostic code')
|
->label('Reason'),
|
||||||
->copyable(),
|
|
||||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||||
->label('Role definition ID')
|
->label('Role definition ID')
|
||||||
->copyable(),
|
->copyable(),
|
||||||
|
|||||||
@ -178,12 +178,9 @@ protected function getHeaderActions(): array
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -1,613 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\TenantReviewSection;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Support\Enums\TextSize;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class TenantReviewResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $model = TenantReview::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews';
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Reviews';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 45;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can('view', $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()
|
|
||||||
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
|
||||||
->latest('generated_at')
|
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Artifact truth')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('artifact_truth')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.governance-artifact-truth')
|
|
||||||
->state(fn (TenantReview $record): array => static::truthEnvelope($record)->toArray())
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Review')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
|
||||||
->label('Evidence snapshot')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('currentExportReviewPack.id')
|
|
||||||
->label('Current export')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('fingerprint')
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull()
|
|
||||||
->fontFamily('mono')
|
|
||||||
->size(TextSize::ExtraSmall),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Executive posture')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('review_summary')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.tenant-review-summary')
|
|
||||||
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Sections')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('sections')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('title'),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
|
||||||
Section::make('Details')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('section_payload')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.tenant-review-section')
|
|
||||||
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->collapsible()
|
|
||||||
->collapsed()
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('generated_at', 'desc')
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_truth')
|
|
||||||
->label('Artifact truth')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryLabel)
|
|
||||||
->color(fn (TenantReview $record): string => static::truthEnvelope($record)->primaryBadgeSpec()->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => static::truthEnvelope($record)->primaryBadgeSpec()->iconColor)
|
|
||||||
->description(fn (TenantReview $record): ?string => static::truthEnvelope($record)->operatorExplanation?->headline ?? static::truthEnvelope($record)->primaryExplanation)
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label(static::reviewCompletenessCountLabel(TenantReviewCompletenessState::Missing->value)),
|
|
||||||
Tables\Columns\TextColumn::make('publication_truth')
|
|
||||||
->label('Publication')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->label)
|
|
||||||
->color(fn (TenantReview $record): string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->color)
|
|
||||||
->icon(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->icon)
|
|
||||||
->iconColor(fn (TenantReview $record): ?string => BadgeCatalog::spec(
|
|
||||||
BadgeDomain::GovernanceArtifactPublicationReadiness,
|
|
||||||
static::truthEnvelope($record)->publicationReadiness ?? 'internal_only',
|
|
||||||
)->iconColor),
|
|
||||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
|
||||||
->label('Export')
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('artifact_next_step')
|
|
||||||
->label('Next step')
|
|
||||||
->getStateUsing(fn (TenantReview $record): string => static::truthEnvelope($record)->operatorExplanation?->nextActionText ?? static::truthEnvelope($record)->nextStepText())
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options(collect(TenantReviewStatus::cases())
|
|
||||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
|
||||||
->all()),
|
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
|
||||||
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
|
|
||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No tenant reviews yet')
|
|
||||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
|
||||||
->emptyStateActions([
|
|
||||||
static::makeCreateReviewAction(
|
|
||||||
name: 'create_first_review',
|
|
||||||
label: 'Create first review',
|
|
||||||
icon: 'heroicon-o-plus',
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListTenantReviews::route('/'),
|
|
||||||
'view' => Pages\ViewTenantReview::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function makeCreateReviewAction(
|
|
||||||
string $name = 'create_review',
|
|
||||||
string $label = 'Create review',
|
|
||||||
string $icon = 'heroicon-o-plus',
|
|
||||||
): Actions\Action {
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make($name)
|
|
||||||
->label($label)
|
|
||||||
->icon($icon)
|
|
||||||
->form([
|
|
||||||
Section::make('Evidence basis')
|
|
||||||
->schema([
|
|
||||||
Select::make('evidence_snapshot_id')
|
|
||||||
->label('Evidence snapshot')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
|
||||||
->searchable()
|
|
||||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function executeCreateReview(array $data): void
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
|
||||||
$snapshot = is_numeric($snapshotId)
|
|
||||||
? EvidenceSnapshot::query()
|
|
||||||
->whereKey((int) $snapshotId)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->first()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
||||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $review->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Review already available')
|
|
||||||
->body('A matching mutable review already exists for this evidence basis.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
|
||||||
->body('The review is being composed in the background.');
|
|
||||||
|
|
||||||
if ($review->operation_run_id) {
|
|
||||||
$toast->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
|
||||||
{
|
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
|
||||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($review->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can('export', $review)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(ReviewPackService::class);
|
|
||||||
|
|
||||||
if ($service->checkActiveRunForReview($review)) {
|
|
||||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->body('An executive pack export is already queued or running for this review.')
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pack = $service->generateFromReview($review, $user, [
|
|
||||||
'include_pii' => true,
|
|
||||||
'include_operations' => true,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $pack->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Executive pack already available')
|
|
||||||
->body('A matching executive pack already exists for this review.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_pack')
|
|
||||||
->label('View pack')
|
|
||||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->body('The executive pack is being generated in the background.')
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $parameters
|
|
||||||
*/
|
|
||||||
public static function tenantScopedUrl(
|
|
||||||
string $page = 'index',
|
|
||||||
array $parameters = [],
|
|
||||||
?Tenant $tenant = null,
|
|
||||||
?string $panel = null,
|
|
||||||
): string {
|
|
||||||
$panelId = $panel ?? 'tenant';
|
|
||||||
|
|
||||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function evidenceSnapshotOptions(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return EvidenceSnapshot::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->whereNotNull('generated_at')
|
|
||||||
->orderByDesc('generated_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
|
||||||
(string) $snapshot->getKey() => sprintf(
|
|
||||||
'#%d · %s · %s',
|
|
||||||
(int) $snapshot->getKey(),
|
|
||||||
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
|
|
||||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function reviewCompletenessCountLabel(string $state): string
|
|
||||||
{
|
|
||||||
return BadgeCatalog::spec(BadgeDomain::TenantReviewCompleteness, $state)->label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function summaryPresentation(TenantReview $record): array
|
|
||||||
{
|
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(),
|
|
||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
|
||||||
'metrics' => [
|
|
||||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
|
||||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
|
||||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
|
||||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function sectionPresentation(TenantReviewSection $section): array
|
|
||||||
{
|
|
||||||
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
||||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
|
||||||
$review = $section->tenantReview;
|
|
||||||
$tenant = $section->tenant;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
|
||||||
if (is_array($value) || $value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => Str::headline($key),
|
|
||||||
'value' => (string) $value,
|
|
||||||
];
|
|
||||||
})->filter()->values()->all(),
|
|
||||||
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
|
||||||
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
|
||||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
|
||||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
|
||||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
|
||||||
'links' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function truthEnvelope(TenantReview $record): ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
return app(ArtifactTruthPresenter::class)->forTenantReview($record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenantReviews extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantReviewResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TenantReviewResource::makeCreateReviewAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewTenantReview extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantReviewResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
|
||||||
{
|
|
||||||
$tenant = TenantReviewResource::panelTenantContext();
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can('view', $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id
|
|
||||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_export')
|
|
||||||
->label('View executive pack')
|
|
||||||
->icon('heroicon-o-document-arrow-down')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
|
||||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
|
||||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_evidence')
|
|
||||||
->label('View evidence snapshot')
|
|
||||||
->icon('heroicon-o-shield-check')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
|
||||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
|
||||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('refresh_review')
|
|
||||||
->label('Refresh review')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Refresh review queued')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('publish_review')
|
|
||||||
->label('Publish review')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
|
||||||
Notification::make()->success()->title('Review published')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_next_review')
|
|
||||||
->label('Create next review')
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive_review')
|
|
||||||
->label('Archive review')
|
|
||||||
->icon('heroicon-o-archive-box')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
|
||||||
$this->refreshFormData(['status', 'archived_at']);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Review archived')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-m-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,11 +4,8 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
@ -26,46 +23,33 @@ protected function getViewData(): array
|
|||||||
$empty = [
|
$empty = [
|
||||||
'hasAssignment' => false,
|
'hasAssignment' => false,
|
||||||
'profileName' => null,
|
'profileName' => null,
|
||||||
|
'findingsCount' => 0,
|
||||||
|
'highCount' => 0,
|
||||||
|
'mediumCount' => 0,
|
||||||
|
'lowCount' => 0,
|
||||||
'lastComparedAt' => null,
|
'lastComparedAt' => null,
|
||||||
'landingUrl' => null,
|
'landingUrl' => null,
|
||||||
'runUrl' => null,
|
|
||||||
'findingsUrl' => null,
|
|
||||||
'nextActionUrl' => null,
|
|
||||||
'summaryAssessment' => null,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forWidget($tenant);
|
||||||
|
|
||||||
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
if (in_array($stats->state, ['no_tenant', 'no_assignment'], true)) {
|
||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenantLandingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
|
||||||
$runUrl = $stats->operationRunId !== null
|
|
||||||
? OperationRunLinks::view($stats->operationRunId, $tenant)
|
|
||||||
: null;
|
|
||||||
$findingsUrl = FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
|
||||||
'run' => $runUrl,
|
|
||||||
'findings' => $findingsUrl,
|
|
||||||
'landing' => $tenantLandingUrl,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'hasAssignment' => true,
|
'hasAssignment' => true,
|
||||||
'profileName' => $stats->profileName,
|
'profileName' => $stats->profileName,
|
||||||
|
'findingsCount' => $stats->findingsCount ?? 0,
|
||||||
|
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||||
|
'mediumCount' => $stats->severityCounts['medium'] ?? 0,
|
||||||
|
'lowCount' => $stats->severityCounts['low'] ?? 0,
|
||||||
'lastComparedAt' => $stats->lastComparedHuman,
|
'lastComparedAt' => $stats->lastComparedHuman,
|
||||||
'landingUrl' => $tenantLandingUrl,
|
'landingUrl' => \App\Filament\Pages\BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
'runUrl' => $runUrl,
|
|
||||||
'findingsUrl' => $findingsUrl,
|
|
||||||
'nextActionUrl' => $nextActionUrl,
|
|
||||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Dashboard;
|
namespace App\Filament\Widgets\Dashboard;
|
||||||
|
|
||||||
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
@ -31,107 +34,85 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenantId = (int) $tenant->getKey();
|
$tenantId = (int) $tenant->getKey();
|
||||||
$compareStats = BaselineCompareStats::forTenant($tenant);
|
|
||||||
$compareAssessment = $compareStats->summaryAssessment();
|
|
||||||
|
|
||||||
$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)
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
->whereIn('severity', [
|
->where('status', Finding::STATUS_NEW)
|
||||||
Finding::SEVERITY_HIGH,
|
->where('severity', 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 active findings',
|
'title' => 'High severity drift findings',
|
||||||
'body' => "{$highSeverityCount} active finding(s) need review.",
|
'body' => "{$highSeverityCount} finding(s) need review.",
|
||||||
|
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||||
'badge' => 'Drift',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => 'danger',
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($compareAssessment->stateFamily !== 'positive') {
|
$latestBaselineCompareSuccess = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'baseline_compare')
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('outcome', 'succeeded')
|
||||||
|
->whereNotNull('completed_at')
|
||||||
|
->latest('completed_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $latestBaselineCompareSuccess) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Baseline compare posture',
|
'title' => 'No baseline compare yet',
|
||||||
'body' => $compareAssessment->headline,
|
'body' => 'Run a baseline compare after your tenant has an assigned baseline snapshot.',
|
||||||
'supportingMessage' => $compareAssessment->supportingMessage,
|
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
'badge' => 'Baseline',
|
'badge' => 'Drift',
|
||||||
'badgeColor' => $compareAssessment->tone,
|
'badgeColor' => 'warning',
|
||||||
'nextStep' => $compareAssessment->nextActionLabel(),
|
];
|
||||||
|
} else {
|
||||||
|
$isStale = $latestBaselineCompareSuccess->completed_at?->lt(now()->subDays(7)) ?? true;
|
||||||
|
|
||||||
|
if ($isStale) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Baseline compare stale',
|
||||||
|
'body' => 'Last baseline compare is older than 7 days.',
|
||||||
|
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
|
'badge' => 'Drift',
|
||||||
|
'badgeColor' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestBaselineCompareFailure = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('type', 'baseline_compare')
|
||||||
|
->where('status', 'completed')
|
||||||
|
->where('outcome', 'failed')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestBaselineCompareFailure instanceof OperationRun) {
|
||||||
|
$items[] = [
|
||||||
|
'title' => 'Baseline compare failed',
|
||||||
|
'body' => 'Investigate the latest failed run.',
|
||||||
|
'url' => OperationRunLinks::view($latestBaselineCompareFailure, $tenant),
|
||||||
|
'badge' => 'Operations',
|
||||||
|
'badgeColor' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeRuns = ActiveRuns::existForTenant($tenant)
|
$activeRuns = (int) OperationRun::query()
|
||||||
? (int) \App\Models\OperationRun::query()->where('tenant_id', $tenantId)->active()->count()
|
->where('tenant_id', $tenantId)
|
||||||
: 0;
|
->active()
|
||||||
|
->count();
|
||||||
|
|
||||||
if ($activeRuns > 0) {
|
if ($activeRuns > 0) {
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'title' => 'Operations in progress',
|
'title' => 'Operations in progress',
|
||||||
'body' => "{$activeRuns} run(s) are active.",
|
'body' => "{$activeRuns} run(s) are active.",
|
||||||
|
'url' => OperationRunLinks::index($tenant),
|
||||||
'badge' => 'Operations',
|
'badge' => 'Operations',
|
||||||
'badgeColor' => 'warning',
|
'badgeColor' => 'warning',
|
||||||
];
|
];
|
||||||
@ -144,24 +125,24 @@ protected function getViewData(): array
|
|||||||
if ($items === []) {
|
if ($items === []) {
|
||||||
$healthyChecks = [
|
$healthyChecks = [
|
||||||
[
|
[
|
||||||
'title' => 'Baseline compare looks trustworthy',
|
'title' => 'Drift findings look healthy',
|
||||||
'body' => $compareAssessment->headline,
|
'body' => 'No high severity drift findings are open.',
|
||||||
|
'url' => FindingResource::getUrl('index', tenant: $tenant),
|
||||||
|
'linkLabel' => 'View findings',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => 'No overdue findings',
|
'title' => 'Baseline compares are up to date',
|
||||||
'body' => 'No open findings are currently overdue for this tenant.',
|
'body' => $latestBaselineCompareSuccess?->completed_at
|
||||||
],
|
? 'Last baseline compare: '.$latestBaselineCompareSuccess->completed_at->diffForHumans(['short' => true]).'.'
|
||||||
[
|
: 'Baseline compare history is available in Baseline Compare.',
|
||||||
'title' => 'Accepted-risk governance is healthy',
|
'url' => BaselineCompareLanding::getUrl(tenant: $tenant),
|
||||||
'body' => 'No accepted-risk findings currently need governance follow-up.',
|
'linkLabel' => 'Open Baseline Compare',
|
||||||
],
|
|
||||||
[
|
|
||||||
'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',
|
||||||
'body' => 'Nothing is currently running for this tenant.',
|
'body' => 'Nothing is currently running for this tenant.',
|
||||||
|
'url' => OperationRunLinks::index($tenant),
|
||||||
|
'linkLabel' => 'View operations',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\ActiveRuns;
|
use App\Support\OpsUx\ActiveRuns;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -58,8 +57,7 @@ public function table(Table $table): Table
|
|||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
|
||||||
TextColumn::make('created_at')
|
TextColumn::make('created_at')
|
||||||
->label('Started')
|
->label('Started')
|
||||||
->sortable()
|
->sortable()
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Baselines\BaselineCompareStats;
|
use App\Support\Baselines\BaselineCompareStats;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -31,30 +30,26 @@ protected function getViewData(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stats = BaselineCompareStats::forTenant($tenant);
|
$stats = BaselineCompareStats::forTenant($tenant);
|
||||||
$summaryAssessment = $stats->summaryAssessment();
|
|
||||||
|
$uncoveredTypes = $stats->uncoveredTypes ?? [];
|
||||||
|
$uncoveredTypes = is_array($uncoveredTypes) ? $uncoveredTypes : [];
|
||||||
|
|
||||||
|
$coverageStatus = $stats->coverageStatus;
|
||||||
|
$hasWarnings = in_array($coverageStatus, ['warning', 'unproven'], true) && $uncoveredTypes !== [];
|
||||||
|
|
||||||
$runUrl = null;
|
$runUrl = null;
|
||||||
|
|
||||||
if ($stats->operationRunId !== null) {
|
if ($stats->operationRunId !== null) {
|
||||||
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
$runUrl = OperationRunLinks::view($stats->operationRunId, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$landingUrl = BaselineCompareLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
|
||||||
$nextActionUrl = match ($summaryAssessment->nextActionTarget()) {
|
|
||||||
'run' => $runUrl,
|
|
||||||
'findings' => \App\Filament\Resources\FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
|
||||||
'landing' => $landingUrl,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
$shouldShow = in_array($summaryAssessment->stateFamily, ['caution', 'stale', 'unavailable', 'in_progress'], true)
|
|
||||||
|| $summaryAssessment->stateFamily === 'action_required';
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'shouldShow' => $shouldShow,
|
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||||
'landingUrl' => $landingUrl,
|
|
||||||
'runUrl' => $runUrl,
|
'runUrl' => $runUrl,
|
||||||
'nextActionUrl' => $nextActionUrl,
|
'coverageStatus' => $coverageStatus,
|
||||||
'summaryAssessment' => $summaryAssessment->toArray(),
|
'fidelity' => $stats->fidelity,
|
||||||
'state' => $stats->state,
|
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||||
|
'uncoveredTypes' => $uncoveredTypes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -131,7 +131,6 @@ protected function getViewData(): array
|
|||||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||||
|
|
||||||
$latestPack = ReviewPack::query()
|
$latestPack = ReviewPack::query()
|
||||||
->with('tenantReview')
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@ -147,7 +146,6 @@ protected function getViewData(): array
|
|||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,11 +158,6 @@ protected function getViewData(): array
|
|||||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||||
}
|
}
|
||||||
|
|
||||||
$reviewUrl = null;
|
|
||||||
if ($latestPack->tenantReview && $canView) {
|
|
||||||
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failedReason = null;
|
$failedReason = null;
|
||||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||||
@ -180,7 +173,6 @@ protected function getViewData(): array
|
|||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'downloadUrl' => $downloadUrl,
|
'downloadUrl' => $downloadUrl,
|
||||||
'failedReason' => $failedReason,
|
'failedReason' => $failedReason,
|
||||||
'reviewUrl' => $reviewUrl,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +200,6 @@ private function emptyState(): array
|
|||||||
'canManage' => false,
|
'canManage' => false,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,12 +134,9 @@ public function startVerification(StartVerification $verification): void
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Verification blocked')
|
->title('Verification blocked')
|
||||||
->body(implode("\n", $bodyLines))
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||||
->warning()
|
->warning()
|
||||||
->actions($actions)
|
->actions($actions)
|
||||||
->send();
|
->send();
|
||||||
|
|||||||
@ -23,7 +23,6 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
* guidance: ?string,
|
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* url: string
|
* url: string
|
||||||
* }>
|
* }>
|
||||||
@ -49,7 +48,6 @@ class WorkspaceRecentOperations extends Widget
|
|||||||
* status_color: string,
|
* status_color: string,
|
||||||
* outcome_label: string,
|
* outcome_label: string,
|
||||||
* outcome_color: string,
|
* outcome_color: string,
|
||||||
* guidance: ?string,
|
|
||||||
* started_at: string,
|
* started_at: string,
|
||||||
* url: string
|
* url: string
|
||||||
* }> $operations
|
* }> $operations
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -177,7 +177,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isLivewireUpdatePath($path)) {
|
if ($path === '/livewire/update') {
|
||||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||||
|
|
||||||
@ -193,11 +193,6 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
|||||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isLivewireUpdatePath(string $path): bool
|
|
||||||
{
|
|
||||||
return preg_match('#^/livewire(?:-[^/]+)?/update$#', $path) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isChooserFirstPath(string $path): bool
|
private function isChooserFirstPath(string $path): bool
|
||||||
{
|
{
|
||||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -12,18 +11,11 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public int $bulkRunId = 0;
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
@ -76,6 +68,32 @@ public function handle(OperationRunService $runs): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function failed(Throwable $e): void
|
||||||
|
{
|
||||||
|
$run = $this->operationRun;
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
||||||
|
$run = OperationRun::query()->find($this->bulkRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $runs */
|
||||||
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$runs->updateRun(
|
||||||
|
$run,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [[
|
||||||
|
'code' => 'bulk_job.failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveOperationRun(): OperationRun
|
private function resolveOperationRun(): OperationRun
|
||||||
{
|
{
|
||||||
if ($this->operationRun instanceof OperationRun) {
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -16,15 +15,7 @@
|
|||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -13,7 +12,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
use App\Services\Baselines\InventoryMetaContract;
|
use App\Services\Baselines\InventoryMetaContract;
|
||||||
@ -22,9 +20,7 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -36,19 +32,10 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
@ -73,12 +60,10 @@ public function handle(
|
|||||||
OperationRunService $operationRunService,
|
OperationRunService $operationRunService,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
): void {
|
): void {
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
@ -108,7 +93,6 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
? $profile->capture_mode
|
? $profile->capture_mode
|
||||||
@ -128,7 +112,6 @@ public function handle(
|
|||||||
scope: $effectiveScope,
|
scope: $effectiveScope,
|
||||||
identity: $identity,
|
identity: $identity,
|
||||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||||
policyTypes: $truthfulTypes,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$subjects = $inventoryResult['subjects'];
|
$subjects = $inventoryResult['subjects'];
|
||||||
@ -200,12 +183,7 @@ public function handle(
|
|||||||
gaps: $captureGaps,
|
gaps: $captureGaps,
|
||||||
);
|
);
|
||||||
|
|
||||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
$items = $snapshotItems['items'] ?? [];
|
||||||
$items = $normalizedItems['items'];
|
|
||||||
|
|
||||||
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
|
||||||
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$identityHash = $identity->computeIdentity($items);
|
$identityHash = $identity->computeIdentity($items);
|
||||||
|
|
||||||
@ -222,17 +200,16 @@ public function handle(
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$snapshotResult = $this->captureSnapshotArtifact(
|
$snapshot = $this->findOrCreateSnapshot(
|
||||||
$profile,
|
$profile,
|
||||||
$identityHash,
|
$identityHash,
|
||||||
$items,
|
$items,
|
||||||
$snapshotSummary,
|
$snapshotSummary,
|
||||||
);
|
);
|
||||||
|
|
||||||
$snapshot = $snapshotResult['snapshot'];
|
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
|
||||||
|
|
||||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
if ($profile->status === BaselineProfileStatus::Active) {
|
||||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,9 +241,6 @@ public function handle(
|
|||||||
'gaps' => [
|
'gaps' => [
|
||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
|
||||||
? array_values($phaseResult['gap_subjects'])
|
|
||||||
: null,
|
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
],
|
],
|
||||||
@ -276,7 +250,6 @@ public function handle(
|
|||||||
'snapshot_identity_hash' => $identityHash,
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
'was_new_snapshot' => $wasNewSnapshot,
|
||||||
'items_captured' => $snapshotItems['items_count'],
|
'items_captured' => $snapshotItems['items_count'],
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
];
|
];
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -301,7 +274,7 @@ public function handle(
|
|||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* subjects_total: int,
|
* subjects_total: int,
|
||||||
* subjects: list<array{policy_type: string, subject_external_id: string, subject_key: string}>,
|
* subjects: list<array{policy_type: string, subject_external_id: string}>,
|
||||||
* inventory_by_key: array<string, array{
|
* inventory_by_key: array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
* workspace_subject_external_id: string,
|
* workspace_subject_external_id: string,
|
||||||
@ -322,7 +295,6 @@ private function collectInventorySubjects(
|
|||||||
BaselineScope $scope,
|
BaselineScope $scope,
|
||||||
BaselineSnapshotIdentity $identity,
|
BaselineSnapshotIdentity $identity,
|
||||||
?int $latestInventorySyncRunId = null,
|
?int $latestInventorySyncRunId = null,
|
||||||
?array $policyTypes = null,
|
|
||||||
): array {
|
): array {
|
||||||
$query = InventoryItem::query()
|
$query = InventoryItem::query()
|
||||||
->where('tenant_id', $sourceTenant->getKey());
|
->where('tenant_id', $sourceTenant->getKey());
|
||||||
@ -331,7 +303,7 @@ private function collectInventorySubjects(
|
|||||||
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
$query->where('last_seen_operation_run_id', $latestInventorySyncRunId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->whereIn('policy_type', is_array($policyTypes) && $policyTypes !== [] ? $policyTypes : $scope->allTypes());
|
$query->whereIn('policy_type', $scope->allTypes());
|
||||||
|
|
||||||
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
/** @var array<string, array{tenant_subject_external_id: string, workspace_subject_external_id: string, subject_key: string, policy_type: string, identity_strategy: string, display_name: ?string, category: ?string, platform: ?string, is_built_in: ?bool, role_permission_count: ?int}> $inventoryByKey */
|
||||||
$inventoryByKey = [];
|
$inventoryByKey = [];
|
||||||
@ -419,7 +391,6 @@ private function collectInventorySubjects(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||||
'subject_key' => (string) $item['subject_key'],
|
|
||||||
],
|
],
|
||||||
$inventoryByKey,
|
$inventoryByKey,
|
||||||
));
|
));
|
||||||
@ -432,27 +403,6 @@ private function collectInventorySubjects(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
|
||||||
{
|
|
||||||
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
|
||||||
|
|
||||||
if (is_array($truthfulTypes)) {
|
|
||||||
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
|
||||||
|
|
||||||
if ($truthfulTypes !== []) {
|
|
||||||
sort($truthfulTypes, SORT_STRING);
|
|
||||||
|
|
||||||
return $truthfulTypes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $effectiveScope->allTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{
|
* @param array<string, array{
|
||||||
* tenant_subject_external_id: string,
|
* tenant_subject_external_id: string,
|
||||||
@ -550,151 +500,29 @@ private function buildSnapshotItems(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function findOrCreateSnapshot(
|
||||||
* @param array<int, array<string, mixed>> $snapshotItems
|
|
||||||
* @param array<string, mixed> $summaryJsonb
|
|
||||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
|
||||||
*/
|
|
||||||
private function captureSnapshotArtifact(
|
|
||||||
BaselineProfile $profile,
|
BaselineProfile $profile,
|
||||||
string $identityHash,
|
string $identityHash,
|
||||||
array $snapshotItems,
|
array $snapshotItems,
|
||||||
array $summaryJsonb,
|
array $summaryJsonb,
|
||||||
): array {
|
): BaselineSnapshot {
|
||||||
$existing = $this->findExistingConsumableSnapshot($profile, $identityHash);
|
|
||||||
|
|
||||||
if ($existing instanceof BaselineSnapshot) {
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $existing,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: false,
|
|
||||||
expectedItems: count($snapshotItems),
|
|
||||||
persistedItems: count($snapshotItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'snapshot' => $existing,
|
|
||||||
'was_new_snapshot' => false,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$expectedItems = count($snapshotItems);
|
|
||||||
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, $expectedItems);
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$persistedItems = $this->persistSnapshotItems($snapshot, $snapshotItems);
|
|
||||||
|
|
||||||
if ($persistedItems !== $expectedItems) {
|
|
||||||
throw new RuntimeException('Baseline snapshot completion proof failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot->markComplete($identityHash, [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => $persistedItems,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot->refresh();
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: $persistedItems,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'snapshot' => $snapshot,
|
|
||||||
'was_new_snapshot' => true,
|
|
||||||
];
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
$persistedItems = (int) BaselineSnapshotItem::query()
|
|
||||||
->where('baseline_snapshot_id', (int) $snapshot->getKey())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$reasonCode = $exception instanceof RuntimeException
|
|
||||||
&& $exception->getMessage() === 'Baseline snapshot completion proof failed.'
|
|
||||||
? BaselineReasonCodes::SNAPSHOT_COMPLETION_PROOF_FAILED
|
|
||||||
: BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED;
|
|
||||||
|
|
||||||
$snapshot->markIncomplete($reasonCode, [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => $persistedItems,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$snapshot->refresh();
|
|
||||||
|
|
||||||
$this->rememberSnapshotOnRun(
|
|
||||||
snapshot: $snapshot,
|
|
||||||
identityHash: $identityHash,
|
|
||||||
wasNewSnapshot: true,
|
|
||||||
expectedItems: $expectedItems,
|
|
||||||
persistedItems: $persistedItems,
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
$existing = BaselineSnapshot::query()
|
$existing = BaselineSnapshot::query()
|
||||||
->where('workspace_id', $profile->workspace_id)
|
->where('workspace_id', $profile->workspace_id)
|
||||||
->where('baseline_profile_id', $profile->getKey())
|
->where('baseline_profile_id', $profile->getKey())
|
||||||
->where('snapshot_identity_hash', $identityHash)
|
->where('snapshot_identity_hash', $identityHash)
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
if ($existing instanceof BaselineSnapshot) {
|
||||||
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$snapshot = BaselineSnapshot::create([
|
||||||
* @param array<string, mixed> $summaryJsonb
|
|
||||||
*/
|
|
||||||
private function createBuildingSnapshot(
|
|
||||||
BaselineProfile $profile,
|
|
||||||
string $identityHash,
|
|
||||||
array $summaryJsonb,
|
|
||||||
int $expectedItems,
|
|
||||||
): BaselineSnapshot {
|
|
||||||
return BaselineSnapshot::create([
|
|
||||||
'workspace_id' => (int) $profile->workspace_id,
|
'workspace_id' => (int) $profile->workspace_id,
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
'snapshot_identity_hash' => $identityHash,
|
||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
|
||||||
'summary_jsonb' => $summaryJsonb,
|
'summary_jsonb' => $summaryJsonb,
|
||||||
'completion_meta_jsonb' => [
|
|
||||||
'expected_identity_hash' => $identityHash,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
'persisted_items' => 0,
|
|
||||||
'producer_run_id' => (int) $this->operationRun->getKey(),
|
|
||||||
'was_empty_capture' => $expectedItems === 0,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array<string, mixed>> $snapshotItems
|
|
||||||
*/
|
|
||||||
private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapshotItems): int
|
|
||||||
{
|
|
||||||
$persistedItems = 0;
|
|
||||||
|
|
||||||
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
foreach (array_chunk($snapshotItems, 100) as $chunk) {
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
@ -713,56 +541,9 @@ private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapsho
|
|||||||
);
|
);
|
||||||
|
|
||||||
BaselineSnapshotItem::insert($rows);
|
BaselineSnapshotItem::insert($rows);
|
||||||
$persistedItems += count($rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $persistedItems;
|
return $snapshot;
|
||||||
}
|
|
||||||
|
|
||||||
private function temporarySnapshotIdentityHash(BaselineProfile $profile): string
|
|
||||||
{
|
|
||||||
return hash(
|
|
||||||
'sha256',
|
|
||||||
implode('|', [
|
|
||||||
'building',
|
|
||||||
(string) $profile->getKey(),
|
|
||||||
(string) $this->operationRun->getKey(),
|
|
||||||
(string) microtime(true),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rememberSnapshotOnRun(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
string $identityHash,
|
|
||||||
bool $wasNewSnapshot,
|
|
||||||
int $expectedItems,
|
|
||||||
int $persistedItems,
|
|
||||||
?string $reasonCode = null,
|
|
||||||
): void {
|
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$context['baseline_snapshot_id'] = (int) $snapshot->getKey();
|
|
||||||
$context['result'] = array_merge(
|
|
||||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
||||||
[
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_identity_hash' => $identityHash,
|
|
||||||
'was_new_snapshot' => $wasNewSnapshot,
|
|
||||||
'items_captured' => $persistedItems,
|
|
||||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
|
||||||
'expected_items' => $expectedItems,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
|
||||||
$context['reason_code'] = $reasonCode;
|
|
||||||
$context['result']['snapshot_reason_code'] = $reasonCode;
|
|
||||||
} else {
|
|
||||||
unset($context['reason_code'], $context['result']['snapshot_reason_code']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $context]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
@ -19,7 +18,6 @@
|
|||||||
use App\Services\Baselines\BaselineAutoCloseService;
|
use App\Services\Baselines\BaselineAutoCloseService;
|
||||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
|
||||||
use App\Services\Baselines\CurrentStateHashResolver;
|
use App\Services\Baselines\CurrentStateHashResolver;
|
||||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||||
@ -39,16 +37,13 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Baselines\SubjectResolver;
|
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -59,15 +54,7 @@
|
|||||||
|
|
||||||
class CompareBaselineToTenantJob implements ShouldQueue
|
class CompareBaselineToTenantJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
@ -97,7 +84,6 @@ public function handle(
|
|||||||
?SettingsResolver $settingsResolver = null,
|
?SettingsResolver $settingsResolver = null,
|
||||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||||
?CurrentStateHashResolver $hashResolver = null,
|
?CurrentStateHashResolver $hashResolver = null,
|
||||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
|
||||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
@ -106,7 +92,6 @@ public function handle(
|
|||||||
$settingsResolver ??= app(SettingsResolver::class);
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
|
||||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
@ -145,7 +130,7 @@ public function handle(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||||
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
$effectiveTypes = $effectiveScope->allTypes();
|
||||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
@ -293,52 +278,12 @@ public function handle(
|
|||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey($snapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id', 'captured_at']);
|
||||||
|
|
||||||
if (! $snapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
throw new RuntimeException("BaselineSnapshot #{$snapshotId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotResolution = $snapshotTruthResolver->resolveCompareSnapshot($profile, $snapshot);
|
|
||||||
|
|
||||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
|
||||||
$reasonCode = is_string($snapshotResolution['reason_code'] ?? null)
|
|
||||||
? (string) $snapshotResolution['reason_code']
|
|
||||||
: BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
|
||||||
|
|
||||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
|
||||||
$context['baseline_compare'] = array_merge(
|
|
||||||
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
|
||||||
[
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'effective_snapshot_id' => $snapshotResolution['effective_snapshot']?->getKey(),
|
|
||||||
'latest_attempted_snapshot_id' => $snapshotResolution['latest_attempted_snapshot']?->getKey(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$context['result'] = array_merge(
|
|
||||||
is_array($context['result'] ?? null) ? $context['result'] : [],
|
|
||||||
[
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
$context = $this->withCompareReasonTranslation($context, $reasonCode);
|
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $context]);
|
|
||||||
$this->operationRun->refresh();
|
|
||||||
|
|
||||||
$operationRunService->finalizeBlockedRun(
|
|
||||||
run: $this->operationRun,
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
message: $this->snapshotBlockedMessage($reasonCode),
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var BaselineSnapshot $snapshot */
|
|
||||||
$snapshot = $snapshotResolution['snapshot'];
|
|
||||||
$snapshotId = (int) $snapshot->getKey();
|
|
||||||
|
|
||||||
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||||
? CarbonImmutable::instance($snapshot->captured_at)
|
? CarbonImmutable::instance($snapshot->captured_at)
|
||||||
: null;
|
: null;
|
||||||
@ -364,7 +309,6 @@ public function handle(
|
|||||||
static fn (array $item): array => [
|
static fn (array $item): array => [
|
||||||
'policy_type' => (string) $item['policy_type'],
|
'policy_type' => (string) $item['policy_type'],
|
||||||
'subject_external_id' => (string) $item['subject_external_id'],
|
'subject_external_id' => (string) $item['subject_external_id'],
|
||||||
'subject_key' => (string) $item['subject_key'],
|
|
||||||
],
|
],
|
||||||
$currentItems,
|
$currentItems,
|
||||||
));
|
));
|
||||||
@ -390,7 +334,6 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$phaseResult = [];
|
$phaseResult = [];
|
||||||
$phaseGaps = [];
|
$phaseGaps = [];
|
||||||
$phaseGapSubjects = [];
|
|
||||||
$resumeToken = null;
|
$resumeToken = null;
|
||||||
|
|
||||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||||
@ -419,7 +362,6 @@ public function handle(
|
|||||||
|
|
||||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||||
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
$phaseGaps = is_array($phaseResult['gaps'] ?? null) ? $phaseResult['gaps'] : [];
|
||||||
$phaseGapSubjects = is_array($phaseResult['gap_subjects'] ?? null) ? $phaseResult['gap_subjects'] : [];
|
|
||||||
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
$resumeToken = is_string($phaseResult['resume_token'] ?? null) ? $phaseResult['resume_token'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -499,12 +441,6 @@ public function handle(
|
|||||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||||
$gapsCount = array_sum($gapsByReason);
|
$gapsCount = array_sum($gapsByReason);
|
||||||
|
|
||||||
$gapSubjects = $this->collectGapSubjects(
|
|
||||||
ambiguousKeys: $ambiguousKeys,
|
|
||||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
|
||||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
'total' => count($driftResults),
|
'total' => count($driftResults),
|
||||||
'processed' => count($driftResults),
|
'processed' => count($driftResults),
|
||||||
@ -582,7 +518,6 @@ public function handle(
|
|||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
'by_reason' => $gapsByReason,
|
'by_reason' => $gapsByReason,
|
||||||
...$gapsByReason,
|
...$gapsByReason,
|
||||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
|
||||||
],
|
],
|
||||||
'resume_token' => $resumeToken,
|
'resume_token' => $resumeToken,
|
||||||
'coverage' => [
|
'coverage' => [
|
||||||
@ -610,10 +545,6 @@ public function handle(
|
|||||||
'findings_resolved' => $resolvedCount,
|
'findings_resolved' => $resolvedCount,
|
||||||
'severity_breakdown' => $severityBreakdown,
|
'severity_breakdown' => $severityBreakdown,
|
||||||
];
|
];
|
||||||
$updatedContext = $this->withCompareReasonTranslation(
|
|
||||||
$updatedContext,
|
|
||||||
$reasonCode?->value,
|
|
||||||
);
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
$this->auditCompleted(
|
$this->auditCompleted(
|
||||||
@ -859,7 +790,6 @@ private function completeWithCoverageWarning(
|
|||||||
'findings_resolved' => 0,
|
'findings_resolved' => 0,
|
||||||
'severity_breakdown' => [],
|
'severity_breakdown' => [],
|
||||||
];
|
];
|
||||||
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
|
||||||
|
|
||||||
$this->operationRun->update(['context' => $updatedContext]);
|
$this->operationRun->update(['context' => $updatedContext]);
|
||||||
|
|
||||||
@ -966,34 +896,6 @@ private function loadBaselineItems(int $snapshotId, array $policyTypes): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function withCompareReasonTranslation(array $context, ?string $reasonCode): array
|
|
||||||
{
|
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
unset($context['reason_translation'], $context['next_steps']);
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
|
||||||
|
|
||||||
if ($translation === null) {
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context['reason_translation'] = $translation->toArray();
|
|
||||||
$context['reason_code'] = $reasonCode;
|
|
||||||
|
|
||||||
if ($translation->toLegacyNextSteps() !== []) {
|
|
||||||
$context['next_steps'] = $translation->toLegacyNextSteps();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load current inventory items keyed by "policy_type|subject_key".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1102,38 +1004,6 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
|||||||
return $run instanceof OperationRun ? $run : null;
|
return $run instanceof OperationRun ? $run : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function snapshotBlockedMessage(string $reasonCode): string
|
|
||||||
{
|
|
||||||
return match ($reasonCode) {
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The selected baseline snapshot is still building and cannot be used for compare yet.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The selected baseline snapshot is incomplete and cannot be used for compare.',
|
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is now current, so this historical snapshot is blocked from compare.',
|
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT => 'The selected baseline snapshot is no longer available.',
|
|
||||||
default => 'No consumable baseline snapshot is currently available for compare.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function truthfulTypesFromContext(array $context, BaselineScope $effectiveScope): array
|
|
||||||
{
|
|
||||||
$truthfulTypes = data_get($context, 'effective_scope.truthful_types');
|
|
||||||
|
|
||||||
if (is_array($truthfulTypes)) {
|
|
||||||
$truthfulTypes = array_values(array_filter($truthfulTypes, 'is_string'));
|
|
||||||
|
|
||||||
if ($truthfulTypes !== []) {
|
|
||||||
sort($truthfulTypes, SORT_STRING);
|
|
||||||
|
|
||||||
return $truthfulTypes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $effectiveScope->allTypes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare baseline items vs current inventory and produce drift results.
|
* Compare baseline items vs current inventory and produce drift results.
|
||||||
*
|
*
|
||||||
@ -1166,7 +1036,6 @@ private function computeDrift(
|
|||||||
): array {
|
): array {
|
||||||
$drift = [];
|
$drift = [];
|
||||||
$evidenceGaps = [];
|
$evidenceGaps = [];
|
||||||
$evidenceGapSubjects = [];
|
|
||||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||||
|
|
||||||
@ -1208,7 +1077,6 @@ private function computeDrift(
|
|||||||
if (! is_array($currentItem)) {
|
if (! is_array($currentItem)) {
|
||||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1273,7 +1141,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_current'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1290,14 +1157,12 @@ private function computeDrift(
|
|||||||
if ($isRbacRoleDefinition) {
|
if ($isRbacRoleDefinition) {
|
||||||
if ($baselinePolicyVersionId === null) {
|
if ($baselinePolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($currentPolicyVersionId === null) {
|
if ($currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1311,7 +1176,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($roleDefinitionDiff === null) {
|
if ($roleDefinitionDiff === null) {
|
||||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1392,7 +1256,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_current'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1408,7 +1271,6 @@ private function computeDrift(
|
|||||||
|
|
||||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -1468,7 +1330,6 @@ private function computeDrift(
|
|||||||
return [
|
return [
|
||||||
'drift' => $drift,
|
'drift' => $drift,
|
||||||
'evidence_gaps' => $evidenceGaps,
|
'evidence_gaps' => $evidenceGaps,
|
||||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
|
||||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1980,163 +1841,6 @@ private function mergeGapCounts(array ...$gaps): array
|
|||||||
return $merged;
|
return $merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private const GAP_SUBJECTS_LIMIT = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $ambiguousKeys
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function collectGapSubjects(array $ambiguousKeys, mixed $phaseGapSubjects, mixed $driftGapSubjects): array
|
|
||||||
{
|
|
||||||
$subjects = [];
|
|
||||||
$seen = [];
|
|
||||||
|
|
||||||
if ($ambiguousKeys !== []) {
|
|
||||||
foreach (array_slice($ambiguousKeys, 0, self::GAP_SUBJECTS_LIMIT) as $ambiguousKey) {
|
|
||||||
if (! is_string($ambiguousKey) || $ambiguousKey === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$policyType, $subjectKey] = $this->splitGapSubjectKey($ambiguousKey);
|
|
||||||
|
|
||||||
if ($policyType === null || $subjectKey === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
|
||||||
$record = array_merge($descriptor->toArray(), $this->subjectResolver()->ambiguousMatch($descriptor)->toArray());
|
|
||||||
$fingerprint = md5(json_encode([$record['policy_type'], $record['subject_key'], $record['reason_code']]));
|
|
||||||
|
|
||||||
if (isset($seen[$fingerprint])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seen[$fingerprint] = true;
|
|
||||||
$subjects[] = $record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->normalizeStructuredGapSubjects($phaseGapSubjects) as $record) {
|
|
||||||
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
|
||||||
|
|
||||||
if (isset($seen[$fingerprint])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seen[$fingerprint] = true;
|
|
||||||
$subjects[] = $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->normalizeLegacyGapSubjects($driftGapSubjects) as $record) {
|
|
||||||
$fingerprint = md5(json_encode([$record['policy_type'] ?? null, $record['subject_key'] ?? null, $record['reason_code'] ?? null]));
|
|
||||||
|
|
||||||
if (isset($seen[$fingerprint])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seen[$fingerprint] = true;
|
|
||||||
$subjects[] = $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_slice($subjects, 0, self::GAP_SUBJECTS_LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function normalizeStructuredGapSubjects(mixed $value): array
|
|
||||||
{
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects = [];
|
|
||||||
|
|
||||||
foreach ($value as $record) {
|
|
||||||
if (! is_array($record)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($record['policy_type'] ?? null) || ! is_string($record['subject_key'] ?? null) || ! is_string($record['reason_code'] ?? null)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects[] = $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $subjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function normalizeLegacyGapSubjects(mixed $value): array
|
|
||||||
{
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects = [];
|
|
||||||
|
|
||||||
foreach ($value as $reasonCode => $keys) {
|
|
||||||
if (! is_string($reasonCode) || ! is_array($keys)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
if (! is_string($key) || $key === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$policyType, $subjectKey] = $this->splitGapSubjectKey($key);
|
|
||||||
|
|
||||||
if ($policyType === null || $subjectKey === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$descriptor = $this->subjectResolver()->describeForCompare($policyType, subjectKey: $subjectKey);
|
|
||||||
$outcome = match ($reasonCode) {
|
|
||||||
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
|
||||||
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
|
||||||
default => $this->subjectResolver()->captureFailed($descriptor),
|
|
||||||
};
|
|
||||||
|
|
||||||
$record = array_merge($descriptor->toArray(), $outcome->toArray());
|
|
||||||
$record['reason_code'] = $reasonCode;
|
|
||||||
$subjects[] = $record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $subjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: ?string, 1: ?string}
|
|
||||||
*/
|
|
||||||
private function splitGapSubjectKey(string $value): array
|
|
||||||
{
|
|
||||||
$parts = explode('|', $value, 2);
|
|
||||||
|
|
||||||
if (count($parts) !== 2) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
[$policyType, $subjectKey] = $parts;
|
|
||||||
$policyType = trim($policyType);
|
|
||||||
$subjectKey = trim($subjectKey);
|
|
||||||
|
|
||||||
if ($policyType === '' || $subjectKey === '') {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$policyType, $subjectKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function subjectResolver(): SubjectResolver
|
|
||||||
{
|
|
||||||
return app(SubjectResolver::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
* @param array<string, array{subject_external_id: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ComposeTenantReviewJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use BridgesFailedOperationRun;
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantReviewId,
|
|
||||||
public int $operationRunId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(TenantReviewService $service, OperationRunService $operationRuns): void
|
|
||||||
{
|
|
||||||
$review = TenantReview::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->tenantReviewId);
|
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview || ! $operationRun instanceof OperationRun || ! $review->tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
|
||||||
$review->update(['status' => TenantReviewStatus::Draft->value]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$review = $service->compose($review);
|
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$review->update([
|
|
||||||
'status' => TenantReviewStatus::Failed->value,
|
|
||||||
'summary' => array_merge(is_array($review->summary) ? $review->summary : [], [
|
|
||||||
'error' => $throwable->getMessage(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'tenant_review_compose.failed',
|
|
||||||
'message' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs\Concerns;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
trait BridgesFailedOperationRun
|
|
||||||
{
|
|
||||||
public function failed(Throwable $exception): void
|
|
||||||
{
|
|
||||||
$operationRun = $this->failedBridgeOperationRun();
|
|
||||||
|
|
||||||
if (! $operationRun instanceof OperationRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(OperationRunService::class)->bridgeFailedJobFailure($operationRun, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function failedBridgeOperationRun(): ?OperationRun
|
|
||||||
{
|
|
||||||
if (property_exists($this, 'operationRun') && $this->operationRun instanceof OperationRun) {
|
|
||||||
return $this->operationRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property_exists($this, 'run') && $this->run instanceof OperationRun) {
|
|
||||||
return $this->run;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidateIds = [];
|
|
||||||
|
|
||||||
foreach (['operationRunId', 'bulkRunId', 'runId'] as $property) {
|
|
||||||
if (! property_exists($this, $property)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = $this->{$property};
|
|
||||||
|
|
||||||
if (is_numeric($value) && (int) $value > 0) {
|
|
||||||
$candidateIds[] = (int) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_values(array_unique($candidateIds)) as $candidateId) {
|
|
||||||
$operationRun = OperationRun::query()->find($candidateId);
|
|
||||||
|
|
||||||
if ($operationRun instanceof OperationRun) {
|
|
||||||
return $operationRun;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -20,10 +20,6 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -25,10 +25,6 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 420;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@ -1,122 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GenerateEvidenceSnapshotJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $snapshotId,
|
|
||||||
public int $operationRunId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(EvidenceSnapshotService $service, OperationRunService $operationRuns): void
|
|
||||||
{
|
|
||||||
$snapshot = EvidenceSnapshot::query()->with('tenant')->find($this->snapshotId);
|
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot || ! $operationRun instanceof OperationRun || ! $snapshot->tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
|
||||||
$snapshot->update(['status' => EvidenceSnapshotStatus::Generating->value]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
|
||||||
$previousActive = EvidenceSnapshot::query()
|
|
||||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
|
||||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
|
||||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
|
||||||
->whereKeyNot((int) $snapshot->getKey())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$snapshot->items()->delete();
|
|
||||||
|
|
||||||
foreach ($payload['items'] as $item) {
|
|
||||||
$snapshot->items()->create([
|
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
|
||||||
'workspace_id' => (int) $snapshot->workspace_id,
|
|
||||||
'dimension_key' => $item['dimension_key'],
|
|
||||||
'state' => $item['state'],
|
|
||||||
'required' => $item['required'],
|
|
||||||
'source_kind' => $item['source_kind'],
|
|
||||||
'source_record_type' => $item['source_record_type'],
|
|
||||||
'source_record_id' => $item['source_record_id'],
|
|
||||||
'source_fingerprint' => $item['source_fingerprint'],
|
|
||||||
'measured_at' => $item['measured_at'],
|
|
||||||
'freshness_at' => $item['freshness_at'],
|
|
||||||
'summary_payload' => $item['summary_payload'],
|
|
||||||
'sort_order' => $item['sort_order'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
|
||||||
$previousActive->update([
|
|
||||||
'status' => EvidenceSnapshotStatus::Superseded->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot->update([
|
|
||||||
'fingerprint' => $payload['fingerprint'],
|
|
||||||
'previous_fingerprint' => $previousActive?->fingerprint,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => $payload['completeness'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
'summary' => $payload['summary'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$snapshot->update([
|
|
||||||
'status' => EvidenceSnapshotStatus::Failed->value,
|
|
||||||
'summary' => [
|
|
||||||
'error' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'evidence_snapshot_generation.failed',
|
|
||||||
'message' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,12 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Services\Intune\SecretClassificationService;
|
use App\Services\Intune\SecretClassificationService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
@ -28,10 +27,6 @@ class GenerateReviewPackJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $reviewPackId,
|
public int $reviewPackId,
|
||||||
public int $operationRunId,
|
public int $operationRunId,
|
||||||
@ -39,7 +34,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): void
|
public function handle(OperationRunService $operationRunService): void
|
||||||
{
|
{
|
||||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId);
|
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||||
|
|
||||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||||
@ -59,20 +54,12 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshot = $reviewPack->evidenceSnapshot;
|
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
||||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark running via OperationRunService (auto-sets started_at)
|
// Mark running via OperationRunService (auto-sets started_at)
|
||||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||||
|
|
||||||
@ -80,44 +67,60 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||||
{
|
{
|
||||||
$review = $reviewPack->tenantReview;
|
|
||||||
|
|
||||||
if ($review instanceof TenantReview) {
|
|
||||||
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$options = $reviewPack->options ?? [];
|
$options = $reviewPack->options ?? [];
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||||
$items = $snapshot->items->keyBy('dimension_key');
|
$tenantId = (int) $tenant->getKey();
|
||||||
$findingsPayload = $this->itemSummaryPayload($items->get('findings_summary'));
|
|
||||||
$permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture'));
|
|
||||||
$entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles'));
|
|
||||||
$operationsPayload = $this->itemSummaryPayload($items->get('operations_summary'));
|
|
||||||
$riskAcceptance = is_array($snapshot->summary['risk_acceptance'] ?? null)
|
|
||||||
? $snapshot->summary['risk_acceptance']
|
|
||||||
: (is_array($findingsPayload['risk_acceptance'] ?? null) ? $findingsPayload['risk_acceptance'] : []);
|
|
||||||
|
|
||||||
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
// 1. Collect StoredReports
|
||||||
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
$storedReports = StoredReport::query()
|
||||||
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
->where('tenant_id', $tenantId)
|
||||||
$dataFreshness = $this->computeDataFreshness($items);
|
->whereIn('report_type', [
|
||||||
|
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||||
|
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->keyBy('report_type');
|
||||||
|
|
||||||
|
// 2. Collect open findings
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->orderBy('severity')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 3. Collect tenant hardening fields
|
||||||
|
$hardening = [
|
||||||
|
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||||
|
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||||
|
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||||
|
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||||
|
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Collect recent OperationRuns (30 days)
|
||||||
|
$recentOperations = $includeOperations
|
||||||
|
? OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
// 5. Data freshness
|
||||||
|
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||||
|
|
||||||
// 6. Build file map
|
// 6. Build file map
|
||||||
$fileMap = $this->buildFileMap(
|
$fileMap = $this->buildFileMap(
|
||||||
|
storedReports: $storedReports,
|
||||||
findings: $findings,
|
findings: $findings,
|
||||||
hardening: $hardening,
|
hardening: $hardening,
|
||||||
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
|
||||||
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
|
||||||
recentOperations: $recentOperations,
|
recentOperations: $recentOperations,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
snapshot: $snapshot,
|
|
||||||
dataFreshness: $dataFreshness,
|
dataFreshness: $dataFreshness,
|
||||||
riskAcceptance: $riskAcceptance,
|
|
||||||
includePii: $includePii,
|
includePii: $includePii,
|
||||||
includeOperations: $includeOperations,
|
includeOperations: $includeOperations,
|
||||||
);
|
);
|
||||||
@ -151,24 +154,16 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
|
|
||||||
// 11. Compute summary
|
// 11. Compute summary
|
||||||
$summary = [
|
$summary = [
|
||||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
'finding_count' => $findings->count(),
|
||||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
'report_count' => $storedReports->count(),
|
||||||
'operation_count' => $recentOperations->count(),
|
'operation_count' => $recentOperations->count(),
|
||||||
'data_freshness' => $dataFreshness,
|
'data_freshness' => $dataFreshness,
|
||||||
'risk_acceptance' => $riskAcceptance,
|
|
||||||
'evidence_resolution' => [
|
|
||||||
'outcome' => 'resolved',
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 12. Update ReviewPack
|
// 12. Update ReviewPack
|
||||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||||
$reviewPack->update([
|
$reviewPack->update([
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
'status' => ReviewPackStatus::Ready->value,
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
'fingerprint' => $fingerprint,
|
||||||
'sha256' => $sha256,
|
'sha256' => $sha256,
|
||||||
'file_size' => $fileSize,
|
'file_size' => $fileSize,
|
||||||
@ -188,113 +183,18 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function executeReviewDerivedGeneration(
|
|
||||||
ReviewPack $reviewPack,
|
|
||||||
TenantReview $review,
|
|
||||||
OperationRun $operationRun,
|
|
||||||
Tenant $tenant,
|
|
||||||
EvidenceSnapshot $snapshot,
|
|
||||||
OperationRunService $operationRunService,
|
|
||||||
): void {
|
|
||||||
$options = $reviewPack->options ?? [];
|
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
||||||
|
|
||||||
$fileMap = $this->buildReviewDerivedFileMap(
|
|
||||||
review: $review,
|
|
||||||
tenant: $tenant,
|
|
||||||
snapshot: $snapshot,
|
|
||||||
includePii: $includePii,
|
|
||||||
includeOperations: $includeOperations,
|
|
||||||
);
|
|
||||||
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->assembleZip($tempFile, $fileMap);
|
|
||||||
|
|
||||||
$sha256 = hash_file('sha256', $tempFile);
|
|
||||||
$fileSize = filesize($tempFile);
|
|
||||||
$filePath = sprintf(
|
|
||||||
'review-packs/%s/review-%d-%s.zip',
|
|
||||||
$tenant->external_id,
|
|
||||||
(int) $review->getKey(),
|
|
||||||
now()->format('Y-m-d-His'),
|
|
||||||
);
|
|
||||||
|
|
||||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
||||||
} finally {
|
|
||||||
if (file_exists($tempFile)) {
|
|
||||||
unlink($tempFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
|
||||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$summary = [
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'review_status' => (string) $review->status,
|
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
|
||||||
'section_count' => $review->sections->count(),
|
|
||||||
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
|
||||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
|
||||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
|
||||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
|
||||||
'evidence_resolution' => [
|
|
||||||
'outcome' => 'resolved',
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
|
||||||
$reviewPack->update([
|
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'sha256' => $sha256,
|
|
||||||
'file_size' => $fileSize,
|
|
||||||
'file_path' => $filePath,
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
'generated_at' => now(),
|
|
||||||
'expires_at' => now()->addDays($retentionDays),
|
|
||||||
'summary' => $summary,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$review->update([
|
|
||||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
||||||
'summary' => array_merge($reviewSummary, [
|
|
||||||
'has_ready_export' => true,
|
|
||||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||||
|
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||||
* @return array<string, ?string>
|
* @return array<string, ?string>
|
||||||
*/
|
*/
|
||||||
private function computeDataFreshness($items): array
|
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||||
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||||
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||||
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,15 +204,12 @@ private function computeDataFreshness($items): array
|
|||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function buildFileMap(
|
private function buildFileMap(
|
||||||
|
$storedReports,
|
||||||
$findings,
|
$findings,
|
||||||
array $hardening,
|
array $hardening,
|
||||||
array $permissionPosture,
|
|
||||||
array $entraAdminRoles,
|
|
||||||
$recentOperations,
|
$recentOperations,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
EvidenceSnapshot $snapshot,
|
|
||||||
array $dataFreshness,
|
array $dataFreshness,
|
||||||
array $riskAcceptance,
|
|
||||||
bool $includePii,
|
bool $includePii,
|
||||||
bool $includeOperations,
|
bool $includeOperations,
|
||||||
): array {
|
): array {
|
||||||
@ -330,12 +227,6 @@ private function buildFileMap(
|
|||||||
'tenant_id' => $tenant->external_id,
|
'tenant_id' => $tenant->external_id,
|
||||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
'evidence_snapshot' => [
|
|
||||||
'id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
||||||
],
|
|
||||||
'redaction_integrity' => [
|
'redaction_integrity' => [
|
||||||
'protected_values_hidden' => true,
|
'protected_values_hidden' => true,
|
||||||
'note' => RedactionIntegrity::protectedValueNote(),
|
'note' => RedactionIntegrity::protectedValueNote(),
|
||||||
@ -350,14 +241,16 @@ private function buildFileMap(
|
|||||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||||
|
|
||||||
// reports/entra_admin_roles.json
|
// reports/entra_admin_roles.json
|
||||||
|
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||||
$this->redactReportPayload($entraAdminRoles, $includePii),
|
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||||
);
|
);
|
||||||
|
|
||||||
// reports/permission_posture.json
|
// reports/permission_posture.json
|
||||||
|
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||||
$files['reports/permission_posture.json'] = json_encode(
|
$files['reports/permission_posture.json'] = json_encode(
|
||||||
$this->redactReportPayload($permissionPosture, $includePii),
|
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -365,10 +258,8 @@ private function buildFileMap(
|
|||||||
$files['summary.json'] = json_encode([
|
$files['summary.json'] = json_encode([
|
||||||
'data_freshness' => $dataFreshness,
|
'data_freshness' => $dataFreshness,
|
||||||
'finding_count' => $findings->count(),
|
'finding_count' => $findings->count(),
|
||||||
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
'report_count' => $storedReports->count(),
|
||||||
'operation_count' => $recentOperations->count(),
|
'operation_count' => $recentOperations->count(),
|
||||||
'risk_acceptance' => $riskAcceptance,
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
return $files;
|
return $files;
|
||||||
@ -382,11 +273,10 @@ private function buildFileMap(
|
|||||||
private function buildFindingsCsv($findings, bool $includePii): string
|
private function buildFindingsCsv($findings, bool $includePii): string
|
||||||
{
|
{
|
||||||
$handle = fopen('php://temp', 'r+');
|
$handle = fopen('php://temp', 'r+');
|
||||||
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||||
|
|
||||||
foreach ($findings as $finding) {
|
foreach ($findings as $finding) {
|
||||||
$row = $finding instanceof Finding
|
fputcsv($handle, [
|
||||||
? [
|
|
||||||
$finding->id,
|
$finding->id,
|
||||||
$finding->finding_type,
|
$finding->finding_type,
|
||||||
$finding->severity,
|
$finding->severity,
|
||||||
@ -395,20 +285,6 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
|||||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||||
$finding->created_at?->toIso8601String(),
|
$finding->created_at?->toIso8601String(),
|
||||||
$finding->updated_at?->toIso8601String(),
|
$finding->updated_at?->toIso8601String(),
|
||||||
]
|
|
||||||
: [
|
|
||||||
$finding['id'] ?? '',
|
|
||||||
$finding['finding_type'] ?? '',
|
|
||||||
$finding['severity'] ?? '',
|
|
||||||
$finding['status'] ?? '',
|
|
||||||
$includePii ? ($finding['title'] ?? '') : '[REDACTED]',
|
|
||||||
$includePii ? ($finding['description'] ?? '') : '[REDACTED]',
|
|
||||||
$finding['created_at'] ?? '',
|
|
||||||
$finding['updated_at'] ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->writeCsvRow($handle, [
|
|
||||||
...$row,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,11 +301,10 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
|||||||
private function buildOperationsCsv($operations, bool $includePii): string
|
private function buildOperationsCsv($operations, bool $includePii): string
|
||||||
{
|
{
|
||||||
$handle = fopen('php://temp', 'r+');
|
$handle = fopen('php://temp', 'r+');
|
||||||
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||||
|
|
||||||
foreach ($operations as $operation) {
|
foreach ($operations as $operation) {
|
||||||
$row = $operation instanceof OperationRun
|
fputcsv($handle, [
|
||||||
? [
|
|
||||||
$operation->id,
|
$operation->id,
|
||||||
$operation->type,
|
$operation->type,
|
||||||
$operation->status,
|
$operation->status,
|
||||||
@ -437,19 +312,6 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
|||||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||||
$operation->started_at?->toIso8601String(),
|
$operation->started_at?->toIso8601String(),
|
||||||
$operation->completed_at?->toIso8601String(),
|
$operation->completed_at?->toIso8601String(),
|
||||||
]
|
|
||||||
: [
|
|
||||||
$operation['id'] ?? '',
|
|
||||||
$operation['type'] ?? '',
|
|
||||||
$operation['status'] ?? '',
|
|
||||||
$operation['outcome'] ?? '',
|
|
||||||
$includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]',
|
|
||||||
$operation['started_at'] ?? '',
|
|
||||||
$operation['completed_at'] ?? '',
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->writeCsvRow($handle, [
|
|
||||||
...$row,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -460,15 +322,6 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
|||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param resource $handle
|
|
||||||
* @param array<int, mixed> $row
|
|
||||||
*/
|
|
||||||
private function writeCsvRow($handle, array $row): void
|
|
||||||
{
|
|
||||||
fputcsv($handle, $row, ',', '"', '\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redact PII from a report payload.
|
* Redact PII from a report payload.
|
||||||
*
|
*
|
||||||
@ -578,98 +431,9 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
|||||||
$zip->close();
|
$zip->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function buildReviewDerivedFileMap(
|
|
||||||
TenantReview $review,
|
|
||||||
Tenant $tenant,
|
|
||||||
EvidenceSnapshot $snapshot,
|
|
||||||
bool $includePii,
|
|
||||||
bool $includeOperations,
|
|
||||||
): array {
|
|
||||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
|
|
||||||
$sections = $review->sections
|
|
||||||
->filter(fn (mixed $section): bool => $includeOperations || $section->section_key !== 'operations_health')
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$files = [
|
|
||||||
'metadata.json' => json_encode([
|
|
||||||
'version' => '1.0',
|
|
||||||
'tenant_id' => $tenant->external_id,
|
|
||||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
|
||||||
'generated_at' => now()->toIso8601String(),
|
|
||||||
'tenant_review' => [
|
|
||||||
'id' => (int) $review->getKey(),
|
|
||||||
'status' => (string) $review->status,
|
|
||||||
'completeness_state' => (string) $review->completeness_state,
|
|
||||||
'published_at' => $review->published_at?->toIso8601String(),
|
|
||||||
'fingerprint' => (string) $review->fingerprint,
|
|
||||||
],
|
|
||||||
'evidence_snapshot' => [
|
|
||||||
'id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
|
||||||
],
|
|
||||||
'options' => [
|
|
||||||
'include_pii' => $includePii,
|
|
||||||
'include_operations' => $includeOperations,
|
|
||||||
],
|
|
||||||
'redaction_integrity' => [
|
|
||||||
'protected_values_hidden' => true,
|
|
||||||
'note' => RedactionIntegrity::protectedValueNote(),
|
|
||||||
],
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
||||||
'summary.json' => json_encode($this->redactReportPayload(array_merge([
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'review_status' => (string) $review->status,
|
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
|
||||||
], $reviewSummary), $includePii), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
||||||
'sections.json' => json_encode($sections->map(function ($section) use ($includePii): array {
|
|
||||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
||||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'section_key' => (string) $section->section_key,
|
|
||||||
'title' => (string) $section->title,
|
|
||||||
'sort_order' => (int) $section->sort_order,
|
|
||||||
'required' => (bool) $section->required,
|
|
||||||
'completeness_state' => (string) $section->completeness_state,
|
|
||||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
||||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
||||||
];
|
|
||||||
})->all(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($sections as $section) {
|
|
||||||
$renderPayload = is_array($section->render_payload) ? $section->render_payload : [];
|
|
||||||
$summaryPayload = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
||||||
$filename = sprintf('sections/%02d-%s.json', (int) $section->sort_order, (string) $section->section_key);
|
|
||||||
|
|
||||||
$files[$filename] = json_encode([
|
|
||||||
'title' => (string) $section->title,
|
|
||||||
'completeness_state' => (string) $section->completeness_state,
|
|
||||||
'summary_payload' => $this->redactReportPayload($summaryPayload, $includePii),
|
|
||||||
'render_payload' => $this->redactReportPayload($renderPayload, $includePii),
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||||
{
|
{
|
||||||
$reviewPack->update([
|
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||||
'status' => ReviewPackStatus::Failed->value,
|
|
||||||
'summary' => array_merge($reviewPack->summary ?? [], [
|
|
||||||
'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [
|
|
||||||
'outcome' => $reasonCode,
|
|
||||||
'reasons' => [mb_substr($errorMessage, 0, 500)],
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
@ -680,13 +444,4 @@ private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun,
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function itemSummaryPayload(mixed $item): array
|
|
||||||
{
|
|
||||||
if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item->summary_payload;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,10 +40,6 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
public int $timeout = 300;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compatibility-only legacy field.
|
* Compatibility-only legacy field.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -25,15 +24,7 @@
|
|||||||
|
|
||||||
class RunInventorySyncJob implements ShouldQueue
|
class RunInventorySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -22,15 +21,7 @@
|
|||||||
|
|
||||||
class SyncPoliciesJob implements ShouldQueue
|
class SyncPoliciesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use BridgesFailedOperationRun;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
use Dispatchable;
|
|
||||||
use InteractsWithQueue;
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public int $timeout = 180;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
|||||||
@ -20,10 +20,6 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
public int $timeout = 240;
|
|
||||||
|
|
||||||
public bool $failOnTimeout = true;
|
|
||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,254 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
|
||||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Tables\TableComponent;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class BaselineCompareEvidenceGapTable extends TableComponent
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public array $gapRows = [];
|
|
||||||
|
|
||||||
public string $context = 'default';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array<string, mixed>> $buckets
|
|
||||||
*/
|
|
||||||
public function mount(array $buckets = [], string $context = 'default'): void
|
|
||||||
{
|
|
||||||
$this->gapRows = BaselineCompareEvidenceGapDetails::tableRows($buckets);
|
|
||||||
$this->context = $context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->queryStringIdentifier('baselineCompareEvidenceGap'.Str::studly($this->context))
|
|
||||||
->defaultSort('reason_label')
|
|
||||||
->defaultPaginationPageOption(10)
|
|
||||||
->paginated(TablePaginationProfiles::picker())
|
|
||||||
->searchable()
|
|
||||||
->searchPlaceholder(__('baseline-compare.evidence_gap_search_placeholder'))
|
|
||||||
->records(function (
|
|
||||||
?string $sortColumn,
|
|
||||||
?string $sortDirection,
|
|
||||||
?string $search,
|
|
||||||
array $filters,
|
|
||||||
int $page,
|
|
||||||
int $recordsPerPage
|
|
||||||
): LengthAwarePaginator {
|
|
||||||
$rows = $this->filterRows(
|
|
||||||
rows: collect($this->gapRows),
|
|
||||||
search: $search,
|
|
||||||
filters: $filters,
|
|
||||||
);
|
|
||||||
|
|
||||||
$rows = $this->sortRows(
|
|
||||||
rows: $rows,
|
|
||||||
sortColumn: $sortColumn,
|
|
||||||
sortDirection: $sortDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->paginateRows(
|
|
||||||
rows: $rows,
|
|
||||||
page: $page,
|
|
||||||
recordsPerPage: $recordsPerPage,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('reason_code')
|
|
||||||
->label(__('baseline-compare.evidence_gap_reason'))
|
|
||||||
->options(BaselineCompareEvidenceGapDetails::reasonFilterOptions($this->gapRows)),
|
|
||||||
SelectFilter::make('policy_type')
|
|
||||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
|
||||||
->options(BaselineCompareEvidenceGapDetails::policyTypeFilterOptions($this->gapRows)),
|
|
||||||
SelectFilter::make('subject_class')
|
|
||||||
->label(__('baseline-compare.evidence_gap_subject_class'))
|
|
||||||
->options(BaselineCompareEvidenceGapDetails::subjectClassFilterOptions($this->gapRows)),
|
|
||||||
SelectFilter::make('operator_action_category')
|
|
||||||
->label(__('baseline-compare.evidence_gap_next_action'))
|
|
||||||
->options(BaselineCompareEvidenceGapDetails::actionCategoryFilterOptions($this->gapRows)),
|
|
||||||
])
|
|
||||||
->striped()
|
|
||||||
->deferLoading(! app()->runningUnitTests())
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('reason_label')
|
|
||||||
->label(__('baseline-compare.evidence_gap_reason'))
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap()
|
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
TextColumn::make('policy_type')
|
|
||||||
->label(__('baseline-compare.evidence_gap_policy_type'))
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
|
|
||||||
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
|
|
||||||
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
|
|
||||||
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType))
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('subject_class_label')
|
|
||||||
->label(__('baseline-compare.evidence_gap_subject_class'))
|
|
||||||
->badge()
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('resolution_outcome_label')
|
|
||||||
->label(__('baseline-compare.evidence_gap_outcome'))
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('operator_action_category_label')
|
|
||||||
->label(__('baseline-compare.evidence_gap_next_action'))
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('subject_key')
|
|
||||||
->label(__('baseline-compare.evidence_gap_subject_key'))
|
|
||||||
->searchable()
|
|
||||||
->sortable()
|
|
||||||
->wrap(),
|
|
||||||
])
|
|
||||||
->actions([])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading(__('baseline-compare.evidence_gap_table_empty_heading'))
|
|
||||||
->emptyStateDescription(__('baseline-compare.evidence_gap_table_empty_description'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('livewire.baseline-compare-evidence-gap-table');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, array<string, mixed>> $rows
|
|
||||||
* @param array<string, mixed> $filters
|
|
||||||
* @return Collection<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function filterRows(Collection $rows, ?string $search, array $filters): Collection
|
|
||||||
{
|
|
||||||
$normalizedSearch = Str::lower(trim((string) $search));
|
|
||||||
$reasonCode = $filters['reason_code']['value'] ?? null;
|
|
||||||
$policyType = $filters['policy_type']['value'] ?? null;
|
|
||||||
$subjectClass = $filters['subject_class']['value'] ?? null;
|
|
||||||
$operatorActionCategory = $filters['operator_action_category']['value'] ?? null;
|
|
||||||
|
|
||||||
return $rows
|
|
||||||
->when(
|
|
||||||
$normalizedSearch !== '',
|
|
||||||
function (Collection $rows) use ($normalizedSearch): Collection {
|
|
||||||
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
|
||||||
return str_contains(Str::lower((string) ($row['search_text'] ?? '')), $normalizedSearch);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
filled($reasonCode),
|
|
||||||
fn (Collection $rows): Collection => $rows->where('reason_code', (string) $reasonCode)
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
filled($policyType),
|
|
||||||
fn (Collection $rows): Collection => $rows->where('policy_type', (string) $policyType)
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
filled($subjectClass),
|
|
||||||
fn (Collection $rows): Collection => $rows->where('subject_class', (string) $subjectClass)
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
filled($operatorActionCategory),
|
|
||||||
fn (Collection $rows): Collection => $rows->where('operator_action_category', (string) $operatorActionCategory)
|
|
||||||
)
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, array<string, mixed>> $rows
|
|
||||||
* @return Collection<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
|
||||||
{
|
|
||||||
if (! filled($sortColumn)) {
|
|
||||||
return $rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
|
||||||
|
|
||||||
return $rows->sortBy(
|
|
||||||
fn (array $row): string => (string) ($row[$sortColumn] ?? ''),
|
|
||||||
SORT_NATURAL | SORT_FLAG_CASE,
|
|
||||||
$direction === 'desc'
|
|
||||||
)->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, array<string, mixed>> $rows
|
|
||||||
*/
|
|
||||||
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$perPage = max(1, $recordsPerPage);
|
|
||||||
$currentPage = max(1, $page);
|
|
||||||
$total = $rows->count();
|
|
||||||
$items = $rows->forPage($currentPage, $perPage)
|
|
||||||
->values()
|
|
||||||
->map(fn (array $row, int $index): Model => $this->toTableRecord(
|
|
||||||
row: $row,
|
|
||||||
index: (($currentPage - 1) * $perPage) + $index,
|
|
||||||
));
|
|
||||||
|
|
||||||
return new LengthAwarePaginator(
|
|
||||||
$items,
|
|
||||||
$total,
|
|
||||||
$perPage,
|
|
||||||
$currentPage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $row
|
|
||||||
*/
|
|
||||||
private function toTableRecord(array $row, int $index): Model
|
|
||||||
{
|
|
||||||
$record = new class extends Model
|
|
||||||
{
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
public $incrementing = false;
|
|
||||||
|
|
||||||
protected $keyType = 'string';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
protected $table = 'baseline_compare_evidence_gap_rows';
|
|
||||||
};
|
|
||||||
|
|
||||||
$record->forceFill([
|
|
||||||
'id' => implode(':', array_filter([
|
|
||||||
(string) ($row['reason_code'] ?? 'reason'),
|
|
||||||
(string) ($row['policy_type'] ?? 'policy'),
|
|
||||||
(string) ($row['subject_key'] ?? 'subject'),
|
|
||||||
(string) $index,
|
|
||||||
])),
|
|
||||||
...$row,
|
|
||||||
]);
|
|
||||||
$record->exists = true;
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -122,37 +121,6 @@ public function snapshots(): HasMany
|
|||||||
return $this->hasMany(BaselineSnapshot::class);
|
return $this->hasMany(BaselineSnapshot::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolveCurrentConsumableSnapshot(): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
$activeSnapshot = $this->relationLoaded('activeSnapshot')
|
|
||||||
? $this->getRelation('activeSnapshot')
|
|
||||||
: $this->activeSnapshot()->first();
|
|
||||||
|
|
||||||
if ($activeSnapshot instanceof BaselineSnapshot && $activeSnapshot->isConsumable()) {
|
|
||||||
return $activeSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->snapshots()
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveLatestAttemptedSnapshot(): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return $this->snapshots()
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasConsumableSnapshot(): bool
|
|
||||||
{
|
|
||||||
return $this->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantAssignments(): HasMany
|
public function tenantAssignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BaselineTenantAssignment::class);
|
return $this->hasMany(BaselineTenantAssignment::class);
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class BaselineSnapshot extends Model
|
class BaselineSnapshot extends Model
|
||||||
{
|
{
|
||||||
@ -19,20 +13,10 @@ class BaselineSnapshot extends Model
|
|||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
/**
|
protected $casts = [
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
|
||||||
'summary_jsonb' => 'array',
|
'summary_jsonb' => 'array',
|
||||||
'completion_meta_jsonb' => 'array',
|
|
||||||
'captured_at' => 'datetime',
|
'captured_at' => 'datetime',
|
||||||
'completed_at' => 'datetime',
|
|
||||||
'failed_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
public function workspace(): BelongsTo
|
public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
@ -48,100 +32,4 @@ public function items(): HasMany
|
|||||||
{
|
{
|
||||||
return $this->hasMany(BaselineSnapshotItem::class);
|
return $this->hasMany(BaselineSnapshotItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeConsumable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeLatestConsumable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->consumable()
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isConsumable(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBuilding(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Building;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isComplete(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Complete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isIncomplete(): bool
|
|
||||||
{
|
|
||||||
return $this->lifecycleState() === BaselineSnapshotLifecycleState::Incomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markBuilding(array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building,
|
|
||||||
'completed_at' => null,
|
|
||||||
'failed_at' => null,
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markComplete(string $identityHash, array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
if ($this->isIncomplete()) {
|
|
||||||
throw new RuntimeException('Incomplete baseline snapshots cannot transition back to complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->forceFill([
|
|
||||||
'snapshot_identity_hash' => $identityHash,
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete,
|
|
||||||
'completed_at' => now(),
|
|
||||||
'failed_at' => null,
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta($completionMeta),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markIncomplete(?string $reasonCode = null, array $completionMeta = []): void
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete,
|
|
||||||
'completed_at' => null,
|
|
||||||
'failed_at' => now(),
|
|
||||||
'completion_meta_jsonb' => $this->mergedCompletionMeta(array_filter([
|
|
||||||
'finalization_reason_code' => $reasonCode ?? BaselineReasonCodes::SNAPSHOT_INCOMPLETE,
|
|
||||||
...$completionMeta,
|
|
||||||
], static fn (mixed $value): bool => $value !== null)),
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lifecycleState(): BaselineSnapshotLifecycleState
|
|
||||||
{
|
|
||||||
if ($this->lifecycle_state instanceof BaselineSnapshotLifecycleState) {
|
|
||||||
return $this->lifecycle_state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($this->lifecycle_state) && BaselineSnapshotLifecycleState::tryFrom($this->lifecycle_state) instanceof BaselineSnapshotLifecycleState) {
|
|
||||||
return BaselineSnapshotLifecycleState::from($this->lifecycle_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
return BaselineSnapshotLifecycleState::Incomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $completionMeta
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function mergedCompletionMeta(array $completionMeta): array
|
|
||||||
{
|
|
||||||
$existing = is_array($this->completion_meta_jsonb) ? $this->completion_meta_jsonb : [];
|
|
||||||
|
|
||||||
return array_replace($existing, $completionMeta);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class EvidenceSnapshot extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary' => 'array',
|
|
||||||
'generated_at' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<OperationRun, $this>
|
|
||||||
*/
|
|
||||||
public function operationRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function initiator(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<EvidenceSnapshotItem, $this>
|
|
||||||
*/
|
|
||||||
public function items(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(EvidenceSnapshotItem::class)->orderBy('sort_order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function reviewPacks(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ReviewPack::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantReview, $this>
|
|
||||||
*/
|
|
||||||
public function tenantReviews(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeActive(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', EvidenceSnapshotStatus::Active->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeCurrent(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->whereIn('status', [
|
|
||||||
EvidenceSnapshotStatus::Queued->value,
|
|
||||||
EvidenceSnapshotStatus::Generating->value,
|
|
||||||
EvidenceSnapshotStatus::Active->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isCurrent(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
EvidenceSnapshotStatus::Queued->value,
|
|
||||||
EvidenceSnapshotStatus::Generating->value,
|
|
||||||
EvidenceSnapshotStatus::Active->value,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessState(): EvidenceCompletenessState
|
|
||||||
{
|
|
||||||
return EvidenceCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? EvidenceCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class EvidenceSnapshotItem extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required' => 'boolean',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
'freshness_at' => 'datetime',
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'sort_order' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
|
||||||
*/
|
|
||||||
public function snapshot(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(EvidenceSnapshot::class, 'evidence_snapshot_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class Finding extends Model
|
class Finding extends Model
|
||||||
@ -99,14 +98,6 @@ public function closedByUser(): BelongsTo
|
|||||||
return $this->belongsTo(User::class, 'closed_by_user_id');
|
return $this->belongsTo(User::class, 'closed_by_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasOne<FindingException, $this>
|
|
||||||
*/
|
|
||||||
public function findingException(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(FindingException::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -169,58 +160,6 @@ public function hasOpenStatus(): bool
|
|||||||
return self::isOpenStatus($this->status);
|
return self::isOpenStatus($this->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isRiskAccepted(): bool
|
|
||||||
{
|
|
||||||
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledge(User $user): self
|
|
||||||
{
|
|
||||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_ACKNOWLEDGED,
|
|
||||||
'acknowledged_at' => now(),
|
|
||||||
'acknowledged_by_user_id' => $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolve(string $reason): self
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_RESOLVED,
|
|
||||||
'resolved_at' => now(),
|
|
||||||
'resolved_reason' => $reason,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $evidence
|
|
||||||
*/
|
|
||||||
public function reopen(array $evidence): self
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_NEW,
|
|
||||||
'resolved_at' => null,
|
|
||||||
'resolved_reason' => null,
|
|
||||||
'evidence_jsonb' => $evidence,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolvedSubjectDisplayName(): ?string
|
public function resolvedSubjectDisplayName(): ?string
|
||||||
{
|
{
|
||||||
$displayName = $this->getAttribute('subject_display_name');
|
$displayName = $this->getAttribute('subject_display_name');
|
||||||
|
|||||||
@ -1,243 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class FindingException extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string STATUS_PENDING = 'pending';
|
|
||||||
|
|
||||||
public const string STATUS_ACTIVE = 'active';
|
|
||||||
|
|
||||||
public const string STATUS_EXPIRING = 'expiring';
|
|
||||||
|
|
||||||
public const string STATUS_EXPIRED = 'expired';
|
|
||||||
|
|
||||||
public const string STATUS_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string STATUS_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
public const string STATUS_SUPERSEDED = 'superseded';
|
|
||||||
|
|
||||||
public const string VALIDITY_VALID = 'valid';
|
|
||||||
|
|
||||||
public const string VALIDITY_EXPIRING = 'expiring';
|
|
||||||
|
|
||||||
public const string VALIDITY_EXPIRED = 'expired';
|
|
||||||
|
|
||||||
public const string VALIDITY_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
public const string VALIDITY_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string VALIDITY_MISSING_SUPPORT = 'missing_support';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'requested_at' => 'datetime',
|
|
||||||
'approved_at' => 'datetime',
|
|
||||||
'rejected_at' => 'datetime',
|
|
||||||
'revoked_at' => 'datetime',
|
|
||||||
'effective_from' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'review_due_at' => 'datetime',
|
|
||||||
'evidence_summary' => 'array',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Finding, $this>
|
|
||||||
*/
|
|
||||||
public function finding(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Finding::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function requester(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function owner(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'owner_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function approver(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingExceptionDecision, $this>
|
|
||||||
*/
|
|
||||||
public function currentDecision(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingExceptionDecision::class, 'current_decision_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<FindingExceptionDecision, $this>
|
|
||||||
*/
|
|
||||||
public function decisions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(FindingExceptionDecision::class)
|
|
||||||
->orderBy('decided_at')
|
|
||||||
->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<FindingExceptionEvidenceReference, $this>
|
|
||||||
*/
|
|
||||||
public function evidenceReferences(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(FindingExceptionEvidenceReference::class)
|
|
||||||
->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForFinding(Builder $query, Finding $finding): Builder
|
|
||||||
{
|
|
||||||
return $query->where('finding_id', (int) $finding->getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopePending(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', self::STATUS_PENDING);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeCurrent(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', [
|
|
||||||
self::STATUS_PENDING,
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPending(): bool
|
|
||||||
{
|
|
||||||
return (string) $this->status === self::STATUS_PENDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isActiveLike(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasPriorApproval(): bool
|
|
||||||
{
|
|
||||||
return $this->approved_at !== null
|
|
||||||
&& $this->effective_from !== null
|
|
||||||
&& is_numeric($this->approved_by_user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasValidGovernance(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->current_validity_state, [
|
|
||||||
self::VALIDITY_VALID,
|
|
||||||
self::VALIDITY_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentDecisionType(): ?string
|
|
||||||
{
|
|
||||||
$decision = $this->relationLoaded('currentDecision')
|
|
||||||
? $this->currentDecision
|
|
||||||
: $this->currentDecision()->first();
|
|
||||||
|
|
||||||
return $decision instanceof FindingExceptionDecision
|
|
||||||
? (string) $decision->decision_type
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPendingRenewal(): bool
|
|
||||||
{
|
|
||||||
return $this->isPending()
|
|
||||||
&& $this->hasPriorApproval()
|
|
||||||
&& $this->currentDecisionType() === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requiresFreshDecisionForFinding(Finding $finding): bool
|
|
||||||
{
|
|
||||||
return ! $finding->isRiskAccepted()
|
|
||||||
&& ! $this->isPending()
|
|
||||||
&& $this->hasValidGovernance();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canBeRenewed(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
self::STATUS_EXPIRED,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canBeRevoked(): bool
|
|
||||||
{
|
|
||||||
if ($this->isPendingRenewal()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
class FindingExceptionDecision extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string TYPE_REQUESTED = 'requested';
|
|
||||||
|
|
||||||
public const string TYPE_APPROVED = 'approved';
|
|
||||||
|
|
||||||
public const string TYPE_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string TYPE_RENEWAL_REQUESTED = 'renewal_requested';
|
|
||||||
|
|
||||||
public const string TYPE_RENEWED = 'renewed';
|
|
||||||
|
|
||||||
public const string TYPE_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'effective_from' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'decided_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::updating(static function (): void {
|
|
||||||
throw new LogicException('Finding exception decisions are append-only.');
|
|
||||||
});
|
|
||||||
|
|
||||||
static::deleting(static function (): void {
|
|
||||||
throw new LogicException('Finding exception decisions are append-only.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingException, $this>
|
|
||||||
*/
|
|
||||||
public function exception(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingException::class, 'finding_exception_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function actor(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'actor_user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class FindingExceptionEvidenceReference extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingException, $this>
|
|
||||||
*/
|
|
||||||
public function exception(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingException::class, 'finding_exception_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\Operations\OperationRunFreshnessState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -129,145 +127,4 @@ public function setFinishedAtAttribute(mixed $value): void
|
|||||||
{
|
{
|
||||||
$this->completed_at = $value;
|
$this->completed_at = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isGovernanceArtifactOperation(): bool
|
|
||||||
{
|
|
||||||
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function supportsOperatorExplanation(): bool
|
|
||||||
{
|
|
||||||
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function governanceArtifactFamily(): ?string
|
|
||||||
{
|
|
||||||
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function artifactResultContext(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
|
||||||
|
|
||||||
return array_merge($context, ['result' => $result]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function relatedArtifactId(): ?int
|
|
||||||
{
|
|
||||||
return match ($this->governanceArtifactFamily()) {
|
|
||||||
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
|
||||||
? (int) data_get($this->context, 'result.snapshot_id')
|
|
||||||
: null,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function reconciliation(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
$reconciliation = $context['reconciliation'] ?? null;
|
|
||||||
|
|
||||||
return is_array($reconciliation) ? $reconciliation : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isLifecycleReconciled(): bool
|
|
||||||
{
|
|
||||||
return $this->reconciliation() !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lifecycleReconciliationReasonCode(): ?string
|
|
||||||
{
|
|
||||||
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function freshnessState(): OperationRunFreshnessState
|
|
||||||
{
|
|
||||||
return OperationRunFreshnessState::forRun($this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function baselineGapEnvelope(): array
|
|
||||||
{
|
|
||||||
$context = is_array($this->context) ? $this->context : [];
|
|
||||||
|
|
||||||
return match ((string) $this->type) {
|
|
||||||
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
|
||||||
? data_get($context, 'baseline_compare.evidence_gaps')
|
|
||||||
: [],
|
|
||||||
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
|
||||||
? data_get($context, 'baseline_capture.gaps')
|
|
||||||
: [],
|
|
||||||
default => [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasStructuredBaselineGapPayload(): bool
|
|
||||||
{
|
|
||||||
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($subjects as $subject) {
|
|
||||||
if (! is_array($subject)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
'policy_type',
|
|
||||||
'subject_key',
|
|
||||||
'subject_class',
|
|
||||||
'resolution_path',
|
|
||||||
'resolution_outcome',
|
|
||||||
'reason_code',
|
|
||||||
'operator_action_category',
|
|
||||||
'structural',
|
|
||||||
'retryable',
|
|
||||||
] as $key) {
|
|
||||||
if (! array_key_exists($key, $subject)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasLegacyBaselineGapPayload(): bool
|
|
||||||
{
|
|
||||||
$envelope = $this->baselineGapEnvelope();
|
|
||||||
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
|
|
||||||
|
|
||||||
if (array_key_exists('policy_not_found', $byReason)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$subjects = $envelope['subjects'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($subjects)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! array_is_list($subjects)) {
|
|
||||||
return $subjects !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($subjects === []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $this->hasStructuredBaselineGapPayload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,22 +71,6 @@ public function initiator(): BelongsTo
|
|||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
|
||||||
*/
|
|
||||||
public function evidenceSnapshot(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(EvidenceSnapshot::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<TenantReview, $this>
|
|
||||||
*/
|
|
||||||
public function tenantReview(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Builder<self> $query
|
* @param Builder<self> $query
|
||||||
* @return Builder<self>
|
* @return Builder<self>
|
||||||
|
|||||||
@ -261,21 +261,6 @@ public function auditLogs(): HasMany
|
|||||||
return $this->hasMany(AuditLog::class);
|
return $this->hasMany(AuditLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findingExceptions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(FindingException::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function evidenceSnapshots(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(EvidenceSnapshot::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenantReviews(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function settings(): HasMany
|
public function settings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantSetting::class);
|
return $this->hasMany(TenantSetting::class);
|
||||||
|
|||||||
@ -1,195 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class TenantReview extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary' => 'array',
|
|
||||||
'generated_at' => 'datetime',
|
|
||||||
'published_at' => 'datetime',
|
|
||||||
'archived_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
|
||||||
*/
|
|
||||||
public function evidenceSnapshot(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(EvidenceSnapshot::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<OperationRun, $this>
|
|
||||||
*/
|
|
||||||
public function operationRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function initiator(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function publisher(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'published_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function currentExportReviewPack(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(ReviewPack::class, 'current_export_review_pack_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<self, $this>
|
|
||||||
*/
|
|
||||||
public function supersededByReview(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(self::class, 'superseded_by_review_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<self, $this>
|
|
||||||
*/
|
|
||||||
public function supersededReviews(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(self::class, 'superseded_by_review_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantReviewSection, $this>
|
|
||||||
*/
|
|
||||||
public function sections(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantReviewSection::class)->orderBy('sort_order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function reviewPacks(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ReviewPack::class)->latest('generated_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('workspace_id', $workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopePublished(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', TenantReviewStatus::Published->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeMutable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', [
|
|
||||||
TenantReviewStatus::Draft->value,
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Failed->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function statusEnum(): TenantReviewStatus
|
|
||||||
{
|
|
||||||
return TenantReviewStatus::from((string) $this->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessEnum(): TenantReviewCompletenessState
|
|
||||||
{
|
|
||||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? TenantReviewCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPublished(): bool
|
|
||||||
{
|
|
||||||
return $this->statusEnum()->isPublished();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isMutable(): bool
|
|
||||||
{
|
|
||||||
return $this->statusEnum()->isMutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function publishBlockers(): array
|
|
||||||
{
|
|
||||||
$summary = is_array($this->summary) ? $this->summary : [];
|
|
||||||
$blockers = $summary['publish_blockers'] ?? [];
|
|
||||||
|
|
||||||
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class TenantReviewSection extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required' => 'boolean',
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'render_payload' => 'array',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<TenantReview, $this>
|
|
||||||
*/
|
|
||||||
public function tenantReview(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeRequired(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('required', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessEnum(): TenantReviewCompletenessState
|
|
||||||
{
|
|
||||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? TenantReviewCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -120,10 +119,15 @@ public function tenantRoleValue(Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
$role = $this->tenants()
|
||||||
$resolver = app(CapabilityResolver::class);
|
->whereKey($tenant->getKey())
|
||||||
|
->value('role');
|
||||||
|
|
||||||
return $resolver->getRole($this, $tenant)?->value;
|
if (! is_string($role)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function allowsTenantSync(Tenant $tenant): bool
|
public function allowsTenantSync(Tenant $tenant): bool
|
||||||
@ -141,10 +145,9 @@ public function canAccessTenant(Model $tenant): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
return $this->tenantMemberships()
|
||||||
$resolver = app(CapabilityResolver::class);
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->exists();
|
||||||
return $resolver->isMember($this, $tenant);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
@ -45,14 +44,6 @@ public function toDatabase(object $notifiable): array
|
|||||||
->url($runUrl),
|
->url($runUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $notification->getDatabaseMessage();
|
return $notification->getDatabaseMessage();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
$message['reason_translation'] = $reasonEnvelope->toArray();
|
|
||||||
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,8 +49,8 @@ public function toDatabase(object $notifiable): array
|
|||||||
|
|
||||||
return FilamentNotification::make()
|
return FilamentNotification::make()
|
||||||
->title("{$operationLabel} queued")
|
->title("{$operationLabel} queued")
|
||||||
->body('Queued for execution. Open the run for progress and next steps.')
|
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||||
->info()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EvidenceSnapshotPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EvidenceSnapshot $snapshot): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, EvidenceSnapshot $snapshot): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class FindingExceptionPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = $this->resolvedTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $exception);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function approve(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeCanonicalApproval($user, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reject(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeCanonicalApproval($user, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeCanonicalApproval(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $exception->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId) || $workspaceId !== (int) $exception->workspace_id) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = $tenant->workspace;
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $workspace)) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizedTenantOrNull(User $user, FindingException $exception): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = $this->resolvedTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $exception->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvedTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant && (int) $tenant->workspace_id === $workspaceId ? $tenant : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -61,7 +61,7 @@ public function view(User $user, OperationRun $run): Response|bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||||
->requiredCapabilityForRun($run);
|
->requiredCapabilityForType((string) $run->type);
|
||||||
|
|
||||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class TenantReviewPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function publish(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function archive(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function export(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createNextReview(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeManageAction(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizedTenantOrNull(User $user, TenantReview $review): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = $review->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $review->workspace_id !== (int) $tenant->workspace_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,8 +17,6 @@
|
|||||||
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;
|
||||||
@ -78,9 +76,6 @@ 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(
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
@ -18,7 +17,6 @@
|
|||||||
use App\Policies\AlertRulePolicy;
|
use App\Policies\AlertRulePolicy;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Policies\TenantOnboardingSessionPolicy;
|
use App\Policies\TenantOnboardingSessionPolicy;
|
||||||
use App\Policies\TenantReviewPolicy;
|
|
||||||
use App\Policies\WorkspaceSettingPolicy;
|
use App\Policies\WorkspaceSettingPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -32,7 +30,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
protected $policies = [
|
protected $policies = [
|
||||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||||
TenantReview::class => TenantReviewPolicy::class,
|
|
||||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||||
AlertDestination::class => AlertDestinationPolicy::class,
|
AlertDestination::class => AlertDestinationPolicy::class,
|
||||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
use App\Filament\Pages\WorkspaceOverview;
|
use App\Filament\Pages\WorkspaceOverview;
|
||||||
@ -28,7 +26,6 @@
|
|||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -174,15 +171,12 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
FindingExceptionsQueue::class,
|
|
||||||
ReviewRegister::class,
|
|
||||||
])
|
])
|
||||||
->widgets([
|
->widgets([
|
||||||
AccountWidget::class,
|
AccountWidget::class,
|
||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->unsavedChangesAlerts()
|
->unsavedChangesAlerts()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
@ -203,7 +197,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Filament\System\Pages\Dashboard;
|
use App\Filament\System\Pages\Dashboard;
|
||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -35,7 +34,6 @@ public function panel(Panel $panel): Panel
|
|||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_START,
|
PanelsRenderHook::BODY_START,
|
||||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
@ -61,6 +59,6 @@ public function panel(Panel $panel): Panel
|
|||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||||
])
|
])
|
||||||
->theme(PanelThemeAsset::resolve('resources/css/filament/system/theme.css'));
|
->viteTheme('resources/css/filament/system/theme.css');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,7 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
@ -78,9 +76,6 @@ public function panel(Panel $panel): Panel
|
|||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||||
->resources([
|
|
||||||
TenantReviewResource::class,
|
|
||||||
])
|
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
@ -92,7 +87,6 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
@ -113,7 +107,7 @@ public function panel(Panel $panel): Panel
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (! app()->runningUnitTests()) {
|
if (! app()->runningUnitTests()) {
|
||||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $panel;
|
return $panel;
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\LifecycleReconciliationReason;
|
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@ -152,23 +151,25 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
|||||||
/** @var OperationRunService $runs */
|
/** @var OperationRunService $runs */
|
||||||
$runs = app(OperationRunService::class);
|
$runs = app(OperationRunService::class);
|
||||||
|
|
||||||
$runs->updateRunWithReconciliation(
|
$runs->updateRun(
|
||||||
run: $run,
|
$run,
|
||||||
status: $opStatus,
|
status: $opStatus,
|
||||||
outcome: $opOutcome,
|
outcome: $opOutcome,
|
||||||
summaryCounts: $summaryCounts,
|
summaryCounts: $summaryCounts,
|
||||||
failures: $failures,
|
failures: $failures,
|
||||||
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
||||||
reasonMessage: LifecycleReconciliationReason::AdapterOutOfSync->defaultMessage(),
|
|
||||||
source: 'adapter_reconciler',
|
|
||||||
evidence: [
|
|
||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
|
||||||
'restore_status' => $restoreStatus?->value,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
|
|
||||||
|
$updatedContext = is_array($run->context) ? $run->context : [];
|
||||||
|
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
||||||
|
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
||||||
|
$reconciliation['reason'] = 'adapter_out_of_sync';
|
||||||
|
|
||||||
|
$updatedContext['reconciliation'] = $reconciliation;
|
||||||
|
|
||||||
|
$run->context = $updatedContext;
|
||||||
|
|
||||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||||
$run->started_at = $restoreRun->started_at;
|
$run->started_at = $restoreRun->started_at;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
@ -52,10 +50,6 @@ class RoleCapabilityMap
|
|||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Manager->value => [
|
TenantRole::Manager->value => [
|
||||||
@ -70,8 +64,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
@ -92,10 +84,6 @@ class RoleCapabilityMap
|
|||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
@ -105,7 +93,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
@ -120,14 +107,11 @@ class RoleCapabilityMap
|
|||||||
Capabilities::ENTRA_ROLES_VIEW,
|
Capabilities::ENTRA_ROLES_VIEW,
|
||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Readonly->value => [
|
TenantRole::Readonly->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
@ -139,8 +123,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::ENTRA_ROLES_VIEW,
|
Capabilities::ENTRA_ROLES_VIEW,
|
||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,6 @@ class WorkspaceRoleCapabilityMap
|
|||||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||||
Capabilities::AUDIT_VIEW,
|
Capabilities::AUDIT_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Manager->value => [
|
WorkspaceRole::Manager->value => [
|
||||||
@ -64,7 +63,6 @@ class WorkspaceRoleCapabilityMap
|
|||||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||||
Capabilities::AUDIT_VIEW,
|
Capabilities::AUDIT_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
WorkspaceRole::Operator->value => [
|
WorkspaceRole::Operator->value => [
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
|
||||||
final class BaselineCaptureService
|
final class BaselineCaptureService
|
||||||
@ -23,7 +22,6 @@ final class BaselineCaptureService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,7 +53,7 @@ public function startCapture(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,21 +17,17 @@
|
|||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
|
||||||
|
|
||||||
final class BaselineCompareService
|
final class BaselineCompareService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OperationRunService $runs,
|
private readonly OperationRunService $runs,
|
||||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
|
||||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: bool, run?: OperationRun, reason_code?: string, reason_translation?: array<string, mixed>}
|
* @return array{ok: bool, run?: OperationRun, reason_code?: string}
|
||||||
*/
|
*/
|
||||||
public function startCompare(
|
public function startCompare(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@ -44,45 +40,38 @@ public function startCompare(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $assignment instanceof BaselineTenantAssignment) {
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_NO_ASSIGNMENT);
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_NO_ASSIGNMENT];
|
||||||
}
|
}
|
||||||
|
|
||||||
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
$profile = BaselineProfile::query()->find($assignment->baseline_profile_id);
|
||||||
|
|
||||||
if (! $profile instanceof BaselineProfile) {
|
if (! $profile instanceof BaselineProfile) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE);
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE];
|
||||||
}
|
}
|
||||||
|
|
||||||
$precondition = $this->validatePreconditions($profile);
|
$hasExplicitSnapshotSelection = is_int($baselineSnapshotId) && $baselineSnapshotId > 0;
|
||||||
|
$precondition = $this->validatePreconditions($profile, hasExplicitSnapshotSelection: $hasExplicitSnapshotSelection);
|
||||||
|
|
||||||
if ($precondition !== null) {
|
if ($precondition !== null) {
|
||||||
return $this->failedStart($precondition);
|
return ['ok' => false, 'reason_code' => $precondition];
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedSnapshot = null;
|
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||||
|
|
||||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
if ($snapshotId > 0) {
|
||||||
$selectedSnapshot = BaselineSnapshot::query()
|
$snapshot = BaselineSnapshot::query()
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
->where('workspace_id', (int) $profile->workspace_id)
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
->where('baseline_profile_id', (int) $profile->getKey())
|
||||||
->whereKey((int) $baselineSnapshotId)
|
->whereKey($snapshotId)
|
||||||
->first();
|
->first(['id']);
|
||||||
|
|
||||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
if (! $snapshot instanceof BaselineSnapshot) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
return ['ok' => false, 'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
$snapshotId = (int) $profile->active_snapshot_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$snapshotResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile, $selectedSnapshot);
|
|
||||||
|
|
||||||
if (! ($snapshotResolution['ok'] ?? false)) {
|
|
||||||
return $this->failedStart($snapshotResolution['reason_code'] ?? BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var BaselineSnapshot $snapshot */
|
|
||||||
$snapshot = $snapshotResolution['snapshot'];
|
|
||||||
$snapshotId = (int) $snapshot->getKey();
|
|
||||||
|
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = BaselineScope::fromJsonb(
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||||
);
|
);
|
||||||
@ -103,7 +92,7 @@ public function startCompare(
|
|||||||
],
|
],
|
||||||
'baseline_profile_id' => (int) $profile->getKey(),
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -124,7 +113,7 @@ public function startCompare(
|
|||||||
return ['ok' => true, 'run' => $run];
|
return ['ok' => true, 'run' => $run];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePreconditions(BaselineProfile $profile): ?string
|
private function validatePreconditions(BaselineProfile $profile, bool $hasExplicitSnapshotSelection = false): ?string
|
||||||
{
|
{
|
||||||
if ($profile->status !== BaselineProfileStatus::Active) {
|
if ($profile->status !== BaselineProfileStatus::Active) {
|
||||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||||
@ -134,20 +123,10 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
|||||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||||
|
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
|
||||||
*/
|
|
||||||
private function failedStart(string $reasonCode): array
|
|
||||||
{
|
|
||||||
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'baseline_compare');
|
|
||||||
|
|
||||||
return array_filter([
|
|
||||||
'ok' => false,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
'reason_translation' => $translation?->toArray(),
|
|
||||||
], static fn (mixed $value): bool => $value !== null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,28 +10,22 @@
|
|||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
use App\Support\Baselines\BaselineEvidenceResumeToken;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Baselines\ResolutionOutcomeRecord;
|
|
||||||
use App\Support\Baselines\ResolutionPath;
|
|
||||||
use App\Support\Baselines\SubjectDescriptor;
|
|
||||||
use App\Support\Baselines\SubjectResolver;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class BaselineContentCapturePhase
|
final class BaselineContentCapturePhase
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
private readonly ?SubjectResolver $subjectResolver = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
* Capture baseline-purpose policy versions (content + assignments + scope tags) within a run budget.
|
||||||
*
|
*
|
||||||
* @param list<array{policy_type: string, subject_external_id: string, subject_key?: string}> $subjects
|
* @param list<array{policy_type: string, subject_external_id: string}> $subjects
|
||||||
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
* @param array{max_items_per_run: int, max_concurrency: int, max_retries: int} $budgets
|
||||||
* @return array{
|
* @return array{
|
||||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||||
* gaps: array<string, int>,
|
* gaps: array<string, int>,
|
||||||
* gap_subjects: list<array<string, mixed>>,
|
|
||||||
* resume_token: ?string,
|
* resume_token: ?string,
|
||||||
* captured_versions: array<string, array{
|
* captured_versions: array<string, array{
|
||||||
* policy_type: string,
|
* policy_type: string,
|
||||||
@ -82,8 +76,6 @@ public function capture(
|
|||||||
|
|
||||||
/** @var array<string, int> $gaps */
|
/** @var array<string, int> $gaps */
|
||||||
$gaps = [];
|
$gaps = [];
|
||||||
/** @var list<array<string, mixed>> $gapSubjects */
|
|
||||||
$gapSubjects = [];
|
|
||||||
$capturedVersions = [];
|
$capturedVersions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -95,40 +87,24 @@ public function capture(
|
|||||||
foreach ($chunk as $subject) {
|
foreach ($chunk as $subject) {
|
||||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||||
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
$externalId = trim((string) ($subject['subject_external_id'] ?? ''));
|
||||||
$subjectKey = trim((string) ($subject['subject_key'] ?? ''));
|
|
||||||
$descriptor = $this->resolver()->describeForCapture(
|
|
||||||
$policyType !== '' ? $policyType : 'unknown',
|
|
||||||
$externalId !== '' ? $externalId : null,
|
|
||||||
$subjectKey !== '' ? $subjectKey : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($policyType === '' || $externalId === '') {
|
if ($policyType === '' || $externalId === '') {
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$captureKey = $policyType.'|'.$externalId;
|
$subjectKey = $policyType.'|'.$externalId;
|
||||||
|
|
||||||
if (isset($seen[$captureKey])) {
|
if (isset($seen[$subjectKey])) {
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||||
$stats['skipped']++;
|
$stats['skipped']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$seen[$captureKey] = true;
|
$seen[$subjectKey] = true;
|
||||||
|
|
||||||
if (
|
|
||||||
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
|
||||||
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
|
||||||
) {
|
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
|
||||||
$stats['skipped']++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = Policy::query()
|
$policy = Policy::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -137,7 +113,7 @@ public function capture(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->missingExpectedRecord($descriptor));
|
$gaps['policy_not_found'] = ($gaps['policy_not_found'] ?? 0) + 1;
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -176,7 +152,7 @@ public function capture(
|
|||||||
$version = $result['version'] ?? null;
|
$version = $result['version'] ?? null;
|
||||||
|
|
||||||
if ($version instanceof PolicyVersion) {
|
if ($version instanceof PolicyVersion) {
|
||||||
$capturedVersions[$captureKey] = [
|
$capturedVersions[$subjectKey] = [
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'subject_external_id' => $externalId,
|
'subject_external_id' => $externalId,
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
@ -202,10 +178,10 @@ public function capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($isThrottled) {
|
if ($isThrottled) {
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||||
$stats['throttled']++;
|
$stats['throttled']++;
|
||||||
} else {
|
} else {
|
||||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||||
$stats['failed']++;
|
$stats['failed']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,22 +201,7 @@ public function capture(
|
|||||||
|
|
||||||
$remainingCount = max(0, count($subjects) - $processed);
|
$remainingCount = max(0, count($subjects) - $processed);
|
||||||
if ($remainingCount > 0) {
|
if ($remainingCount > 0) {
|
||||||
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||||
$remainingPolicyType = trim((string) ($remainingSubject['policy_type'] ?? ''));
|
|
||||||
$remainingExternalId = trim((string) ($remainingSubject['subject_external_id'] ?? ''));
|
|
||||||
$remainingSubjectKey = trim((string) ($remainingSubject['subject_key'] ?? ''));
|
|
||||||
|
|
||||||
if ($remainingPolicyType === '' || $remainingExternalId === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remainingDescriptor = $this->resolver()->describeForCapture(
|
|
||||||
$remainingPolicyType,
|
|
||||||
$remainingExternalId,
|
|
||||||
$remainingSubjectKey !== '' ? $remainingSubjectKey : null,
|
|
||||||
);
|
|
||||||
$this->recordGap($gaps, $gapSubjects, $remainingDescriptor, $this->resolver()->budgetExhausted($remainingDescriptor));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,27 +210,11 @@ public function capture(
|
|||||||
return [
|
return [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
'gaps' => $gaps,
|
'gaps' => $gaps,
|
||||||
'gap_subjects' => $gapSubjects,
|
|
||||||
'resume_token' => $resumeTokenOut,
|
'resume_token' => $resumeTokenOut,
|
||||||
'captured_versions' => $capturedVersions,
|
'captured_versions' => $capturedVersions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, int> $gaps
|
|
||||||
* @param list<array<string, mixed>> $gapSubjects
|
|
||||||
*/
|
|
||||||
private function recordGap(array &$gaps, array &$gapSubjects, SubjectDescriptor $descriptor, ResolutionOutcomeRecord $outcome): void
|
|
||||||
{
|
|
||||||
$gaps[$outcome->reasonCode] = ($gaps[$outcome->reasonCode] ?? 0) + 1;
|
|
||||||
$gapSubjects[] = array_merge($descriptor->toArray(), $outcome->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolver(): SubjectResolver
|
|
||||||
{
|
|
||||||
return $this->subjectResolver ?? app(SubjectResolver::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function retryDelayMs(int $attempt): int
|
private function retryDelayMs(int $attempt): int
|
||||||
{
|
{
|
||||||
$attempt = max(0, $attempt);
|
$attempt = max(0, $attempt);
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Baselines;
|
|
||||||
|
|
||||||
final class BaselineSnapshotItemNormalizer
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $items
|
|
||||||
* @return array{items: list<array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}>, duplicates: int}
|
|
||||||
*/
|
|
||||||
public function deduplicate(array $items): array
|
|
||||||
{
|
|
||||||
$uniqueItems = [];
|
|
||||||
$duplicates = 0;
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$key = trim((string) ($item['subject_type'] ?? '')).'|'.trim((string) ($item['subject_external_id'] ?? ''));
|
|
||||||
|
|
||||||
if ($key === '|') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! array_key_exists($key, $uniqueItems)) {
|
|
||||||
$uniqueItems[$key] = $item;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$duplicates++;
|
|
||||||
|
|
||||||
if ($this->shouldReplace($uniqueItems[$key], $item)) {
|
|
||||||
$uniqueItems[$key] = $item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'items' => array_values($uniqueItems),
|
|
||||||
'duplicates' => $duplicates,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $current
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>, baseline_hash?: string} $candidate
|
|
||||||
*/
|
|
||||||
private function shouldReplace(array $current, array $candidate): bool
|
|
||||||
{
|
|
||||||
$currentFidelity = $this->fidelityRank($current);
|
|
||||||
$candidateFidelity = $this->fidelityRank($candidate);
|
|
||||||
|
|
||||||
if ($candidateFidelity !== $currentFidelity) {
|
|
||||||
return $candidateFidelity > $currentFidelity;
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentObservedAt = $this->observedAt($current);
|
|
||||||
$candidateObservedAt = $this->observedAt($candidate);
|
|
||||||
|
|
||||||
if ($candidateObservedAt !== $currentObservedAt) {
|
|
||||||
return $candidateObservedAt > $currentObservedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strcmp((string) ($candidate['baseline_hash'] ?? ''), (string) ($current['baseline_hash'] ?? '')) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
|
||||||
*/
|
|
||||||
private function fidelityRank(array $item): int
|
|
||||||
{
|
|
||||||
$fidelity = data_get($item, 'meta_jsonb.evidence.fidelity');
|
|
||||||
|
|
||||||
return match ($fidelity) {
|
|
||||||
'content' => 2,
|
|
||||||
'meta' => 1,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{meta_jsonb?: array<string, mixed>} $item
|
|
||||||
*/
|
|
||||||
private function observedAt(array $item): string
|
|
||||||
{
|
|
||||||
$observedAt = data_get($item, 'meta_jsonb.evidence.observed_at');
|
|
||||||
|
|
||||||
return is_string($observedAt) ? $observedAt : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Baselines;
|
|
||||||
|
|
||||||
use App\Models\BaselineProfile;
|
|
||||||
use App\Models\BaselineSnapshot;
|
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
|
||||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
|
||||||
|
|
||||||
final class BaselineSnapshotTruthResolver
|
|
||||||
{
|
|
||||||
public function resolveEffectiveSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolveLatestAttemptedSnapshot(BaselineProfile $profile): ?BaselineSnapshot
|
|
||||||
{
|
|
||||||
return BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $profile->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $profile->getKey())
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* ok: bool,
|
|
||||||
* snapshot: ?BaselineSnapshot,
|
|
||||||
* effective_snapshot: ?BaselineSnapshot,
|
|
||||||
* latest_attempted_snapshot: ?BaselineSnapshot,
|
|
||||||
* reason_code: ?string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolveCompareSnapshot(BaselineProfile $profile, ?BaselineSnapshot $explicitSnapshot = null): array
|
|
||||||
{
|
|
||||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
|
||||||
$latestAttemptedSnapshot = $this->resolveLatestAttemptedSnapshot($profile);
|
|
||||||
|
|
||||||
if ($explicitSnapshot instanceof BaselineSnapshot) {
|
|
||||||
if ((int) $explicitSnapshot->workspace_id !== (int) $profile->workspace_id
|
|
||||||
|| (int) $explicitSnapshot->baseline_profile_id !== (int) $profile->getKey()) {
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'snapshot' => null,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = $this->compareBlockedReasonForSnapshot($explicitSnapshot, $effectiveSnapshot, explicitSelection: true);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ok' => $reasonCode === null,
|
|
||||||
'snapshot' => $reasonCode === null ? $explicitSnapshot : null,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => $reasonCode,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
return [
|
|
||||||
'ok' => true,
|
|
||||||
'snapshot' => $effectiveSnapshot,
|
|
||||||
'effective_snapshot' => $effectiveSnapshot,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ok' => false,
|
|
||||||
'snapshot' => null,
|
|
||||||
'effective_snapshot' => null,
|
|
||||||
'latest_attempted_snapshot' => $latestAttemptedSnapshot,
|
|
||||||
'reason_code' => $this->profileBlockedReason($latestAttemptedSnapshot),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isHistoricallySuperseded(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): bool
|
|
||||||
{
|
|
||||||
$effectiveSnapshot ??= BaselineSnapshot::query()
|
|
||||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
|
||||||
->where('baseline_profile_id', (int) $snapshot->baseline_profile_id)
|
|
||||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
|
||||||
->orderByDesc('completed_at')
|
|
||||||
->orderByDesc('captured_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $snapshot->isConsumable()
|
|
||||||
&& (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function artifactReasonCode(BaselineSnapshot $snapshot, ?BaselineSnapshot $effectiveSnapshot = null): ?string
|
|
||||||
{
|
|
||||||
if (! $effectiveSnapshot instanceof BaselineSnapshot) {
|
|
||||||
$snapshot->loadMissing('baselineProfile');
|
|
||||||
|
|
||||||
$profile = $snapshot->baselineProfile;
|
|
||||||
|
|
||||||
if ($profile instanceof BaselineProfile) {
|
|
||||||
$effectiveSnapshot = $this->resolveEffectiveSnapshot($profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isBuilding()) {
|
|
||||||
return BaselineReasonCodes::SNAPSHOT_BUILDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isIncomplete()) {
|
|
||||||
$completionMeta = is_array($snapshot->completion_meta_jsonb) ? $snapshot->completion_meta_jsonb : [];
|
|
||||||
$reasonCode = $completionMeta['finalization_reason_code'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reasonCode) && trim($reasonCode) !== ''
|
|
||||||
? trim($reasonCode)
|
|
||||||
: BaselineReasonCodes::SNAPSHOT_INCOMPLETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isHistoricallySuperseded($snapshot, $effectiveSnapshot)) {
|
|
||||||
return BaselineReasonCodes::SNAPSHOT_SUPERSEDED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function compareBlockedReasonForSnapshot(
|
|
||||||
BaselineSnapshot $snapshot,
|
|
||||||
?BaselineSnapshot $effectiveSnapshot,
|
|
||||||
bool $explicitSelection,
|
|
||||||
): ?string {
|
|
||||||
if ($snapshot->isBuilding()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($snapshot->isIncomplete()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $snapshot->isConsumable()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($explicitSelection && $effectiveSnapshot instanceof BaselineSnapshot && (int) $effectiveSnapshot->getKey() !== (int) $snapshot->getKey()) {
|
|
||||||
return BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function profileBlockedReason(?BaselineSnapshot $latestAttemptedSnapshot): string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$latestAttemptedSnapshot?->isBuilding() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING,
|
|
||||||
$latestAttemptedSnapshot?->isIncomplete() === true => BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE,
|
|
||||||
default => BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user