Compare commits
1 Commits
dev
...
152-livewi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2d6a8942 |
22
.github/agents/copilot-instructions.md
vendored
22
.github/agents/copilot-instructions.md
vendored
@ -90,22 +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)
|
||||
- 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)
|
||||
- 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.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -125,8 +109,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 163-baseline-subject-resolution-session-1774398153: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 163-baseline-subject-resolution: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||
- 162-baseline-gap-details: Added 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
|
||||
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 151-findings-workflow-backstop: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||
- 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 END -->
|
||||
|
||||
@ -3,17 +3,12 @@
|
||||
|
||||
- Version change: 1.11.0 → 1.12.0
|
||||
- Modified principles:
|
||||
- None
|
||||
- Scope & Ownership Clarification (SCOPE-001)
|
||||
- Added sections:
|
||||
- Operator Surface Principles (OPSURF-001)
|
||||
- None
|
||||
- Removed sections: None
|
||||
- Templates requiring updates:
|
||||
- ✅ .specify/templates/spec-template.md
|
||||
- ✅ .specify/templates/plan-template.md
|
||||
- ✅ .specify/templates/tasks-template.md
|
||||
- ✅ docs/product/principles.md
|
||||
- ✅ docs/product/standards/README.md
|
||||
- ✅ docs/HANDOVER.md
|
||||
- None
|
||||
- Follow-up TODOs:
|
||||
- None.
|
||||
-->
|
||||
@ -335,65 +330,6 @@ ### Operator-facing UI Naming Standards (UI-NAMING-001)
|
||||
- The visible run label for that action MUST be `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)
|
||||
|
||||
- Every feature spec MUST declare:
|
||||
@ -451,4 +387,4 @@ ### Versioning Policy (SemVer)
|
||||
- **MINOR**: new principle/section or materially expanded guidance.
|
||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||
|
||||
**Version**: 1.12.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-21
|
||||
**Version**: 1.11.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-03-10
|
||||
|
||||
@ -50,12 +50,6 @@ ## Constitution Check
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
- 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 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
|
||||
|
||||
@ -17,14 +17,6 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **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 |
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
@ -135,15 +127,6 @@ ## Requirements *(mandatory)*
|
||||
- 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.
|
||||
|
||||
**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 (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.
|
||||
If the contract is not satisfied, the spec MUST include an explicit exemption with rationale.
|
||||
|
||||
@ -38,13 +38,6 @@ # Tasks: [FEATURE NAME]
|
||||
- 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,
|
||||
- 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:
|
||||
- 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),
|
||||
|
||||
@ -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\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\OperationLifecycleReconciler;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -19,10 +18,8 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
OperationLifecycleReconciler $operationLifecycleReconciler,
|
||||
): int {
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
@ -99,9 +96,31 @@ public function handle(
|
||||
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++;
|
||||
|
||||
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\Support\Auth\Capabilities;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareStats;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -60,8 +59,6 @@ class BaselineCompareLanding extends Page
|
||||
|
||||
public ?int $duplicateNamePoliciesCount = null;
|
||||
|
||||
public ?int $duplicateNameSubjectsCount = null;
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $findingsCount = null;
|
||||
@ -89,21 +86,9 @@ class BaselineCompareLanding extends Page
|
||||
/** @var array<string, int>|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 */
|
||||
public ?array $rbacRoleDefinitionSummary = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $operatorExplanation = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
@ -138,7 +123,6 @@ public function refreshStats(): void
|
||||
$this->profileId = $stats->profileId;
|
||||
$this->snapshotId = $stats->snapshotId;
|
||||
$this->duplicateNamePoliciesCount = $stats->duplicateNamePoliciesCount;
|
||||
$this->duplicateNameSubjectsCount = $stats->duplicateNameSubjectsCount;
|
||||
$this->operationRunId = $stats->operationRunId;
|
||||
$this->findingsCount = $stats->findingsCount;
|
||||
$this->severityCounts = $stats->severityCounts !== [] ? $stats->severityCounts : null;
|
||||
@ -155,17 +139,7 @@ public function refreshStats(): void
|
||||
|
||||
$this->evidenceGapsCount = $stats->evidenceGapsCount;
|
||||
$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->operatorExplanation = $stats->operatorExplanation()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,32 +152,26 @@ public function refreshStats(): void
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
|
||||
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
|
||||
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
|
||||
? (int) $evidenceGapSummary['count']
|
||||
: (int) ($this->evidenceGapsCount ?? 0);
|
||||
$evidenceGapsCountValue = (int) ($this->evidenceGapsCount ?? 0);
|
||||
$hasEvidenceGaps = $evidenceGapsCountValue > 0;
|
||||
$hasWarnings = $hasCoverageWarnings || $hasEvidenceGaps;
|
||||
$hasRbacRoleDefinitionSummary = is_array($this->rbacRoleDefinitionSummary)
|
||||
&& 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;
|
||||
$evidenceGapsTooltip = null;
|
||||
|
||||
if ($hasEvidenceGaps) {
|
||||
$parts = array_map(
|
||||
static fn (array $reason): string => $reason['reason_label'].' ('.$reason['count'].')',
|
||||
BaselineCompareEvidenceGapDetails::topReasons(
|
||||
is_array($evidenceGapSummary['by_reason'] ?? null) ? $evidenceGapSummary['by_reason'] : [],
|
||||
5,
|
||||
),
|
||||
);
|
||||
if ($hasEvidenceGaps && is_array($this->evidenceGapsTopReasons) && $this->evidenceGapsTopReasons !== []) {
|
||||
$parts = [];
|
||||
|
||||
foreach (array_slice($this->evidenceGapsTopReasons, 0, 5, true) as $reason => $count) {
|
||||
if (! is_string($reason) || $reason === '' || ! is_numeric($count)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = $reason.' ('.((int) $count).')';
|
||||
}
|
||||
|
||||
if ($parts !== []) {
|
||||
$evidenceGapsSummary = implode(', ', $parts);
|
||||
@ -239,9 +207,6 @@ protected function getViewData(): array
|
||||
'hasEvidenceGaps' => $hasEvidenceGaps,
|
||||
'hasWarnings' => $hasWarnings,
|
||||
'hasRbacRoleDefinitionSummary' => $hasRbacRoleDefinitionSummary,
|
||||
'evidenceGapDetailState' => $evidenceGapDetailState,
|
||||
'hasEvidenceGapDetailSection' => $hasEvidenceGapDetailSection,
|
||||
'hasEvidenceGapDiagnostics' => $hasEvidenceGapDiagnostics,
|
||||
'evidenceGapsSummary' => $evidenceGapsSummary,
|
||||
'evidenceGapsTooltip' => $evidenceGapsTooltip,
|
||||
'findingsColorClass' => $findingsColorClass,
|
||||
@ -342,22 +307,9 @@ private function compareNowAction(): Action
|
||||
$result = $service->startCompare($tenant, $user);
|
||||
|
||||
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()
|
||||
->title('Cannot start comparison')
|
||||
->body($message)
|
||||
->body('Reason: '.($result['reason_code'] ?? 'unknown'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
|
||||
@ -34,7 +34,6 @@
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use UnitEnum;
|
||||
@ -83,16 +82,14 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
public function mount(): void
|
||||
{
|
||||
$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());
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
|
||||
if ($requestedEventId !== null) {
|
||||
$this->resolveAuditLog($requestedEventId);
|
||||
$this->selectedAuditLogId = $requestedEventId;
|
||||
$this->mountTableAction('inspect', (string) $requestedEventId);
|
||||
if ($this->selectedAuditLogId !== null) {
|
||||
$this->selectedAuditLog();
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,10 +98,31 @@ public function mount(): void
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return app(OperateHubShell::class)->headerActions(
|
||||
$actions = app(OperateHubShell::class)->headerActions(
|
||||
scopeActionName: 'operate_hub_scope_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
|
||||
@ -177,19 +195,9 @@ public function table(Table $table): Table
|
||||
->label('Inspect event')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->before(function (AuditLogModel $record): void {
|
||||
->action(function (AuditLogModel $record): void {
|
||||
$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([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
@ -201,11 +209,48 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->action(function (): void {
|
||||
$this->selectedAuditLogId = null;
|
||||
$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>
|
||||
*/
|
||||
@ -278,54 +323,6 @@ private function auditBaseQuery(): Builder
|
||||
->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>
|
||||
*/
|
||||
|
||||
@ -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,503 +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\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('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'));
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
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
|
||||
{
|
||||
return match ($this->activeTab) {
|
||||
@ -235,9 +172,6 @@ private function applyActiveTab(Builder $query): Builder
|
||||
OperationRunStatus::Queued->value,
|
||||
OperationRunStatus::Running->value,
|
||||
]),
|
||||
'blocked' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Blocked->value),
|
||||
'succeeded' => $query
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('outcome', OperationRunOutcome::Succeeded->value),
|
||||
@ -250,26 +184,4 @@ private function applyActiveTab(Builder $query): Builder
|
||||
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\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
use App\Support\Tenants\TenantInteractionLane;
|
||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -172,60 +169,24 @@ public function blockedExecutionBanner(): ?array
|
||||
return null;
|
||||
}
|
||||
|
||||
$operatorExplanation = $this->governanceOperatorExplanation();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'run_detail');
|
||||
$lines = $operatorExplanation instanceof OperatorExplanationPattern
|
||||
? array_values(array_filter([
|
||||
$operatorExplanation->headline,
|
||||
$operatorExplanation->dominantCauseExplanation,
|
||||
OperationUxPresenter::surfaceGuidance($this->run),
|
||||
]))
|
||||
: ($reasonEnvelope?->toBodyLines() ?? [
|
||||
OperationUxPresenter::surfaceFailureDetail($this->run) ?? 'The queued run was refused before side effects could begin.',
|
||||
OperationUxPresenter::surfaceGuidance($this->run) ?? 'Review the blocked prerequisite before retrying.',
|
||||
]);
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$reasonCode = data_get($context, 'reason_code');
|
||||
|
||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
||||
}
|
||||
|
||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
||||
$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 [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Blocked by prerequisite',
|
||||
'body' => implode(' ', $lines),
|
||||
'title' => 'Execution blocked',
|
||||
'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.';
|
||||
$guidance = OperationUxPresenter::surfaceGuidance($this->run);
|
||||
$body = $guidance !== null ? $detail.' '.$guidance : $detail;
|
||||
|
||||
return match ($this->run->freshnessState()->value) {
|
||||
'likely_stale' => [
|
||||
'tone' => 'amber',
|
||||
'title' => 'Likely stale run',
|
||||
'body' => $body,
|
||||
],
|
||||
'reconciled_failed' => [
|
||||
'tone' => 'rose',
|
||||
'title' => 'Automatically reconciled',
|
||||
'body' => $body,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{tone: string, title: string, body: string}|null
|
||||
*/
|
||||
@ -460,13 +421,4 @@ private function relatedLinksTenant(): ?Tenant
|
||||
lane: TenantInteractionLane::StandardActiveOperating,
|
||||
)->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;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
@ -6,28 +6,19 @@
|
||||
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineTenantAssignment;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
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\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -297,32 +288,15 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('None'),
|
||||
])
|
||||
->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')
|
||||
->schema([
|
||||
TextEntry::make('createdByUser.name')
|
||||
->label('Created by')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot yet'),
|
||||
TextEntry::make('created_at')
|
||||
->dateTime(),
|
||||
TextEntry::make('updated_at')
|
||||
@ -381,27 +355,10 @@ public static function table(Table $table): Table
|
||||
TextColumn::make('tenant_assignments_count')
|
||||
->label('Assigned tenants')
|
||||
->counts('tenantAssignments'),
|
||||
TextColumn::make('current_snapshot_truth')
|
||||
->label('Current snapshot')
|
||||
->getStateUsing(fn (BaselineProfile $record): string => self::currentSnapshotLabel($record))
|
||||
->description(fn (BaselineProfile $record): ?string => self::currentSnapshotDescription($record))
|
||||
->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('activeSnapshot.captured_at')
|
||||
->label('Last snapshot')
|
||||
->dateTime()
|
||||
->placeholder('No snapshot'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@ -588,167 +545,4 @@ private static function archiveTableAction(?Workspace $workspace): 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
|
||||
? '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')
|
||||
->label($label)
|
||||
@ -198,7 +198,7 @@ private function compareNowAction(): Action
|
||||
->required()
|
||||
->searchable(),
|
||||
])
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [] || ! $this->profileHasConsumableSnapshot())
|
||||
->disabled(fn (): bool => $this->getEligibleCompareTenantOptions() === [])
|
||||
->action(function (array $data): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -256,11 +256,7 @@ private function compareNowAction(): Action
|
||||
$message = match ($reasonCode) {
|
||||
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_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.',
|
||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT => 'This baseline profile has no active snapshot.',
|
||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||
};
|
||||
|
||||
@ -399,12 +395,4 @@ private function hasManageCapability(): bool
|
||||
return $resolver->isMember($user, $workspace)
|
||||
&& $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\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\FidelityState;
|
||||
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\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
@ -23,8 +18,6 @@
|
||||
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 App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
@ -172,39 +165,15 @@ public static function table(Table $table): Table
|
||||
->label('Captured')
|
||||
->since()
|
||||
->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')
|
||||
->label('Fidelity')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||
->wrap(),
|
||||
TextColumn::make('artifact_next_step')
|
||||
->label('Next step')
|
||||
->getStateUsing(static fn (BaselineSnapshot $record): string => self::truthEnvelope($record)->operatorExplanation?->nextActionText ?? self::truthEnvelope($record)->nextStepText())
|
||||
->wrap(),
|
||||
TextColumn::make('snapshot_state')
|
||||
->label('State')
|
||||
->badge()
|
||||
->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)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
@ -214,10 +183,10 @@ public static function table(Table $table): Table
|
||||
->label('Baseline')
|
||||
->options(static::baselineProfileOptions())
|
||||
->searchable(),
|
||||
SelectFilter::make('lifecycle_state')
|
||||
->label('Lifecycle')
|
||||
->options(static::lifecycleOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyLifecycleFilter($query, $data['value'] ?? null)),
|
||||
SelectFilter::make('snapshot_state')
|
||||
->label('State')
|
||||
->options(static::snapshotStateOptions())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applySnapshotStateFilter($query, $data['value'] ?? null)),
|
||||
FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'),
|
||||
])
|
||||
->actions([
|
||||
@ -278,9 +247,12 @@ private static function baselineProfileOptions(): array
|
||||
/**
|
||||
* @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
|
||||
@ -318,13 +290,7 @@ private static function fidelitySummary(BaselineSnapshot $snapshot): string
|
||||
{
|
||||
$counts = self::fidelityCounts($snapshot);
|
||||
|
||||
return sprintf(
|
||||
'%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),
|
||||
);
|
||||
return sprintf('Content %d, Meta %d', (int) ($counts['content'] ?? 0), (int) ($counts['meta'] ?? 0));
|
||||
}
|
||||
|
||||
private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
@ -332,17 +298,6 @@ private static function gapsCount(BaselineSnapshot $snapshot): int
|
||||
$summary = self::summary($snapshot);
|
||||
$gaps = $summary['gaps'] ?? null;
|
||||
$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;
|
||||
|
||||
@ -354,86 +309,32 @@ private static function hasGaps(BaselineSnapshot $snapshot): bool
|
||||
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) === '') {
|
||||
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
|
||||
{
|
||||
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))",
|
||||
'pgsql' => "GREATEST(0, COALESCE((summary_jsonb #>> '{gaps,count}')::int, 0) - COALESCE((summary_jsonb #>> '{gaps,by_reason,meta_fallback}')::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))",
|
||||
'sqlite' => "COALESCE(CAST(json_extract(summary_jsonb, '$.gaps.count') AS INTEGER), 0)",
|
||||
'pgsql' => "COALESCE((summary_jsonb #>> '{gaps,count}')::int, 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();
|
||||
|
||||
if ($snapshot instanceof BaselineSnapshot) {
|
||||
$snapshot->loadMissing(['baselineProfile', 'items']);
|
||||
|
||||
$relatedContext = app(RelatedNavigationResolver::class)
|
||||
->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,488 +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\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());
|
||||
}
|
||||
|
||||
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_summary')
|
||||
->label('Finding')
|
||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
||||
->searchable(),
|
||||
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(),
|
||||
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()),
|
||||
])
|
||||
->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
|
||||
{
|
||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
||||
|
||||
if (is_string($summary) && trim($summary) !== '') {
|
||||
return trim($summary);
|
||||
}
|
||||
|
||||
return 'Finding #'.$record->finding_id;
|
||||
}
|
||||
|
||||
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 ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingExceptionResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFindingExceptions extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingExceptionResource::class;
|
||||
|
||||
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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,208 +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('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\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -38,8 +35,6 @@
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
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;
|
||||
@ -228,62 +223,6 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->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')
|
||||
->schema([
|
||||
TextEntry::make('redaction_integrity_note')
|
||||
@ -1075,6 +1014,80 @@ public static function table(Table $table): Table
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->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'),
|
||||
])
|
||||
->emptyStateHeading('No findings match this view')
|
||||
@ -1085,7 +1098,7 @@ public static function table(Table $table): Table
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
->withSubjectDisplayName();
|
||||
}
|
||||
|
||||
@ -1094,7 +1107,7 @@ public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
return static::resolveTenantOwnedRecordOrFail(
|
||||
$key,
|
||||
parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
->withSubjectDisplayName(),
|
||||
);
|
||||
}
|
||||
@ -1172,9 +1185,7 @@ public static function workflowActions(): array
|
||||
static::assignAction(),
|
||||
static::resolveAction(),
|
||||
static::closeAction(),
|
||||
static::requestExceptionAction(),
|
||||
static::renewExceptionAction(),
|
||||
static::revokeExceptionAction(),
|
||||
static::riskAcceptAction(),
|
||||
static::reopenAction(),
|
||||
];
|
||||
}
|
||||
@ -1344,153 +1355,37 @@ public static function closeAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function requestExceptionAction(): Actions\Action
|
||||
public static function riskAcceptAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('request_exception')
|
||||
->label('Request exception')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
Actions\Action::make('risk_accept')
|
||||
->label('Risk accept')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
Textarea::make('closed_reason')
|
||||
->label('Risk acceptance reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->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()
|
||||
->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(),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
||||
static::runExceptionRequestMutation($record, $data, $service);
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding marked as risk accepted',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
|
||||
$finding,
|
||||
$tenant,
|
||||
$user,
|
||||
(string) ($data['closed_reason'] ?? ''),
|
||||
),
|
||||
);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
||||
->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::currentFindingException($record)?->canBeRenewed() ?? false)
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'owner_user_id' => static::currentFindingException($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::currentFindingException($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)
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
@ -1567,112 +1462,6 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
->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
|
||||
{
|
||||
return static::resolveProtectedFindingRecordOrFail($record);
|
||||
@ -1694,67 +1483,6 @@ private static function resolveProtectedFindingRecordOrFail(Finding|int|string $
|
||||
return $resolvedRecord;
|
||||
}
|
||||
|
||||
private static function currentFindingException(Finding $record): ?FindingException
|
||||
{
|
||||
$finding = static::resolveProtectedFindingRecordOrFail($record);
|
||||
|
||||
return static::resolvedFindingException($finding);
|
||||
}
|
||||
|
||||
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 ($exception instanceof FindingException && $exception->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@ -39,7 +39,7 @@ class ListFindings extends ListRecords
|
||||
*/
|
||||
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 {
|
||||
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
|
||||
} catch (ModelNotFoundException) {
|
||||
|
||||
@ -8,10 +8,8 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\VerificationCheckAcknowledgement;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Baselines\BaselineCompareEvidenceGapDetails;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
@ -23,17 +21,13 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||
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\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -129,11 +123,10 @@ public static function table(Table $table): Table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::lifecycleAttentionSummary($record)),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
@ -156,11 +149,10 @@ public static function table(Table $table): Table
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('tenant_id')
|
||||
@ -213,9 +205,13 @@ public static function table(Table $table): Table
|
||||
return FilterOptionCatalog::operationTypes(array_keys($types));
|
||||
}),
|
||||
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')
|
||||
->options(BadgeCatalog::options(BadgeDomain::OperationRunOutcome, OperationRunOutcome::values(includeReserved: false))),
|
||||
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
||||
Tables\Filters\SelectFilter::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->options(function (): array {
|
||||
@ -255,25 +251,13 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
{
|
||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record));
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record));
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||
$targetScope = static::targetScopeDisplay($record);
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
$referencedTenantLifecycle = $record->tenant instanceof Tenant
|
||||
? ReferencedTenantLifecyclePresentation::forOperationRun($record->tenant)
|
||||
: null;
|
||||
$artifactTruth = $record->supportsOperatorExplanation()
|
||||
? app(ArtifactTruthPresenter::class)->forOperationRun($record)
|
||||
: null;
|
||||
$operatorExplanation = $artifactTruth?->operatorExplanation;
|
||||
$artifactTruthBadge = $artifactTruth !== null
|
||||
? $factory->statusBadge(
|
||||
$artifactTruth->primaryBadgeSpec()->label,
|
||||
$artifactTruth->primaryBadgeSpec()->color,
|
||||
$artifactTruth->primaryBadgeSpec()->icon,
|
||||
$artifactTruth->primaryBadgeSpec()->iconColor,
|
||||
)
|
||||
: null;
|
||||
|
||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||
@ -303,15 +287,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['artifactTruthState' => $artifactTruth?->toArray()],
|
||||
visible: $artifactTruth !== null,
|
||||
description: 'Run lifecycle stays separate from whether the intended governance artifact was actually produced and usable.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
@ -329,15 +304,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||
$artifactTruth !== null
|
||||
? $factory->keyFact('Artifact truth', $artifactTruth->primaryLabel, badge: $artifactTruthBadge)
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||
: null,
|
||||
$referencedTenantLifecycle !== null
|
||||
? $factory->keyFact(
|
||||
'Tenant lifecycle',
|
||||
@ -356,29 +322,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
$referencedTenantLifecycle?->contextNote !== null
|
||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||
: null,
|
||||
static::freshnessLabel($record) !== null
|
||||
? $factory->keyFact('Freshness', (string) static::freshnessLabel($record))
|
||||
: null,
|
||||
static::reconciliationHeadline($record) !== null
|
||||
? $factory->keyFact('Lifecycle truth', (string) static::reconciliationHeadline($record))
|
||||
: null,
|
||||
static::reconciledAtLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled at', (string) static::reconciledAtLabel($record))
|
||||
: null,
|
||||
static::reconciliationSourceLabel($record) !== null
|
||||
? $factory->keyFact('Reconciled by', (string) static::reconciliationSourceLabel($record))
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Artifact next step', $operatorExplanation->nextActionText)
|
||||
: ($artifactTruth !== null
|
||||
? $factory->keyFact('Artifact next step', $artifactTruth->nextStepText())
|
||||
: null),
|
||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
OperationUxPresenter::surfaceGuidance($record) !== null
|
||||
? $factory->keyFact('Next step', OperationUxPresenter::surfaceGuidance($record))
|
||||
: null,
|
||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||
static::blockedExecutionReasonCode($record) !== null
|
||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
||||
@ -442,25 +385,9 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (static::reconciliationPayload($record) !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'reconciliation',
|
||||
kind: 'operational_context',
|
||||
title: 'Lifecycle reconciliation',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::reconciliationPayload($record)],
|
||||
description: 'Lifecycle reconciliation is diagnostic evidence showing when TenantPilot force-resolved the run.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
@ -473,25 +400,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
||||
);
|
||||
}
|
||||
|
||||
if (($gapSummary['detail_state'] ?? 'no_gaps') !== 'no_gaps') {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_compare_gap_details',
|
||||
kind: 'operational_context',
|
||||
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: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($baselineCompareEvidence !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
@ -546,7 +454,7 @@ private static function summaryCountFacts(
|
||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
|
||||
return array_map(
|
||||
static fn (string $key, int $value): array => $factory->keyFact(SummaryCountsNormalizer::label($key), $value),
|
||||
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value),
|
||||
array_keys($counts),
|
||||
array_values($counts),
|
||||
);
|
||||
@ -558,13 +466,8 @@ private static function blockedExecutionReasonCode(OperationRun $record): ?strin
|
||||
return null;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->operatorLabel;
|
||||
}
|
||||
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
||||
?? data_get($context, 'reason_code')
|
||||
?? data_get($record->failure_summary, '0.reason_code');
|
||||
@ -578,12 +481,6 @@ private static function blockedExecutionDetail(OperationRun $record): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
return $reasonEnvelope->shortExplanation;
|
||||
}
|
||||
|
||||
$message = data_get($record->failure_summary, '0.message');
|
||||
|
||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
||||
@ -616,8 +513,6 @@ private static function baselineCompareFacts(
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$gapDetails = BaselineCompareEvidenceGapDetails::fromOperationRun($record);
|
||||
$gapSummary = is_array($gapDetails['summary'] ?? null) ? $gapDetails['summary'] : [];
|
||||
$facts = [];
|
||||
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
@ -649,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 !== []) {
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||
@ -813,82 +684,6 @@ private static function contextPayload(OperationRun $record): array
|
||||
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
|
||||
{
|
||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||
|
||||
@ -824,12 +824,9 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -924,12 +921,9 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
@ -1021,12 +1015,9 @@ public static function table(Table $table): Table
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
|
||||
@ -278,12 +278,9 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Connection check blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
@ -650,12 +647,9 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Inventory sync blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
@ -764,12 +758,9 @@ protected function getHeaderActions(): array
|
||||
? (string) $result->run->context['reason_code']
|
||||
: 'unknown_error';
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Compliance snapshot blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
|
||||
@ -824,10 +824,10 @@ public static function table(Table $table): Table
|
||||
->label('Total')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_succeeded')
|
||||
->label('Applied')
|
||||
->label('Succeeded')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('summary_failed')
|
||||
->label('Failed items')
|
||||
->label('Failed')
|
||||
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
|
||||
Tables\Columns\TextColumn::make('started_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);
|
||||
$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')
|
||||
->label('Dry-run')
|
||||
|
||||
@ -2,15 +2,12 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -20,14 +17,11 @@
|
||||
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\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@ -115,15 +109,6 @@ 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 (ReviewPack $record): array => static::truthEnvelope($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Status')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
@ -179,21 +164,6 @@ public static function infolist(Schema $schema): Schema
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
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')
|
||||
->label('Operation run')
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
@ -207,33 +177,6 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->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))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ReviewPackStatus))
|
||||
->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')
|
||||
->label('Tenant')
|
||||
->searchable(),
|
||||
@ -267,10 +201,6 @@ public static function table(Table $table): Table
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->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')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
@ -279,29 +209,6 @@ public static function table(Table $table): Table
|
||||
->label('Size')
|
||||
->formatStateUsing(fn ($state): string => $state ? Number::fileSize((int) $state) : '—')
|
||||
->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')
|
||||
->label('Created')
|
||||
->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
|
||||
*/
|
||||
@ -429,23 +331,7 @@ public static function executeGeneration(array $data): void
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
try {
|
||||
$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;
|
||||
}
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
|
||||
@ -608,12 +608,9 @@ public static function table(Table $table): Table
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
@ -911,20 +908,8 @@ public static function infolist(Schema $schema): Schema
|
||||
->visible(fn (Tenant $record): bool => filled($record->rbac_status)),
|
||||
Section::make('RBAC Details')
|
||||
->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')
|
||||
->label('Diagnostic code')
|
||||
->copyable(),
|
||||
->label('Reason'),
|
||||
Infolists\Components\TextEntry::make('rbac_role_definition_id')
|
||||
->label('Role definition ID')
|
||||
->copyable(),
|
||||
|
||||
@ -178,12 +178,9 @@ protected function getHeaderActions(): array
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -22,8 +22,6 @@ protected function getViewData(): array
|
||||
|
||||
$empty = [
|
||||
'hasAssignment' => false,
|
||||
'state' => 'no_assignment',
|
||||
'message' => null,
|
||||
'profileName' => null,
|
||||
'findingsCount' => 0,
|
||||
'highCount' => 0,
|
||||
@ -45,8 +43,6 @@ protected function getViewData(): array
|
||||
|
||||
return [
|
||||
'hasAssignment' => true,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'profileName' => $stats->profileName,
|
||||
'findingsCount' => $stats->findingsCount ?? 0,
|
||||
'highCount' => $stats->severityCounts['high'] ?? 0,
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\ActiveRuns;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
@ -58,8 +57,7 @@ public function table(Table $table): Table
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome))
|
||||
->description(fn (OperationRun $record): ?string => OperationUxPresenter::surfaceGuidance($record)),
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->sortable()
|
||||
|
||||
@ -44,10 +44,8 @@ protected function getViewData(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'shouldShow' => ($hasWarnings && $runUrl !== null) || $stats->state === 'no_snapshot',
|
||||
'shouldShow' => $hasWarnings && $runUrl !== null,
|
||||
'runUrl' => $runUrl,
|
||||
'state' => $stats->state,
|
||||
'message' => $stats->message,
|
||||
'coverageStatus' => $coverageStatus,
|
||||
'fidelity' => $stats->fidelity,
|
||||
'uncoveredTypesCount' => $stats->uncoveredTypesCount ?? count($uncoveredTypes),
|
||||
|
||||
@ -131,7 +131,6 @@ protected function getViewData(): array
|
||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||
|
||||
$latestPack = ReviewPack::query()
|
||||
->with('tenantReview')
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
@ -147,7 +146,6 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -160,11 +158,6 @@ protected function getViewData(): array
|
||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||
}
|
||||
|
||||
$reviewUrl = null;
|
||||
if ($latestPack->tenantReview && $canView) {
|
||||
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
|
||||
}
|
||||
|
||||
$failedReason = null;
|
||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||
@ -180,7 +173,6 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'reviewUrl' => $reviewUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -208,7 +200,6 @@ private function emptyState(): array
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,12 +134,9 @@ public function startVerification(StartVerification $verification): void
|
||||
break;
|
||||
}
|
||||
|
||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||
|
||||
Notification::make()
|
||||
->title('Verification blocked')
|
||||
->body(implode("\n", $bodyLines))
|
||||
->body("Blocked by provider configuration ({$reasonCode}).")
|
||||
->warning()
|
||||
->actions($actions)
|
||||
->send();
|
||||
|
||||
@ -23,7 +23,6 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }>
|
||||
@ -49,7 +48,6 @@ class WorkspaceRecentOperations extends Widget
|
||||
* status_color: string,
|
||||
* outcome_label: string,
|
||||
* outcome_color: string,
|
||||
* guidance: ?string,
|
||||
* started_at: string,
|
||||
* url: string
|
||||
* }> $operations
|
||||
|
||||
@ -177,7 +177,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->isLivewireUpdatePath($path)) {
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
@ -193,11 +193,6 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
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
|
||||
{
|
||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
@ -12,18 +11,11 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
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
|
||||
{
|
||||
if ($this->operationRun instanceof OperationRun) {
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
@ -16,15 +15,7 @@
|
||||
|
||||
class BulkTenantSyncJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -13,7 +12,6 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotItemNormalizer;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
@ -22,9 +20,7 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -36,19 +32,10 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class CaptureBaselineSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
@ -73,12 +60,10 @@ public function handle(
|
||||
OperationRunService $operationRunService,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
): void {
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
@ -108,7 +93,6 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$truthfulTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
? $profile->capture_mode
|
||||
@ -128,7 +112,6 @@ public function handle(
|
||||
scope: $effectiveScope,
|
||||
identity: $identity,
|
||||
latestInventorySyncRunId: $latestInventorySyncRunId,
|
||||
policyTypes: $truthfulTypes,
|
||||
);
|
||||
|
||||
$subjects = $inventoryResult['subjects'];
|
||||
@ -200,12 +183,7 @@ public function handle(
|
||||
gaps: $captureGaps,
|
||||
);
|
||||
|
||||
$normalizedItems = $snapshotItemNormalizer->deduplicate($snapshotItems['items'] ?? []);
|
||||
$items = $normalizedItems['items'];
|
||||
|
||||
if (($normalizedItems['duplicates'] ?? 0) > 0) {
|
||||
$captureGaps['duplicate_subject_reference'] = ($captureGaps['duplicate_subject_reference'] ?? 0) + (int) $normalizedItems['duplicates'];
|
||||
}
|
||||
$items = $snapshotItems['items'] ?? [];
|
||||
|
||||
$identityHash = $identity->computeIdentity($items);
|
||||
|
||||
@ -222,17 +200,16 @@ public function handle(
|
||||
],
|
||||
];
|
||||
|
||||
$snapshotResult = $this->captureSnapshotArtifact(
|
||||
$snapshot = $this->findOrCreateSnapshot(
|
||||
$profile,
|
||||
$identityHash,
|
||||
$items,
|
||||
$snapshotSummary,
|
||||
);
|
||||
|
||||
$snapshot = $snapshotResult['snapshot'];
|
||||
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
|
||||
$wasNewSnapshot = $snapshot->wasRecentlyCreated;
|
||||
|
||||
if ($profile->status === BaselineProfileStatus::Active && $snapshot->isConsumable()) {
|
||||
if ($profile->status === BaselineProfileStatus::Active) {
|
||||
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||
}
|
||||
|
||||
@ -264,9 +241,6 @@ public function handle(
|
||||
'gaps' => [
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
|
||||
? array_values($phaseResult['gap_subjects'])
|
||||
: null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
],
|
||||
@ -276,7 +250,6 @@ public function handle(
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'was_new_snapshot' => $wasNewSnapshot,
|
||||
'items_captured' => $snapshotItems['items_count'],
|
||||
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
|
||||
];
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
@ -301,7 +274,7 @@ public function handle(
|
||||
/**
|
||||
* @return array{
|
||||
* 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{
|
||||
* tenant_subject_external_id: string,
|
||||
* workspace_subject_external_id: string,
|
||||
@ -322,7 +295,6 @@ private function collectInventorySubjects(
|
||||
BaselineScope $scope,
|
||||
BaselineSnapshotIdentity $identity,
|
||||
?int $latestInventorySyncRunId = null,
|
||||
?array $policyTypes = null,
|
||||
): array {
|
||||
$query = InventoryItem::query()
|
||||
->where('tenant_id', $sourceTenant->getKey());
|
||||
@ -331,7 +303,7 @@ private function collectInventorySubjects(
|
||||
$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 */
|
||||
$inventoryByKey = [];
|
||||
@ -419,7 +391,6 @@ private function collectInventorySubjects(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['tenant_subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$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{
|
||||
* tenant_subject_external_id: string,
|
||||
@ -550,151 +500,29 @@ private function buildSnapshotItems(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $snapshotItems
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
|
||||
*/
|
||||
private function captureSnapshotArtifact(
|
||||
private function findOrCreateSnapshot(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $snapshotItems,
|
||||
array $summaryJsonb,
|
||||
): array {
|
||||
$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
|
||||
{
|
||||
): BaselineSnapshot {
|
||||
$existing = BaselineSnapshot::query()
|
||||
->where('workspace_id', $profile->workspace_id)
|
||||
->where('baseline_profile_id', $profile->getKey())
|
||||
->where('snapshot_identity_hash', $identityHash)
|
||||
->where('lifecycle_state', BaselineSnapshotLifecycleState::Complete->value)
|
||||
->first();
|
||||
|
||||
return $existing instanceof BaselineSnapshot ? $existing : null;
|
||||
}
|
||||
if ($existing instanceof BaselineSnapshot) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summaryJsonb
|
||||
*/
|
||||
private function createBuildingSnapshot(
|
||||
BaselineProfile $profile,
|
||||
string $identityHash,
|
||||
array $summaryJsonb,
|
||||
int $expectedItems,
|
||||
): BaselineSnapshot {
|
||||
return BaselineSnapshot::create([
|
||||
$snapshot = BaselineSnapshot::create([
|
||||
'workspace_id' => (int) $profile->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'snapshot_identity_hash' => $this->temporarySnapshotIdentityHash($profile),
|
||||
'snapshot_identity_hash' => $identityHash,
|
||||
'captured_at' => now(),
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::Building->value,
|
||||
'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) {
|
||||
$rows = array_map(
|
||||
@ -713,56 +541,9 @@ private function persistSnapshotItems(BaselineSnapshot $snapshot, array $snapsho
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::insert($rows);
|
||||
$persistedItems += count($rows);
|
||||
}
|
||||
|
||||
return $persistedItems;
|
||||
}
|
||||
|
||||
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();
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
@ -19,7 +18,6 @@
|
||||
use App\Services\Baselines\BaselineAutoCloseService;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
@ -39,16 +37,13 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use App\Support\Baselines\SubjectResolver;
|
||||
use App\Support\Inventory\InventoryCoverage;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -59,15 +54,7 @@
|
||||
|
||||
class CompareBaselineToTenantJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
@ -97,7 +84,6 @@ public function handle(
|
||||
?SettingsResolver $settingsResolver = null,
|
||||
?BaselineAutoCloseService $baselineAutoCloseService = null,
|
||||
?CurrentStateHashResolver $hashResolver = null,
|
||||
?BaselineSnapshotTruthResolver $snapshotTruthResolver = null,
|
||||
?MetaEvidenceProvider $metaEvidenceProvider = null,
|
||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||
@ -106,7 +92,6 @@ public function handle(
|
||||
$settingsResolver ??= app(SettingsResolver::class);
|
||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||
$hashResolver ??= app(CurrentStateHashResolver::class);
|
||||
$snapshotTruthResolver ??= app(BaselineSnapshotTruthResolver::class);
|
||||
$metaEvidenceProvider ??= app(MetaEvidenceProvider::class);
|
||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||
@ -145,7 +130,7 @@ public function handle(
|
||||
: null;
|
||||
|
||||
$effectiveScope = BaselineScope::fromJsonb($context['effective_scope'] ?? null);
|
||||
$effectiveTypes = $this->truthfulTypesFromContext($context, $effectiveScope);
|
||||
$effectiveTypes = $effectiveScope->allTypes();
|
||||
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
||||
|
||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||
@ -293,52 +278,12 @@ public function handle(
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey($snapshotId)
|
||||
->first();
|
||||
->first(['id', 'captured_at']);
|
||||
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
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
|
||||
? CarbonImmutable::instance($snapshot->captured_at)
|
||||
: null;
|
||||
@ -364,7 +309,6 @@ public function handle(
|
||||
static fn (array $item): array => [
|
||||
'policy_type' => (string) $item['policy_type'],
|
||||
'subject_external_id' => (string) $item['subject_external_id'],
|
||||
'subject_key' => (string) $item['subject_key'],
|
||||
],
|
||||
$currentItems,
|
||||
));
|
||||
@ -390,7 +334,6 @@ public function handle(
|
||||
];
|
||||
$phaseResult = [];
|
||||
$phaseGaps = [];
|
||||
$phaseGapSubjects = [];
|
||||
$resumeToken = null;
|
||||
|
||||
if ($captureMode === BaselineCaptureMode::FullContent) {
|
||||
@ -419,7 +362,6 @@ public function handle(
|
||||
|
||||
$phaseStats = is_array($phaseResult['stats'] ?? null) ? $phaseResult['stats'] : $phaseStats;
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -499,12 +441,6 @@ public function handle(
|
||||
$gapsByReason = $this->mergeGapCounts($baselineGaps, $currentGaps, $phaseGaps, $driftGaps);
|
||||
$gapsCount = array_sum($gapsByReason);
|
||||
|
||||
$gapSubjects = $this->collectGapSubjects(
|
||||
ambiguousKeys: $ambiguousKeys,
|
||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
||||
);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => count($driftResults),
|
||||
'processed' => count($driftResults),
|
||||
@ -582,7 +518,6 @@ public function handle(
|
||||
'count' => $gapsCount,
|
||||
'by_reason' => $gapsByReason,
|
||||
...$gapsByReason,
|
||||
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
|
||||
],
|
||||
'resume_token' => $resumeToken,
|
||||
'coverage' => [
|
||||
@ -610,10 +545,6 @@ public function handle(
|
||||
'findings_resolved' => $resolvedCount,
|
||||
'severity_breakdown' => $severityBreakdown,
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation(
|
||||
$updatedContext,
|
||||
$reasonCode?->value,
|
||||
);
|
||||
$this->operationRun->update(['context' => $updatedContext]);
|
||||
|
||||
$this->auditCompleted(
|
||||
@ -859,7 +790,6 @@ private function completeWithCoverageWarning(
|
||||
'findings_resolved' => 0,
|
||||
'severity_breakdown' => [],
|
||||
];
|
||||
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
|
||||
|
||||
$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".
|
||||
*
|
||||
@ -1102,38 +1004,6 @@ private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
|
||||
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.
|
||||
*
|
||||
@ -1166,7 +1036,6 @@ private function computeDrift(
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
@ -1208,7 +1077,6 @@ private function computeDrift(
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1273,7 +1141,6 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1290,14 +1157,12 @@ private function computeDrift(
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1311,7 +1176,6 @@ private function computeDrift(
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1392,7 +1256,6 @@ private function computeDrift(
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -1408,7 +1271,6 @@ private function computeDrift(
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$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;
|
||||
}
|
||||
@ -1468,7 +1330,6 @@ private function computeDrift(
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
@ -1980,163 +1841,6 @@ private function mergeGapCounts(array ...$gaps): array
|
||||
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, 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;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
|
||||
@ -25,10 +25,6 @@ class ExecuteRestoreRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 420;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
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;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\ReviewPackService;
|
||||
@ -28,10 +27,6 @@ class GenerateReviewPackJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public function __construct(
|
||||
public int $reviewPackId,
|
||||
public int $operationRunId,
|
||||
@ -39,7 +34,7 @@ public function __construct(
|
||||
|
||||
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);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
@ -59,20 +54,12 @@ public function handle(OperationRunService $operationRunService): void
|
||||
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)
|
||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$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 ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$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'] : []);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
||||
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
||||
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
||||
$dataFreshness = $this->computeDataFreshness($items);
|
||||
// 1. Collect StoredReports
|
||||
$storedReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->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
|
||||
$fileMap = $this->buildFileMap(
|
||||
storedReports: $storedReports,
|
||||
findings: $findings,
|
||||
hardening: $hardening,
|
||||
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
||||
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
||||
recentOperations: $recentOperations,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
dataFreshness: $dataFreshness,
|
||||
riskAcceptance: $riskAcceptance,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
);
|
||||
@ -151,24 +154,16 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'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
|
||||
$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,
|
||||
@ -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>
|
||||
*/
|
||||
private function computeDataFreshness($items): array
|
||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
||||
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
||||
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -304,15 +204,12 @@ private function computeDataFreshness($items): array
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildFileMap(
|
||||
$storedReports,
|
||||
$findings,
|
||||
array $hardening,
|
||||
array $permissionPosture,
|
||||
array $entraAdminRoles,
|
||||
$recentOperations,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $dataFreshness,
|
||||
array $riskAcceptance,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
): array {
|
||||
@ -330,12 +227,6 @@ private function buildFileMap(
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'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' => [
|
||||
'protected_values_hidden' => true,
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
@ -350,14 +241,16 @@ private function buildFileMap(
|
||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||
|
||||
// reports/entra_admin_roles.json
|
||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||
$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,
|
||||
);
|
||||
|
||||
// reports/permission_posture.json
|
||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||
$files['reports/permission_posture.json'] = json_encode(
|
||||
$this->redactReportPayload($permissionPosture, $includePii),
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
@ -365,10 +258,8 @@ private function buildFileMap(
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'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(),
|
||||
'risk_acceptance' => $riskAcceptance,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
@ -382,33 +273,18 @@ private function buildFileMap(
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$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) {
|
||||
$row = $finding instanceof Finding
|
||||
? [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_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,
|
||||
fputcsv($handle, [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -425,31 +301,17 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$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) {
|
||||
$row = $operation instanceof OperationRun
|
||||
? [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_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,
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -460,15 +322,6 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
||||
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.
|
||||
*
|
||||
@ -578,98 +431,9 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
$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
|
||||
{
|
||||
$reviewPack->update([
|
||||
'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)],
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$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 $timeout = 300;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* Compatibility-only legacy field.
|
||||
*
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -25,15 +24,7 @@
|
||||
|
||||
class RunInventorySyncJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Concerns\BridgesFailedOperationRun;
|
||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
@ -22,15 +21,7 @@
|
||||
|
||||
class SyncPoliciesJob implements ShouldQueue
|
||||
{
|
||||
use BridgesFailedOperationRun;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
|
||||
@ -20,10 +20,6 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 240;
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
|
||||
@ -1,216 +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\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(),
|
||||
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()
|
||||
->extraAttributes(['class' => 'font-mono text-xs']),
|
||||
])
|
||||
->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();
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$currentPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSnapshotLifecycleState;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -122,37 +121,6 @@ public function snapshots(): HasMany
|
||||
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
|
||||
{
|
||||
return $this->hasMany(BaselineTenantAssignment::class);
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use RuntimeException;
|
||||
|
||||
class BaselineSnapshot extends Model
|
||||
{
|
||||
@ -19,20 +13,10 @@ class BaselineSnapshot extends Model
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'lifecycle_state' => BaselineSnapshotLifecycleState::class,
|
||||
'summary_jsonb' => 'array',
|
||||
'completion_meta_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
protected $casts = [
|
||||
'summary_jsonb' => 'array',
|
||||
'captured_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
@ -48,100 +32,4 @@ public function items(): HasMany
|
||||
{
|
||||
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\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class Finding extends Model
|
||||
@ -99,14 +98,6 @@ public function closedByUser(): BelongsTo
|
||||
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>
|
||||
*/
|
||||
@ -169,58 +160,6 @@ public function hasOpenStatus(): bool
|
||||
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
|
||||
{
|
||||
$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;
|
||||
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunFreshnessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -129,145 +127,4 @@ public function setFinishedAtAttribute(mixed $value): void
|
||||
{
|
||||
$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 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
|
||||
* @return Builder<self>
|
||||
|
||||
@ -261,21 +261,6 @@ public function auditLogs(): HasMany
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
@ -45,14 +44,6 @@ public function toDatabase(object $notifiable): array
|
||||
->url($runUrl),
|
||||
]);
|
||||
|
||||
$message = $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;
|
||||
return $notification->getDatabaseMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,8 +49,8 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued for execution. Open the run for progress and next steps.')
|
||||
->info()
|
||||
->body('Queued. Monitor progress in Monitoring → Operations.')
|
||||
->warning()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('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)
|
||||
->requiredCapabilityForRun($run);
|
||||
->requiredCapabilityForType((string) $run->type);
|
||||
|
||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
@ -18,7 +17,6 @@
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\TenantOnboardingSessionPolicy;
|
||||
use App\Policies\TenantReviewPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -32,7 +30,6 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||
TenantReview::class => TenantReviewPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
AlertDestination::class => AlertDestinationPolicy::class,
|
||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||
|
||||
@ -6,9 +6,7 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
@ -28,7 +26,6 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@ -174,15 +171,12 @@ public function panel(Panel $panel): Panel
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
FindingExceptionsQueue::class,
|
||||
ReviewRegister::class,
|
||||
])
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->unsavedChangesAlerts()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
@ -203,7 +197,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Filament\System\Pages\Dashboard;
|
||||
use App\Http\Middleware\UseSystemSessionCookie;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -35,7 +34,6 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||
@ -61,6 +59,6 @@ public function panel(Panel $panel): Panel
|
||||
Authenticate::class,
|
||||
'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\TenantDashboard;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\PanelThemeAsset;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
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')
|
||||
->resources([
|
||||
TenantReviewResource::class,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
@ -92,7 +87,6 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
@ -113,7 +107,7 @@ public function panel(Panel $panel): Panel
|
||||
]);
|
||||
|
||||
if (! app()->runningUnitTests()) {
|
||||
$panel->theme(PanelThemeAsset::resolve('resources/css/filament/admin/theme.css'));
|
||||
$panel->viteTheme('resources/css/filament/admin/theme.css');
|
||||
}
|
||||
|
||||
return $panel;
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\LifecycleReconciliationReason;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -152,23 +151,25 @@ private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRunWithReconciliation(
|
||||
run: $run,
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
status: $opStatus,
|
||||
outcome: $opOutcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
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();
|
||||
|
||||
$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) {
|
||||
$run->started_at = $restoreRun->started_at;
|
||||
}
|
||||
|
||||
@ -27,8 +27,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
@ -52,10 +50,6 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -70,8 +64,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
@ -92,10 +84,6 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -105,7 +93,6 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
@ -120,14 +107,11 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
@ -139,8 +123,6 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_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_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
@ -64,7 +63,6 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
|
||||
final class BaselineCaptureService
|
||||
@ -23,7 +22,6 @@ final class BaselineCaptureService
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
private readonly BaselineFullContentRolloutGate $rolloutGate,
|
||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -55,7 +53,7 @@ public function startCapture(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'source_tenant_id' => (int) $sourceTenant->getKey(),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
|
||||
@ -17,21 +17,17 @@
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineScope;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
|
||||
final class BaselineCompareService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $runs,
|
||||
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(
|
||||
Tenant $tenant,
|
||||
@ -44,45 +40,38 @@ public function startCompare(
|
||||
->first();
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
return $this->failedStart($precondition);
|
||||
return ['ok' => false, 'reason_code' => $precondition];
|
||||
}
|
||||
|
||||
$selectedSnapshot = null;
|
||||
$snapshotId = $baselineSnapshotId !== null ? (int) $baselineSnapshotId : 0;
|
||||
|
||||
if (is_int($baselineSnapshotId) && $baselineSnapshotId > 0) {
|
||||
$selectedSnapshot = BaselineSnapshot::query()
|
||||
if ($snapshotId > 0) {
|
||||
$snapshot = BaselineSnapshot::query()
|
||||
->where('workspace_id', (int) $profile->workspace_id)
|
||||
->where('baseline_profile_id', (int) $profile->getKey())
|
||||
->whereKey((int) $baselineSnapshotId)
|
||||
->first();
|
||||
->whereKey($snapshotId)
|
||||
->first(['id']);
|
||||
|
||||
if (! $selectedSnapshot instanceof BaselineSnapshot) {
|
||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SNAPSHOT);
|
||||
if (! $snapshot instanceof BaselineSnapshot) {
|
||||
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(
|
||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
||||
);
|
||||
@ -103,7 +92,7 @@ public function startCompare(
|
||||
],
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'baseline_snapshot_id' => $snapshotId,
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext(),
|
||||
'capture_mode' => $captureMode->value,
|
||||
];
|
||||
|
||||
@ -124,7 +113,7 @@ public function startCompare(
|
||||
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) {
|
||||
return BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE;
|
||||
@ -134,20 +123,10 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
||||
return BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED;
|
||||
}
|
||||
|
||||
if (! $hasExplicitSnapshotSelection && $profile->active_snapshot_id === null) {
|
||||
return BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT;
|
||||
}
|
||||
|
||||
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\Support\Baselines\BaselineEvidenceResumeToken;
|
||||
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;
|
||||
|
||||
final class BaselineContentCapturePhase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
private readonly ?SubjectResolver $subjectResolver = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return array{
|
||||
* stats: array{requested: int, succeeded: int, skipped: int, failed: int, throttled: int},
|
||||
* gaps: array<string, int>,
|
||||
* gap_subjects: list<array<string, mixed>>,
|
||||
* resume_token: ?string,
|
||||
* captured_versions: array<string, array{
|
||||
* policy_type: string,
|
||||
@ -82,8 +76,6 @@ public function capture(
|
||||
|
||||
/** @var array<string, int> $gaps */
|
||||
$gaps = [];
|
||||
/** @var list<array<string, mixed>> $gapSubjects */
|
||||
$gapSubjects = [];
|
||||
$capturedVersions = [];
|
||||
|
||||
/**
|
||||
@ -95,40 +87,24 @@ public function capture(
|
||||
foreach ($chunk as $subject) {
|
||||
$policyType = trim((string) ($subject['policy_type'] ?? ''));
|
||||
$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 === '') {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->invalidSubject($descriptor));
|
||||
$gaps['invalid_subject'] = ($gaps['invalid_subject'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$captureKey = $policyType.'|'.$externalId;
|
||||
$subjectKey = $policyType.'|'.$externalId;
|
||||
|
||||
if (isset($seen[$captureKey])) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->duplicateSubject($descriptor));
|
||||
if (isset($seen[$subjectKey])) {
|
||||
$gaps['duplicate_subject'] = ($gaps['duplicate_subject'] ?? 0) + 1;
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$captureKey] = true;
|
||||
|
||||
if (
|
||||
$descriptor->resolutionPath === ResolutionPath::FoundationInventory
|
||||
|| $descriptor->resolutionPath === ResolutionPath::Inventory
|
||||
) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->structuralInventoryOnly($descriptor));
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$seen[$subjectKey] = true;
|
||||
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -137,7 +113,7 @@ public function capture(
|
||||
->first();
|
||||
|
||||
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']++;
|
||||
|
||||
continue;
|
||||
@ -176,7 +152,7 @@ public function capture(
|
||||
$version = $result['version'] ?? null;
|
||||
|
||||
if ($version instanceof PolicyVersion) {
|
||||
$capturedVersions[$captureKey] = [
|
||||
$capturedVersions[$subjectKey] = [
|
||||
'policy_type' => $policyType,
|
||||
'subject_external_id' => $externalId,
|
||||
'version' => $version,
|
||||
@ -202,10 +178,10 @@ public function capture(
|
||||
}
|
||||
|
||||
if ($isThrottled) {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->throttled($descriptor));
|
||||
$gaps['throttled'] = ($gaps['throttled'] ?? 0) + 1;
|
||||
$stats['throttled']++;
|
||||
} else {
|
||||
$this->recordGap($gaps, $gapSubjects, $descriptor, $this->resolver()->captureFailed($descriptor));
|
||||
$gaps['capture_failed'] = ($gaps['capture_failed'] ?? 0) + 1;
|
||||
$stats['failed']++;
|
||||
}
|
||||
|
||||
@ -225,22 +201,7 @@ public function capture(
|
||||
|
||||
$remainingCount = max(0, count($subjects) - $processed);
|
||||
if ($remainingCount > 0) {
|
||||
foreach (array_slice($subjects, $processed) as $remainingSubject) {
|
||||
$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));
|
||||
}
|
||||
$gaps['budget_exhausted'] = ($gaps['budget_exhausted'] ?? 0) + $remainingCount;
|
||||
}
|
||||
}
|
||||
|
||||
@ -249,27 +210,11 @@ public function capture(
|
||||
return [
|
||||
'stats' => $stats,
|
||||
'gaps' => $gaps,
|
||||
'gap_subjects' => $gapSubjects,
|
||||
'resume_token' => $resumeTokenOut,
|
||||
'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
|
||||
{
|
||||
$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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
@ -14,8 +13,6 @@
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -60,8 +57,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
$groups,
|
||||
);
|
||||
|
||||
$overallGapSummary = $this->summaryGapSummary($summary);
|
||||
$overallGapCount = $overallGapSummary->count;
|
||||
$overallGapCount = $this->summaryGapCount($summary);
|
||||
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
|
||||
|
||||
return new RenderedSnapshot(
|
||||
@ -71,7 +67,7 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
|
||||
? trim($snapshot->snapshot_identity_hash)
|
||||
: null,
|
||||
stateLabel: $this->gapStatusSpec($overallGapCount)->label,
|
||||
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
|
||||
fidelitySummary: $this->fidelitySummary($summary),
|
||||
overallFidelity: $overallFidelity,
|
||||
overallGapCount: $overallGapCount,
|
||||
@ -100,21 +96,10 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
{
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot);
|
||||
|
||||
$truthBadge = $factory->statusBadge(
|
||||
$truth->primaryBadgeSpec()->label,
|
||||
$truth->primaryBadgeSpec()->color,
|
||||
$truth->primaryBadgeSpec()->icon,
|
||||
$truth->primaryBadgeSpec()->iconColor,
|
||||
);
|
||||
|
||||
$lifecycleSpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotLifecycle, $snapshot->lifecycleState()->value);
|
||||
$lifecycleBadge = $factory->statusBadge(
|
||||
$lifecycleSpec->label,
|
||||
$lifecycleSpec->color,
|
||||
$lifecycleSpec->icon,
|
||||
$lifecycleSpec->iconColor,
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$rendered->stateLabel,
|
||||
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
@ -129,37 +114,21 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
$currentTruth = $this->currentTruthPresentation($truth);
|
||||
$currentTruthBadge = $factory->statusBadge(
|
||||
$currentTruth['label'],
|
||||
$currentTruth['color'],
|
||||
$currentTruth['icon'],
|
||||
$currentTruth['iconColor'],
|
||||
);
|
||||
$operatorExplanation = $truth->operatorExplanation;
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||
statusBadges: [$truthBadge, $lifecycleBadge, $fidelityBadge],
|
||||
statusBadges: [$stateBadge, $fidelityBadge],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
descriptionHint: 'Current baseline truth, lifecycle proof, and coverage stay ahead of technical payload detail.',
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'artifact_truth',
|
||||
kind: 'current_status',
|
||||
title: 'Artifact truth',
|
||||
view: 'filament.infolists.entries.governance-artifact-truth',
|
||||
viewData: ['state' => $truth->toArray()],
|
||||
description: 'Trustworthy artifact state stays separate from historical trace and support diagnostics.',
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'coverage_summary',
|
||||
kind: 'current_status',
|
||||
@ -191,30 +160,11 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Snapshot truth',
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Artifact truth', $truth->primaryLabel, badge: $truthBadge),
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result meaning', $operatorExplanation->evaluationResultLabel())
|
||||
: null,
|
||||
$operatorExplanation !== null
|
||||
? $factory->keyFact('Result trust', $operatorExplanation->trustworthinessLabel())
|
||||
: null,
|
||||
$factory->keyFact('Lifecycle', $lifecycleSpec->label, badge: $lifecycleBadge),
|
||||
$factory->keyFact('Current truth', $currentTruth['label'], badge: $currentTruthBadge),
|
||||
$operatorExplanation !== null && $operatorExplanation->coverageStatement !== null
|
||||
? $factory->keyFact('Coverage', $operatorExplanation->coverageStatement)
|
||||
: null,
|
||||
$factory->keyFact('Next step', $operatorExplanation?->nextActionText ?? $truth->nextStepText()),
|
||||
])),
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'coverage',
|
||||
title: 'Coverage',
|
||||
title: 'Snapshot status',
|
||||
items: [
|
||||
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
@ -222,8 +172,6 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
||||
title: 'Capture timing',
|
||||
items: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Completed', $this->formatTimestamp($snapshot->completed_at?->toIso8601String())),
|
||||
$factory->keyFact('Failed', $this->formatTimestamp($snapshot->failed_at?->toIso8601String())),
|
||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||
],
|
||||
),
|
||||
@ -275,6 +223,10 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
|
||||
$renderedItems,
|
||||
));
|
||||
|
||||
if ($renderingError !== null) {
|
||||
$gapSummary = $gapSummary->withMessage($renderingError);
|
||||
}
|
||||
|
||||
$capturedAt = collect($renderedItems)
|
||||
->pluck('observedAt')
|
||||
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
@ -324,7 +276,10 @@ private function technicalPayload(Collection $items): array
|
||||
*/
|
||||
private function summaryGapCount(array $summary): int
|
||||
{
|
||||
return $this->summaryGapSummary($summary)->count;
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$count = $gaps['count'] ?? 0;
|
||||
|
||||
return is_numeric($count) ? (int) $count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -339,67 +294,7 @@ private function fidelitySummary(array $summary): string
|
||||
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
|
||||
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
|
||||
|
||||
return sprintf(
|
||||
'%s %d, %s %d',
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::Full->value)->label,
|
||||
$content,
|
||||
BadgeCatalog::spec(BadgeDomain::BaselineSnapshotFidelity, FidelityState::ReferenceOnly->value)->label,
|
||||
$meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
private function summaryGapSummary(array $summary): GapSummary
|
||||
{
|
||||
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
|
||||
$byReason = is_array($gaps['by_reason'] ?? null) ? $gaps['by_reason'] : [];
|
||||
$gapSummary = GapSummary::fromReasonMap($byReason);
|
||||
|
||||
if ($byReason !== [] || ! is_numeric($gaps['count'] ?? null) || (int) $gaps['count'] <= 0) {
|
||||
return $gapSummary;
|
||||
}
|
||||
|
||||
return new GapSummary(
|
||||
count: (int) $gaps['count'],
|
||||
messages: ['Coverage gaps need review.'],
|
||||
);
|
||||
}
|
||||
|
||||
private function gapStatusSpec(int $gapCount): \App\Support\Badges\BadgeSpec
|
||||
{
|
||||
return BadgeRenderer::spec(
|
||||
BadgeDomain::BaselineSnapshotGapStatus,
|
||||
$gapCount > 0 ? 'gaps_present' : 'clear',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, color: string, icon: string, iconColor: string}
|
||||
*/
|
||||
private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
|
||||
{
|
||||
return match ($truth->artifactExistence) {
|
||||
'historical_only' => [
|
||||
'label' => 'Historical trace',
|
||||
'color' => 'gray',
|
||||
'icon' => 'heroicon-m-clock',
|
||||
'iconColor' => 'gray',
|
||||
],
|
||||
'created_but_not_usable' => [
|
||||
'label' => 'Not compare input',
|
||||
'color' => 'warning',
|
||||
'icon' => 'heroicon-m-exclamation-triangle',
|
||||
'iconColor' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'label' => 'Current baseline',
|
||||
'color' => 'success',
|
||||
'icon' => 'heroicon-m-check-badge',
|
||||
'iconColor' => 'success',
|
||||
],
|
||||
};
|
||||
return sprintf('Content %d, Meta %d', $content, $meta);
|
||||
}
|
||||
|
||||
private function typeLabel(string $policyType): string
|
||||
|
||||
@ -95,9 +95,9 @@ public function coverageHint(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Full => null,
|
||||
self::Partial => 'Mixed evidence detail is available for this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available for this group.',
|
||||
self::Unsupported => 'Support is limited for this policy type. Fallback rendering is being used.',
|
||||
self::Partial => 'Mixed evidence fidelity across this group.',
|
||||
self::ReferenceOnly => 'Metadata-only evidence is available.',
|
||||
self::Unsupported => 'Fallback metadata rendering is being used.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
return new self(
|
||||
count: 0,
|
||||
count: count($messages),
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
@ -60,25 +60,17 @@ public static function fromItemMeta(array $meta, FidelityState $fidelity, bool $
|
||||
public static function fromReasonMap(array $reasons): self
|
||||
{
|
||||
$messages = [];
|
||||
$primaryCount = 0;
|
||||
|
||||
foreach ($reasons as $reason => $reasonCount) {
|
||||
if (! is_string($reason) || ! is_numeric($reasonCount) || (int) $reasonCount <= 0) {
|
||||
foreach ($reasons as $reason => $count) {
|
||||
if (! is_string($reason) || ! is_numeric($count) || (int) $count <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isDiagnosticReason($reason)) {
|
||||
$messages[] = sprintf('%s (%d)', self::diagnosticMessageForReason($reason), (int) $reasonCount);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $reasonCount);
|
||||
$primaryCount += (int) $reasonCount;
|
||||
$messages[] = sprintf('%s (%d)', self::humanizeReason($reason), (int) $count);
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: $primaryCount,
|
||||
count: array_sum(array_map(static fn (mixed $value): int => is_numeric($value) ? (int) $value : 0, $reasons)),
|
||||
messages: self::uniqueMessages($messages),
|
||||
);
|
||||
}
|
||||
@ -98,6 +90,10 @@ public static function merge(array $summaries): self
|
||||
|
||||
$messages = self::uniqueMessages($messages);
|
||||
|
||||
if ($count === 0) {
|
||||
$count = count($messages);
|
||||
}
|
||||
|
||||
return new self(
|
||||
count: $count,
|
||||
messages: $messages,
|
||||
@ -115,14 +111,14 @@ public function withMessage(string $message): self
|
||||
$messages = self::uniqueMessages([...$this->messages, $message]);
|
||||
|
||||
return new self(
|
||||
count: $this->count,
|
||||
count: max($this->count, count($messages)),
|
||||
messages: $messages,
|
||||
);
|
||||
}
|
||||
|
||||
public function hasGaps(): bool
|
||||
{
|
||||
return $this->count > 0;
|
||||
return $this->count > 0 || $this->messages !== [];
|
||||
}
|
||||
|
||||
public function badgeState(): string
|
||||
@ -162,17 +158,4 @@ private static function humanizeReason(string $reason): string
|
||||
->headline()
|
||||
->toString();
|
||||
}
|
||||
|
||||
private static function isDiagnosticReason(string $reason): bool
|
||||
{
|
||||
return in_array($reason, ['meta_fallback'], true);
|
||||
}
|
||||
|
||||
private static function diagnosticMessageForReason(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'meta_fallback' => 'Metadata-only evidence was used for some items.',
|
||||
default => self::humanizeReason($reason),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Contracts;
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
interface EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* dimension_key: string,
|
||||
* state: string,
|
||||
* required: bool,
|
||||
* source_kind: string,
|
||||
* source_record_type: ?string,
|
||||
* source_record_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* measured_at: ?\DateTimeInterface,
|
||||
* freshness_at: ?\DateTimeInterface,
|
||||
* summary_payload: array<string, mixed>,
|
||||
* fingerprint_payload: array<string, mixed>,
|
||||
* sort_order: int
|
||||
* }
|
||||
*/
|
||||
public function collect(Tenant $tenant): array;
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessEvaluator
|
||||
{
|
||||
/**
|
||||
* @param list<array{state: string, required: bool}> $items
|
||||
*/
|
||||
public function evaluate(array $items): EvidenceCompletenessState
|
||||
{
|
||||
$requiredItems = array_values(array_filter($items, static fn (array $item): bool => $item['required'] === true));
|
||||
|
||||
if ($requiredItems === []) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Missing->value) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Stale->value) {
|
||||
return EvidenceCompletenessState::Stale;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Partial->value) {
|
||||
return EvidenceCompletenessState::Partial;
|
||||
}
|
||||
}
|
||||
|
||||
return EvidenceCompletenessState::Complete;
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceResolutionRequest
|
||||
{
|
||||
/**
|
||||
* @param list<string> $requiredDimensions
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
public readonly ?int $snapshotId = null,
|
||||
public readonly array $requiredDimensions = [],
|
||||
public readonly bool $allowStale = false,
|
||||
) {}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
|
||||
final class EvidenceResolutionResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $eligibleDimensions
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $outcome,
|
||||
public readonly ?EvidenceSnapshot $snapshot,
|
||||
public readonly array $eligibleDimensions = [],
|
||||
public readonly array $reasons = [],
|
||||
) {}
|
||||
|
||||
public static function resolved(EvidenceSnapshot $snapshot, array $eligibleDimensions): self
|
||||
{
|
||||
return new self('resolved', $snapshot, $eligibleDimensions, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function missingSnapshot(array $reasons = []): self
|
||||
{
|
||||
return new self('missing_snapshot', null, [], $reasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function snapshotIneligible(EvidenceSnapshot $snapshot, array $reasons): self
|
||||
{
|
||||
return new self('snapshot_ineligible', $snapshot, [], $reasons);
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->outcome === 'resolved' && $this->snapshot instanceof EvidenceSnapshot;
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceSnapshotFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function hash(array $payload): string
|
||||
{
|
||||
return hash('sha256', json_encode(self::normalize($payload), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private static function normalize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
ksort($value);
|
||||
|
||||
return array_map(self::normalize(...), $value);
|
||||
}
|
||||
|
||||
if ($value instanceof \BackedEnum) {
|
||||
return $value->value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user