Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
de0679cd8b feat: implement spec 147 tenant context enforcement 2026-03-16 23:52:03 +01:00
733 changed files with 2024 additions and 66954 deletions

View File

@ -83,29 +83,6 @@ ## Active Technologies
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
- 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 +102,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
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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 specs 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 specs “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),

View File

@ -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';
}
}

View File

@ -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;

View File

@ -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));
}
}

View File

@ -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.',
};
}
}

View File

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use App\Models\Tenant;
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait InteractsWithTenantOwnedRecords
{
protected static function tenantOwnedRelationshipName(): string
{
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
? static::$tenantOwnershipRelationshipName
: null;
return is_string($relationshipName) && $relationshipName !== ''
? $relationshipName
: 'tenant';
}
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
{
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
return static::resolveTenantContextForCurrentPanel();
}
if (method_exists(static::class, 'panelTenantContext')) {
return static::panelTenantContext();
}
return null;
}
public static function getTenantOwnedEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
}
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
{
return app(TenantOwnedQueryScope::class)->apply(
$query,
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
static::tenantOwnedRelationshipName(),
);
}
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
{
$scopedQuery = static::scopeTenantOwnedQuery(
$query ?? parent::getEloquentQuery(),
$tenant,
);
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
}
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
{
$scopedQuery = static::scopeTenantOwnedQuery(
$query ?? parent::getEloquentQuery(),
$tenant,
);
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
}
}

View File

@ -14,7 +14,7 @@ trait ResolvesPanelTenantContext
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
@ -24,16 +24,6 @@ protected static function resolveTenantContextForCurrentPanel(): ?Tenant
return $tenant instanceof Tenant ? $tenant : null;
}
public static function panelTenantContext(): ?Tenant
{
return static::resolveTenantContextForCurrentPanel();
}
public static function trustedPanelTenantContext(): ?Tenant
{
return static::panelTenantContext();
}
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
{
$tenant = static::resolveTenantContextForCurrentPanel();
@ -44,9 +34,4 @@ protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
return $tenant;
}
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
{
return static::resolveTenantContextForCurrentPanelOrFail();
}
}

View File

@ -6,7 +6,6 @@
use App\Models\Tenant;
use App\Support\OperateHub\OperateHubShell;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@ -22,10 +21,6 @@ public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
return $query->whereRaw('1 = 0');
}
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();

View File

@ -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();

View File

@ -8,9 +8,7 @@
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Filament\Pages\Page;
@ -70,22 +68,7 @@ public function selectTenant(int $tenantId): void
abort(403);
}
$workspaceContext = app(WorkspaceContext::class);
$workspaceId = $workspaceContext->currentWorkspaceId(request());
$tenant = null;
if ($workspaceId === null) {
$tenant = Tenant::query()->whereKey($tenantId)->first();
if ($tenant instanceof Tenant) {
$workspace = $tenant->workspace;
if ($workspace !== null && $user->canAccessTenant($tenant)) {
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
$workspaceId = (int) $workspace->getKey();
}
}
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
$this->redirect(route('filament.admin.pages.choose-workspace'));
@ -93,12 +76,10 @@ public function selectTenant(int $tenantId): void
return;
}
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
$tenant = Tenant::query()
->where('workspace_id', $workspaceId)
->whereKey($tenantId)
->first();
}
$tenant = Tenant::query()
->where('workspace_id', $workspaceId)
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
@ -108,21 +89,13 @@ public function selectTenant(int $tenantId): void
abort(404);
}
$outcome = app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::SelectorEligibility,
actor: $user,
workspaceId: $workspaceId,
lane: TenantInteractionLane::StandardActiveOperating,
);
if (! $outcome->allowed) {
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, request())) {
abort(404);
}

View File

@ -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>
*/

View File

@ -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')),
];
}
}

View File

@ -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'));
}
}

View File

@ -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),
);
}
}

View File

@ -11,7 +11,6 @@
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperateHub\OperateHubShell;
@ -19,13 +18,8 @@
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;
@ -109,7 +103,14 @@ protected function getHeaderActions(): array
return $actions;
}
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
$user = auth()->user();
$tenant = $this->run->tenant;
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
$tenant = null;
}
$related = OperationRunLinks::related($this->run, $tenant);
$relatedActions = [];
@ -163,69 +164,6 @@ public function redactionIntegrityNote(): ?string
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function blockedExecutionBanner(): ?array
{
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
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.',
]);
return [
'tone' => 'amber',
'title' => 'Blocked by prerequisite',
'body' => implode(' ', $lines),
];
}
/**
* @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
*/
@ -434,39 +372,4 @@ private function canResumeCapture(): bool
return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
}
private function relatedLinksTenant(): ?Tenant
{
if (! isset($this->run)) {
return null;
}
$user = auth()->user();
$tenant = $this->run->tenant;
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
return null;
}
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::SelectorEligibility,
actor: $user,
workspaceId: (int) ($this->run->workspace_id ?? 0),
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;
}
}

View File

@ -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;
}
}

View File

@ -12,8 +12,6 @@
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TenantRequiredPermissions extends Page
{
@ -43,8 +41,7 @@ class TenantRequiredPermissions extends Page
*/
public array $viewModel = [];
#[Locked]
public ?int $scopedTenantId = null;
public ?Tenant $scopedTenant = null;
public static function canAccess(): bool
{
@ -53,7 +50,7 @@ public static function canAccess(): bool
public function currentTenant(): ?Tenant
{
return $this->trustedScopedTenant();
return $this->scopedTenant;
}
public function mount(): void
@ -64,7 +61,7 @@ public function mount(): void
abort(404);
}
$this->scopedTenantId = (int) $tenant->getKey();
$this->scopedTenant = $tenant;
$this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions';
@ -146,7 +143,7 @@ public function resetFilters(): void
private function refreshViewModel(): void
{
$tenant = $this->trustedScopedTenant();
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) {
$this->viewModel = [];
@ -175,7 +172,7 @@ private function refreshViewModel(): void
public function reRunVerificationUrl(): string
{
$tenant = $this->trustedScopedTenant();
$tenant = $this->scopedTenant;
if ($tenant instanceof Tenant) {
return TenantResource::getUrl('view', ['record' => $tenant]);
@ -186,7 +183,7 @@ public function reRunVerificationUrl(): string
public function manageProviderConnectionUrl(): ?string
{
$tenant = $this->trustedScopedTenant();
$tenant = $this->scopedTenant;
if (! $tenant instanceof Tenant) {
return null;
@ -237,47 +234,4 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
return $user->canAccessTenant($tenant);
}
private function trustedScopedTenant(): ?Tenant
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$workspaceContext = app(WorkspaceContext::class);
try {
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
} catch (NotFoundHttpException) {
return null;
}
$routeTenant = static::resolveScopedTenant();
if ($routeTenant instanceof Tenant) {
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
if ($this->scopedTenantId === null) {
return null;
}
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
if (! $tenant instanceof Tenant) {
return null;
}
try {
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
} catch (NotFoundHttpException) {
return null;
}
}
}

View File

@ -39,7 +39,6 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Livewire\TrustedState\TrustedStateResolver;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\Onboarding\OnboardingLifecycleState;
@ -52,9 +51,7 @@
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Verification\VerificationAssistViewModelBuilder;
use App\Support\Verification\VerificationCheckStatus;
use App\Support\Verification\VerificationReportOverall;
@ -89,7 +86,6 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Livewire\Attributes\Locked;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -125,14 +121,8 @@ protected function getLayoutData(): array
public ?Tenant $managedTenant = null;
#[Locked]
public ?int $managedTenantId = null;
public ?TenantOnboardingSession $onboardingSession = null;
#[Locked]
public ?int $onboardingSessionId = null;
public ?int $onboardingSessionVersion = null;
public ?int $selectedProviderConnectionId = null;
@ -159,8 +149,6 @@ protected function getLayoutData(): array
protected function getHeaderActions(): array
{
$actions = [];
$draft = $this->currentOnboardingSessionRecord();
$tenant = $this->currentManagedTenantRecord();
if (isset($this->workspace)) {
$actions[] = Action::make('back_to_workspace')
@ -180,10 +168,10 @@ protected function getHeaderActions(): array
$actions[] = Action::make('view_linked_tenant')
->label($this->linkedTenantActionLabel())
->color('gray')
->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
}
if ($this->canResumeDraft($draft)) {
if ($this->canResumeDraft($this->onboardingSession)) {
$actions[] = Action::make('cancel_onboarding_draft')
->label('Cancel draft')
->color('danger')
@ -194,7 +182,7 @@ protected function getHeaderActions(): array
->action(fn () => $this->cancelOnboardingDraft());
}
if ($this->canDeleteDraft($draft)) {
if ($this->canDeleteDraft($this->onboardingSession)) {
$actions[] = Action::make('delete_onboarding_draft_header')
->label('Delete draft')
->color('danger')
@ -212,36 +200,27 @@ protected function getHeaderActions(): array
private function canViewLinkedTenant(): bool
{
$user = auth()->user();
$tenant = $this->currentManagedTenantRecord();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
if (! $user instanceof User || ! $this->managedTenant instanceof Tenant) {
return false;
}
if (! $user->canAccessTenant($tenant)) {
if (! $user->canAccessTenant($this->managedTenant)) {
return false;
}
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::TenantBoundViewability,
actor: $user,
workspaceId: (int) $this->workspace->getKey(),
lane: TenantInteractionLane::AdministrativeManagement,
)->allowed;
return app(TenantOperabilityService::class)->canViewTenantSurface($this->managedTenant);
}
private function linkedTenantActionLabel(): string
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
if (! $this->managedTenant instanceof Tenant) {
return 'View tenant';
}
return sprintf(
'View tenant (%s)',
TenantLifecyclePresentation::fromTenant($tenant)->label,
TenantLifecyclePresentation::fromTenant($this->managedTenant)->label,
);
}
@ -725,7 +704,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
$tenant = $draft->tenant;
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
$this->setManagedTenant($tenant);
$this->managedTenant = $tenant;
}
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
@ -814,9 +793,7 @@ private function draftPickerSchema(): array
*/
private function resumeContextSchema(): array
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
@ -829,19 +806,19 @@ private function resumeContextSchema(): array
->schema([
Text::make('Tenant')
->color('gray'),
Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft))
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
->weight(FontWeight::SemiBold),
Text::make('Current stage')
->color('gray'),
Text::make(fn () => $this->draftStageLabel($this->currentOnboardingSessionRecord() ?? $draft))
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
->badge()
->color(fn () => $this->draftStageColor($this->currentOnboardingSessionRecord() ?? $draft)),
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
Text::make('Started by')
->color('gray'),
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->startedByUser?->name ?? 'Unknown'),
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
Text::make('Last updated by')
->color('gray'),
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->updatedByUser?->name ?? 'Unknown'),
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
]),
];
}
@ -851,13 +828,11 @@ private function resumeContextSchema(): array
*/
private function nonResumableSummarySchema(): array
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
$statusLabel = $draft->status()->label();
$statusLabel = $this->onboardingSession->status()->label();
return [
Callout::make("This onboarding draft is {$statusLabel}.")
@ -872,16 +847,16 @@ private function nonResumableSummarySchema(): array
->color('gray'),
Text::make(fn (): string => $statusLabel)
->badge()
->color(fn () => $this->draftStatusColor($this->currentOnboardingSessionRecord() ?? $draft)),
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
Text::make('Primary domain')
->color('gray'),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['primary_domain'] ?? null) ?: '—')),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
Text::make('Environment')
->color('gray'),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['environment'] ?? null) ?: '—')),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
Text::make('Notes')
->color('gray'),
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['notes'] ?? null) ?: '—')),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
]),
SchemaActions::make([
Action::make('back_to_workspace_summary')
@ -899,7 +874,7 @@ private function nonResumableSummarySchema(): array
->modalHeading('Delete onboarding draft')
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
->modalSubmitActionLabel('Delete draft')
->visible(fn (): bool => $this->canDeleteDraft($this->currentOnboardingSessionRecord() ?? $draft))
->visible(fn (): bool => $this->canDeleteDraft($this->onboardingSession))
->action(fn () => $this->deleteOnboardingDraft()),
]),
];
@ -909,7 +884,7 @@ private function startNewOnboardingDraft(): void
{
$this->showDraftPicker = false;
$this->showStartState = true;
$this->setManagedTenant(null);
$this->managedTenant = null;
$this->setOnboardingSession(null);
$this->selectedProviderConnectionId = null;
$this->selectedBootstrapOperationTypes = [];
@ -961,20 +936,9 @@ private function cancelOnboardingDraft(): void
abort(404);
}
$this->authorizeWorkspaceMember($user);
$this->authorize('cancel', $this->onboardingSession);
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
);
$this->setOnboardingSession($draft);
$this->authorize('cancel', $draft);
if (! $this->canResumeDraft($draft)) {
if (! $this->canResumeDraft($this->onboardingSession)) {
Notification::make()
->title('Draft is not resumable')
->warning()
@ -1035,7 +999,8 @@ private function cancelOnboardingDraft(): void
],
);
$this->setManagedTenant($normalizedTenant);
$this->managedTenant = $normalizedTenant;
$this->onboardingSession->setRelation('tenant', $normalizedTenant);
}
Notification::make()
@ -1058,20 +1023,9 @@ private function deleteOnboardingDraft(): void
abort(404);
}
$this->authorizeWorkspaceMember($user);
$this->authorize('cancel', $this->onboardingSession);
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
);
$this->setOnboardingSession($draft);
$this->authorize('cancel', $draft);
if (! $this->canDeleteDraft($draft)) {
if (! $this->canDeleteDraft($this->onboardingSession)) {
Notification::make()
->title('Draft cannot be deleted')
->warning()
@ -1080,6 +1034,7 @@ private function deleteOnboardingDraft(): void
return;
}
$draft = $this->onboardingSession;
$draftId = (int) $draft->getKey();
$draftTitle = $this->draftTitle($draft);
$draftStatus = $draft->status()->value;
@ -1107,7 +1062,7 @@ private function deleteOnboardingDraft(): void
targetLabel: $draftTitle,
);
$this->setManagedTenant(null);
$this->managedTenant = null;
$this->setOnboardingSession(null);
Notification::make()
@ -1120,10 +1075,8 @@ private function deleteOnboardingDraft(): void
private function showsNonResumableSummary(): bool
{
$draft = $this->currentOnboardingSessionRecord();
return $draft instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($draft);
return $this->onboardingSession instanceof TenantOnboardingSession
&& ! $this->canResumeDraft($this->onboardingSession);
}
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
@ -1156,13 +1109,11 @@ private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App
private function shouldShowDraftLandingAction(): bool
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return false;
}
if (! $this->canResumeDraft($draft)) {
if (! $this->canResumeDraft($this->onboardingSession)) {
return false;
}
@ -1260,95 +1211,14 @@ private function expectedDraftVersion(): ?int
private function setOnboardingSession(?TenantOnboardingSession $draft): void
{
$this->onboardingSession = $draft;
$this->onboardingSessionId = $draft instanceof TenantOnboardingSession
? (int) $draft->getKey()
: null;
$this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession
? $draft->expectedVersion()
: null;
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
$this->setManagedTenant($draft->tenant);
return;
}
if ($draft instanceof TenantOnboardingSession && $draft->tenant_id !== null) {
$this->managedTenantId = (int) $draft->tenant_id;
return;
}
$this->setManagedTenant(null);
}
private function setManagedTenant(?Tenant $tenant): void
{
$this->managedTenant = $tenant;
$this->managedTenantId = $tenant instanceof Tenant
? (int) $tenant->getKey()
: null;
if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof Tenant) {
$this->onboardingSession->setRelation('tenant', $tenant);
}
}
private function currentOnboardingSessionRecord(): ?TenantOnboardingSession
{
if ($this->onboardingSession instanceof TenantOnboardingSession
&& $this->onboardingSessionId !== null
&& (int) $this->onboardingSession->getKey() === $this->onboardingSessionId) {
return $this->onboardingSession;
}
if ($this->onboardingSessionId === null) {
return $this->onboardingSession;
}
$query = TenantOnboardingSession::query()
->with(['tenant', 'startedByUser', 'updatedByUser'])
->whereKey($this->onboardingSessionId);
if (isset($this->workspace)) {
$query->where('workspace_id', (int) $this->workspace->getKey());
}
return $query->first();
}
private function currentManagedTenantRecord(): ?Tenant
{
$draft = $this->currentOnboardingSessionRecord();
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
return $draft->tenant;
}
if ($this->managedTenant instanceof Tenant
&& $this->managedTenantId !== null
&& (int) $this->managedTenant->getKey() === $this->managedTenantId) {
return $this->managedTenant;
}
if ($this->managedTenantId === null) {
return $this->managedTenant;
}
$query = Tenant::query()->withTrashed()->whereKey($this->managedTenantId);
if (isset($this->workspace)) {
$query->where('workspace_id', (int) $this->workspace->getKey());
}
return $query->first();
}
private function refreshOnboardingDraftFromBackend(): void
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
@ -1359,11 +1229,15 @@ private function refreshOnboardingDraftFromBackend(): void
}
$this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve(
$draft,
$this->onboardingSession,
$user,
$this->workspace,
));
if ($this->onboardingSession->tenant instanceof Tenant) {
$this->managedTenant = $this->onboardingSession->tenant;
}
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
$this->initializeWizardData();
@ -1393,13 +1267,11 @@ private function handleImmutableDraft(string $title = 'This onboarding draft is
private function lifecycleState(): OnboardingLifecycleState
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return OnboardingLifecycleState::Draft;
}
return $this->lifecycleService()->snapshot($draft)['lifecycle_state'];
return $this->lifecycleService()->snapshot($this->onboardingSession)['lifecycle_state'];
}
private function lifecycleStateLabel(): string
@ -1422,38 +1294,30 @@ private function lifecycleStateColor(): string
private function currentCheckpointLabel(): string
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return OnboardingCheckpoint::Identify->label();
}
return ($this->lifecycleService()->snapshot($draft)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
return ($this->lifecycleService()->snapshot($this->onboardingSession)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
?? OnboardingCheckpoint::Identify->label();
}
public function shouldPollCheckpointLifecycle(): bool
{
$draft = $this->currentOnboardingSessionRecord();
return $draft instanceof TenantOnboardingSession
&& $this->lifecycleService()->hasActiveCheckpoint($draft);
return $this->onboardingSession instanceof TenantOnboardingSession
&& $this->lifecycleService()->hasActiveCheckpoint($this->onboardingSession);
}
public function refreshCheckpointLifecycle(): void
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($draft));
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($this->onboardingSession));
$tenant = $this->currentManagedTenantRecord();
if ($tenant instanceof Tenant) {
$this->setManagedTenant($tenant->fresh());
if ($this->managedTenant instanceof Tenant) {
$this->managedTenant->refresh();
}
$this->initializeWizardData();
@ -1479,24 +1343,20 @@ private function initializeWizardData(): void
$this->data['new_connection']['is_default'] ??= true;
}
$tenant = $this->currentManagedTenantRecord();
if ($this->managedTenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
$this->data['name'] ??= (string) $this->managedTenant->name;
$this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? '');
if ($tenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
$this->data['name'] ??= (string) $tenant->name;
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
$notes = is_array($tenant->metadata) ? ($tenant->metadata['notes'] ?? null) : null;
$notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null;
if (is_string($notes) && trim($notes) !== '') {
$this->data['notes'] ??= trim($notes);
}
}
$draft = $this->currentOnboardingSessionRecord();
if ($draft instanceof TenantOnboardingSession) {
$state = is_array($draft->state) ? $draft->state : [];
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') {
$this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']);
@ -1526,13 +1386,13 @@ private function initializeWizardData(): void
}
}
$providerConnectionId = $this->resolvePersistedProviderConnectionId($draft->state['provider_connection_id'] ?? null);
$providerConnectionId = $this->resolvePersistedProviderConnectionId($this->onboardingSession->state['provider_connection_id'] ?? null);
if ($providerConnectionId !== null) {
$this->data['provider_connection_id'] = $providerConnectionId;
$this->selectedProviderConnectionId = $providerConnectionId;
}
$types = $draft->state['bootstrap_operation_types'] ?? null;
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
}
@ -1550,7 +1410,7 @@ private function initializeWizardData(): void
private function computeWizardStartStep(): int
{
return app(OnboardingDraftStageResolver::class)
->resolve($this->currentOnboardingSessionRecord())
->resolve($this->onboardingSession)
->wizardStep();
}
@ -1559,15 +1419,13 @@ private function computeWizardStartStep(): int
*/
private function providerConnectionOptions(): array
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
if (! $this->managedTenant instanceof Tenant) {
return [];
}
return ProviderConnection::query()
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $tenant->getKey())
->where('tenant_id', $this->managedTenant->getKey())
->orderByDesc('is_default')
->orderBy('display_name')
->pluck('display_name', 'id')
@ -1584,13 +1442,11 @@ private function verificationStatusLabel(): string
private function verificationStatus(): string
{
$draft = $this->currentOnboardingSessionRecord();
if (! $draft instanceof TenantOnboardingSession) {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'not_started';
}
return $this->lifecycleService()->verificationStatus($draft, $this->selectedProviderConnectionId);
return $this->lifecycleService()->verificationStatus($this->onboardingSession, $this->selectedProviderConnectionId);
}
private function verificationStatusFromRunOutcome(OperationRun $run): string
@ -2087,19 +1943,6 @@ private function authorizeEditableDraft(User $user): void
return;
}
$expectedVersion = $this->expectedDraftVersion();
$this->setOnboardingSession(app(TrustedStateResolver::class)->resolveOnboardingDraft(
$this->onboardingSessionId ?? $this->onboardingSession,
$user,
$this->workspace,
app(OnboardingDraftResolver::class),
));
if ($expectedVersion !== null) {
$this->onboardingSessionVersion = $expectedVersion;
}
$this->authorize('update', $this->onboardingSession);
if (! $this->canResumeDraft($this->onboardingSession)) {
@ -2107,56 +1950,17 @@ private function authorizeEditableDraft(User $user): void
}
}
private function trustedManagedTenantForUser(User $user): Tenant
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = $tenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant = app(WorkspaceContext::class)->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
$this->setManagedTenant($tenant);
return $tenant;
}
private function canResumeDraft(?TenantOnboardingSession $draft): bool
{
if (! $draft instanceof TenantOnboardingSession) {
return false;
}
if (! $draft->tenant instanceof Tenant) {
return $this->lifecycleService()->canResumeDraft($draft);
}
$user = $this->currentUser();
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $draft->tenant,
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
actor: $user instanceof User ? $user : null,
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
lane: TenantInteractionLane::OnboardingWorkflow,
onboardingDraft: $draft,
)->allowed;
return $draft instanceof TenantOnboardingSession
&& $this->lifecycleService()->canResumeDraft($draft);
}
private function authorizeWorkspaceMember(User $user): void
{
$this->workspace = app(TrustedStateResolver::class)->currentWorkspaceForMember(
$user,
app(WorkspaceContext::class),
request(),
);
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
abort(404);
}
}
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
@ -2353,7 +2157,7 @@ public function identifyManagedTenant(array $data): void
resourceId: (string) $tenant->getKey(),
);
$this->setManagedTenant($tenant);
$this->managedTenant = $tenant;
$this->setOnboardingSession($session);
});
} catch (OnboardingDraftConflictException) {
@ -2392,11 +2196,13 @@ public function selectProviderConnection(int $providerConnectionId): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
$this->authorizeEditableDraft($user);
$tenant = $this->trustedManagedTenantForUser($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$connection = ProviderConnection::query()
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->where('tenant_id', $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
@ -2442,7 +2248,7 @@ public function selectProviderConnection(int $providerConnectionId): void
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
@ -2475,7 +2281,11 @@ public function createProviderConnection(array $data): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
$this->authorizeEditableDraft($user);
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
@ -2664,7 +2474,7 @@ public function createProviderConnection(array $data): void
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
@ -2706,9 +2516,7 @@ public function startVerification(): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
$this->authorizeEditableDraft($user);
try {
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
} catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
if (! $this->managedTenant instanceof Tenant) {
Notification::make()
->title('Identify a managed tenant first')
->warning()
@ -2717,6 +2525,8 @@ public function startVerification(): void
return;
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
@ -2882,12 +2692,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();
@ -2920,15 +2727,6 @@ public function startVerification(): void
public function refreshVerificationStatus(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMember($user);
$this->authorizeEditableDraft($user);
$this->refreshCheckpointLifecycle();
Notification::make()
@ -2951,7 +2749,11 @@ public function startBootstrap(array $operationTypes): void
$this->authorizeWorkspaceMember($user);
$this->authorizeEditableDraft($user);
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
@ -3252,19 +3054,6 @@ private function canCompleteOnboarding(): bool
return false;
}
$user = $this->currentUser();
if (! app(TenantOperabilityService::class)->outcomeFor(
tenant: $this->managedTenant,
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
actor: $user instanceof User ? $user : null,
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
lane: TenantInteractionLane::OnboardingWorkflow,
onboardingDraft: $this->onboardingSession,
)->allowed) {
return false;
}
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
return false;
}
@ -3286,27 +3075,23 @@ private function canCompleteOnboarding(): bool
private function completionSummaryTenantLine(): string
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
if (! $this->managedTenant instanceof Tenant) {
return '—';
}
$name = $tenant->name ?? '—';
$tenantId = $tenant->graphTenantId();
$name = $this->managedTenant->name ?? '—';
$tenantId = $this->managedTenant->graphTenantId();
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
}
private function completionSummaryConnectionLabel(): string
{
$tenant = $this->currentManagedTenantRecord();
if (! $tenant instanceof Tenant) {
if (! $this->managedTenant instanceof Tenant) {
return '—';
}
$connection = $this->resolveSelectedProviderConnection($tenant);
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
if (! $connection instanceof ProviderConnection) {
return 'Not configured';
@ -3434,29 +3219,12 @@ public function completeOnboarding(): void
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
$this->authorizeEditableDraft($user);
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->trustedManagedTenantForUser($user);
$completionOutcome = app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
actor: $user,
workspaceId: (int) $this->workspace->getKey(),
lane: TenantInteractionLane::OnboardingWorkflow,
onboardingDraft: $this->onboardingSession,
);
if (! $completionOutcome->allowed) {
Notification::make()
->title('Onboarding unavailable')
->body('This tenant can no longer be completed from the current onboarding workflow state.')
->warning()
->send();
return;
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
$run = $this->verificationRun();
@ -3492,7 +3260,7 @@ public function completeOnboarding(): void
}
}
$tenant = $tenant->fresh();
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);

View File

@ -10,9 +10,6 @@
use App\Models\User;
use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection;
@ -67,15 +64,7 @@ public function getTenants(): Collection
->where('workspace_id', $this->workspace->getKey())
->orderBy('name')
->get()
->filter(function (Tenant $tenant) use ($user): bool {
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
actor: $user,
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
lane: TenantInteractionLane::AdministrativeManagement,
)->allowed;
})
->filter(fn (Tenant $tenant): bool => app(TenantOperabilityService::class)->canViewTenantSurface($tenant))
->values();
}

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources;
use App\Exceptions\InvalidPolicyTypeException;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
@ -65,7 +64,6 @@
class BackupScheduleResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSchedule::class;
@ -583,8 +581,6 @@ public static function table(Table $table): Table
->color('danger')
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('delete', $record);
if ($record->trashed()) {
@ -626,8 +622,6 @@ public static function table(Table $table): Table
->color('success')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('restore', $record);
if (! $record->trashed()) {
@ -668,8 +662,6 @@ public static function table(Table $table): Table
->color('danger')
->visible(fn (BackupSchedule $record): bool => $record->trashed())
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
$record = static::resolveProtectedScheduleRecordOrFail($record);
Gate::authorize('forceDelete', $record);
if (! $record->trashed()) {
@ -927,32 +919,17 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return parent::getEloquentQuery()
->where('tenant_id', $tenantId)
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
->orderByDesc('is_enabled')
->orderBy('next_run_at');
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
}
protected static function resolveProtectedScheduleRecordOrFail(BackupSchedule|int|string $record): BackupSchedule
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof BackupSchedule) {
abort(404);
}
return $resolvedRecord;
return static::getEloquentQuery()->withTrashed();
}
public static function getRelations(): array

View File

@ -5,6 +5,7 @@
use App\Filament\Resources\BackupScheduleResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class EditBackupSchedule extends EditRecord
{
@ -12,7 +13,15 @@ class EditBackupSchedule extends EditRecord
protected function resolveRecord(int|string $key): Model
{
return BackupScheduleResource::resolveScopedRecordOrFail($key);
$record = BackupScheduleResource::getEloquentQuery()
->withTrashed()
->find($key);
if ($record === null) {
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
}
return $record;
}
protected function mutateFormDataBeforeSave(array $data): array

View File

@ -5,32 +5,18 @@
use App\Filament\Resources\BackupScheduleResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ListBackupSchedules extends ListRecords
{
protected static string $resource = BackupScheduleResource::class;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'restore', 'forceDelete'], true)) {
try {
BackupScheduleResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
@ -54,14 +40,4 @@ private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;
}
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: [],
request: request(),
tenantFilterName: null,
);
}
}

View File

@ -12,7 +12,6 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Closure;
use Filament\Actions;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
@ -25,19 +24,6 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
protected static ?string $title = 'Executions';
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && $name === 'view' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedOperationRun($context['recordKey']);
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
@ -62,7 +48,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('type')
->label('Type')
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
->formatStateUsing([OperationCatalog::class, 'label']),
Tables\Columns\TextColumn::make('status')
->badge()
@ -101,7 +87,6 @@ public function table(Table $table): Table
->label('View')
->icon('heroicon-o-eye')
->url(function (OperationRun $record): string {
$record = $this->resolveOwnerScopedOperationRun($record);
$tenant = Tenant::currentOrFail();
return OperationRunLinks::view($record, $tenant);
@ -112,32 +97,4 @@ public function table(Table $table): Table
->emptyStateHeading('No schedule runs yet')
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
}
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
{
$recordId = $record instanceof OperationRun
? (int) $record->getKey()
: (is_numeric($record) ? (int) $record : 0);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $this->getOwnerRecord()
->operationRuns()
->where('tenant_id', Tenant::currentOrFail()->getKey())
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof OperationRun) {
abort(404);
}
return $resolvedRecord;
}
public static function formatOperationType(?string $state): string
{
return OperationCatalog::label($state);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
@ -57,7 +56,6 @@
class BackupSetResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = BackupSet::class;
@ -122,12 +120,13 @@ public static function canCreate(): bool
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery();
}
$tenant = static::resolveTenantContextForCurrentPanel();
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
if (! $tenant instanceof Tenant) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
}
public static function form(Schema $schema): Schema

View File

@ -17,7 +17,6 @@
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewBackupSet extends ViewRecord
{
@ -25,11 +24,6 @@ class ViewBackupSet extends ViewRecord
protected static string $resource = BackupSetResource::class;
protected function resolveRecord(int|string $key): Model
{
return BackupSetResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
$actions = [

View File

@ -43,27 +43,6 @@ public function closeAddPoliciesModal(): void
$this->unmountAction();
}
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true) {
$backupSet = $this->getOwnerRecord();
if ($name === 'remove' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedBackupItemId($backupSet, $context['recordKey']);
}
if ($name === 'bulk_remove' && ($context['bulk'] ?? false) === true) {
$this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function table(Table $table): Table
{
$refreshTable = Actions\Action::make('refreshTable')
@ -98,7 +77,7 @@ public function table(Table $table): Table
->color('danger')
->icon('heroicon-o-x-mark')
->requiresConfirmation()
->action(function (mixed $record): void {
->action(function (BackupItem $record): void {
$backupSet = $this->getOwnerRecord();
$user = auth()->user();
@ -115,7 +94,7 @@ public function table(Table $table): Table
abort(404);
}
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
$backupItemIds = [(int) $record->getKey()];
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
@ -194,7 +173,14 @@ public function table(Table $table): Table
abort(404);
}
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
$backupItemIds = $records
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($backupItemIds === []) {
return;
@ -448,68 +434,4 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
return $query->whereIn('policy_type', $types);
}
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
{
$recordId = $this->normalizeBackupItemKey($record);
if ($recordId <= 0) {
abort(404);
}
$resolvedId = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereKey($recordId)
->value('id');
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
abort(404);
}
return (int) $resolvedId;
}
/**
* @return array<int, int>
*/
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
{
$requestedIds = collect($recordKeys)
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if ($requestedIds === []) {
return [];
}
$resolvedIds = $backupSet->items()
->where('tenant_id', (int) $backupSet->tenant_id)
->whereIn('id', $requestedIds)
->pluck('id')
->map(fn (mixed $value): int => (int) $value)
->filter(fn (int $value): bool => $value > 0)
->unique()
->sort()
->values()
->all();
if (count($resolvedIds) !== count($requestedIds)) {
abort(404);
}
return $resolvedIds;
}
private function normalizeBackupItemKey(mixed $record): int
{
if ($record instanceof BackupItem) {
return (int) $record->getKey();
}
return is_numeric($record) ? (int) $record : 0;
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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';
}
}

View File

@ -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);

View File

@ -2,8 +2,6 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup;
@ -11,6 +9,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -34,16 +33,12 @@
class EntraGroupResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false;
protected static ?string $model = EntraGroup::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
protected static ?string $recordTitleAttribute = 'display_name';
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
@ -193,13 +188,15 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->latest('id');
}
$tenant = static::panelTenantContext();
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key);
return parent::getEloquentQuery()
->when(
$tenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenant->getKey()),
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->latest('id');
}
public static function getGlobalSearchResultUrl(Model $record): string
@ -219,6 +216,19 @@ public static function getPages(): array
];
}
public static function panelTenantContext(): ?Tenant
{
if (Filament::getCurrentPanel()?->getId() === 'admin') {
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
return $tenant instanceof Tenant ? $tenant : null;
}
$tenant = Tenant::current();
return $tenant instanceof Tenant ? $tenant : null;
}
/**
* @param array<string, mixed> $parameters
*/

View File

@ -8,17 +8,11 @@
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewEntraGroup extends ViewRecord
{
protected static string $resource = EntraGroupResource::class;
protected function resolveRecord(int|string $key): Model
{
return EntraGroupResource::resolveScopedRecordOrFail($key);
}
protected function authorizeAccess(): void
{
$tenant = EntraGroupResource::panelTenantContext();

View File

@ -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();
}
}

View File

@ -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(),
];
}
}

View File

@ -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();
}
}

View File

@ -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';
}
}

View File

@ -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')),
];
}
}

View File

@ -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);
}
}

View File

@ -2,18 +2,14 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
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 +34,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;
@ -61,7 +55,6 @@
class FindingResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = Finding::class;
@ -119,8 +112,7 @@ public static function canView(Model $record): bool
}
if ($record instanceof Finding) {
return (int) $record->tenant_id === (int) $tenant->getKey()
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
@ -228,62 +220,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')
@ -816,7 +752,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->triage($record, $tenant, $user);
$triagedCount++;
} catch (Throwable) {
@ -897,7 +832,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
$assignedCount++;
} catch (Throwable) {
@ -972,7 +906,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->resolve($record, $tenant, $user, $reason);
$resolvedCount++;
} catch (Throwable) {
@ -1047,7 +980,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->close($record, $tenant, $user, $reason);
$closedCount++;
} catch (Throwable) {
@ -1075,6 +1007,79 @@ 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 {
$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')
@ -1084,19 +1089,12 @@ 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'])
->withSubjectDisplayName();
}
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
->withSubjectDisplayName(),
);
return parent::getEloquentQuery()
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
->withSubjectDisplayName()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
/**
@ -1172,9 +1170,7 @@ public static function workflowActions(): array
static::assignAction(),
static::resolveAction(),
static::closeAction(),
static::requestExceptionAction(),
static::renewExceptionAction(),
static::revokeExceptionAction(),
static::riskAcceptAction(),
static::reopenAction(),
];
}
@ -1186,7 +1182,7 @@ public static function triageAction(): Actions\Action
->label('Triage')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW,
Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
@ -1212,7 +1208,7 @@ public static function startProgressAction(): Actions\Action
->label('Start progress')
->icon('heroicon-o-play')
->color('info')
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true))
@ -1237,7 +1233,7 @@ public static function assignAction(): Actions\Action
->label('Assign')
->icon('heroicon-o-user-plus')
->color('gray')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->fillForm(fn (Finding $record): array => [
'assignee_user_id' => $record->assignee_user_id,
'owner_user_id' => $record->owner_user_id,
@ -1281,7 +1277,7 @@ public static function resolveAction(): Actions\Action
->label('Resolve')
->icon('heroicon-o-check-badge')
->color('success')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
->requiresConfirmation()
->form([
Textarea::make('resolved_reason')
@ -1316,7 +1312,6 @@ public static function closeAction(): Actions\Action
->label('Close')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
->requiresConfirmation()
->form([
Textarea::make('closed_reason')
@ -1344,153 +1339,36 @@ 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();
}
@ -1503,7 +1381,7 @@ public static function reopenAction(): Actions\Action
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->requiresConfirmation()
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation(
record: $record,
@ -1523,7 +1401,6 @@ public static function reopenAction(): Actions\Action
*/
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
{
$record = static::resolveProtectedFindingRecordOrFail($record);
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
@ -1540,15 +1417,6 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
return;
}
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
Notification::make()
->title('Finding belongs to a different workspace')
->danger()
->send();
return;
}
try {
$callback($record, $tenant, $user);
} catch (InvalidArgumentException $e) {
@ -1567,194 +1435,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);
}
private static function freshWorkflowStatus(Finding $record): string
{
return (string) static::freshWorkflowRecord($record)->status;
}
private static function resolveProtectedFindingRecordOrFail(Finding|int|string $record): Finding
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof Finding) {
abort(404);
}
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>
*/

View File

@ -23,7 +23,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Arr;
use Throwable;
@ -33,26 +32,14 @@ class ListFindings extends ListRecords
protected static string $resource = FindingResource::class;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
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)) {
try {
FindingResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: ['scope_key', 'run_ids'],
request: request(),
tenantFilterName: null,
);
parent::mount();
}
@ -259,7 +246,15 @@ protected function getHeaderActions(): array
protected function buildAllMatchingQuery(): Builder
{
$query = FindingResource::getEloquentQuery();
$query = Finding::query();
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
if (! is_numeric($tenantId)) {
return $query->whereRaw('1 = 0');
}
$query->where('tenant_id', (int) $tenantId);
$query->where('status', Finding::STATUS_NEW);
@ -309,16 +304,6 @@ protected function buildAllMatchingQuery(): Builder
return $query;
}
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: ['scope_key', 'run_ids'],
request: request(),
tenantFilterName: null,
);
}
private function filterIsActive(string $filterName): bool
{
$state = $this->getTableFilterState($filterName);

View File

@ -8,17 +8,11 @@
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
class ViewFinding extends ViewRecord
{
protected static string $resource = FindingResource::class;
protected function resolveRecord(int|string $key): Model
{
return FindingResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources;
use App\Filament\Clusters\Inventory\InventoryCluster;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\InventoryItemResource\Pages;
use App\Models\InventoryItem;
@ -39,7 +38,6 @@
class InventoryItemResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = InventoryItem::class;
@ -336,13 +334,11 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
->with('lastSeenRun');
}
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->with('lastSeenRun');
}
public static function getPages(): array

View File

@ -174,8 +174,6 @@ protected function getHeaderActions(): array
],
context: array_merge($computed['selection'], [
'selection_hash' => $computed['selection_hash'],
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],

View File

@ -4,14 +4,8 @@
use App\Filament\Resources\InventoryItemResource;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewInventoryItem extends ViewRecord
{
protected static string $resource = InventoryItemResource::class;
protected function resolveRecord(int|string $key): Model
{
return InventoryItemResource::resolveScopedRecordOrFail($key);
}
}

View File

@ -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,39 +322,7 @@ 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))
: null,
static::blockedExecutionDetail($record) !== null
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
: null,
static::blockedExecutionSource($record) !== null
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
: null,
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
])),
),
@ -435,32 +369,16 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$factory->viewSection(
id: 'failures',
kind: 'operational_context',
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
title: 'Failures',
view: 'filament.infolists.entries.snapshot-json',
viewData: ['payload' => $record->failure_summary ?? []],
),
);
}
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 +391,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,68 +445,12 @@ 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),
);
}
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
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');
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
}
private static function blockedExecutionDetail(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
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.';
}
private static function blockedExecutionSource(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$context = is_array($record->context) ? $record->context : [];
$blockedBy = $context['blocked_by'] ?? null;
if (! is_string($blockedBy) || trim($blockedBy) === '') {
return null;
}
return match (trim($blockedBy)) {
'queued_execution_legitimacy' => 'Execution legitimacy revalidation',
default => ucfirst(str_replace('_', ' ', trim($blockedBy))),
};
}
/**
* @return list<array<string, mixed>>
*/
@ -616,8 +459,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 +490,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 +630,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) {

View File

@ -2,9 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Jobs\BulkPolicyDeleteJob;
@ -56,9 +54,7 @@
class PolicyResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static ?string $model = Policy::class;
@ -1014,25 +1010,16 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
return static::getTenantOwnedEloquentQuery()
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->withCount('versions')
->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]);
}
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->withCount('versions')
->with([
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
]),
);
}
public static function getRelations(): array
{
return [

View File

@ -3,20 +3,12 @@
namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords;
class ListPolicies extends ListRecords
{
protected static string $resource = PolicyResource::class;
public function mount(): void
{
$this->syncCanonicalAdminTenantFilterState();
parent::mount();
}
protected function getHeaderActions(): array
{
return [
@ -30,14 +22,4 @@ protected function getTableEmptyStateActions(): array
PolicyResource::makeSyncAction(),
];
}
private function syncCanonicalAdminTenantFilterState(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
tenantSensitiveFilters: [],
request: request(),
tenantFilterName: null,
);
}
}

View File

@ -16,7 +16,6 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class ViewPolicy extends ViewRecord
@ -25,11 +24,6 @@ class ViewPolicy extends ViewRecord
protected Width|string|null $maxContentWidth = Width::Full;
protected function resolveRecord(int|string $key): Model
{
return PolicyResource::resolveScopedRecordOrFail($key);
}
protected function getActions(): array
{
return [$this->makeCaptureSnapshotAction()];

View File

@ -4,7 +4,6 @@
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
@ -32,19 +31,6 @@ class VersionsRelationManager extends RelationManager
protected static string $relationship = 'versions';
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && $name === 'restore_to_intune' && filled($context['recordKey'] ?? null)) {
$this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $context['recordKey']);
}
return parent::mountAction($name, $arguments, $context);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
@ -69,8 +55,7 @@ public function table(Table $table): Table
->label('Preview only (dry-run)')
->default(true),
])
->action(function (mixed $record, array $data, RestoreService $restoreService) {
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
@ -193,26 +178,4 @@ public function table(Table $table): Table
->emptyStateHeading('No versions captured')
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
}
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
{
$recordId = $record instanceof PolicyVersion
? (int) $record->getKey()
: (is_numeric($record) ? (int) $record : 0);
if ($recordId <= 0) {
abort(404);
}
$resolvedRecord = $policy->versions()
->where('tenant_id', (int) $policy->tenant_id)
->whereKey($recordId)
->first();
if (! $resolvedRecord instanceof PolicyVersion) {
abort(404);
}
return $resolvedRecord;
}
}

View File

@ -2,9 +2,7 @@
namespace App\Filament\Resources;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob;
@ -61,9 +59,7 @@
class PolicyVersionResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ScopesGlobalSearchToTenant;
protected static ?string $model = PolicyVersion::class;
@ -897,6 +893,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$tenantId = $tenant->getKey();
$user = auth()->user();
$resolver = app(CapabilityResolver::class);
@ -906,7 +903,8 @@ public static function getEloquentQuery(): Builder
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return static::getTenantOwnedEloquentQuery()
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
@ -920,36 +918,6 @@ public static function getEloquentQuery(): Builder
->with('policy');
}
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
{
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
$user = auth()->user();
$resolver = app(CapabilityResolver::class);
$canSeeBaselinePurposeEvidence = $user instanceof User
&& (
$resolver->can($user, $tenant, Capabilities::TENANT_SYNC)
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
);
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()
->withTrashed()
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
return $query->where(function (Builder $query): void {
$query
->whereNull('capture_purpose')
->orWhereNotIn('capture_purpose', [
PolicyVersionCapturePurpose::BaselineCapture->value,
PolicyVersionCapturePurpose::BaselineCompare->value,
]);
});
})
->with('policy'),
);
}
/**
* @return list<array{
* key: string,

View File

@ -9,7 +9,6 @@
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Model;
class ViewPolicyVersion extends ViewRecord
{
@ -17,11 +16,6 @@ class ViewPolicyVersion extends ViewRecord
protected Width|string|null $maxContentWidth = Width::Full;
protected function resolveRecord(int|string $key): Model
{
return PolicyVersionResource::resolveScopedRecordOrFail($key);
}
protected function getHeaderActions(): array
{
return [

View File

@ -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')

View File

@ -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')

View File

@ -4,7 +4,6 @@
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Jobs\BulkRestoreRunDeleteJob;
@ -67,7 +66,6 @@
class RestoreRunResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = RestoreRun::class;
@ -244,44 +242,18 @@ public static function makeCreateAction(): Actions\CreateAction
public static function getEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
->with('backupSet');
}
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
);
}
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof RestoreRun) {
abort(404);
}
return $resolvedRecord;
}
/**
* @return array<int, int>
*/
protected static function resolveProtectedRestoreRunIds(Collection $records): array
{
return $records
->map(function (mixed $record): int {
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
return (int) $resolvedRecord->getKey();
})
->filter(fn (int $value): bool => $value > 0)
->unique()
->values()
->all();
return parent::getEloquentQuery()
->with('backupSet')
->when(
$tenantId !== null,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId),
)
->when(
$tenantId === null,
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
);
}
/**
@ -824,10 +796,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(),
@ -874,8 +846,6 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$record->restore();
if ($record->tenant) {
@ -907,8 +877,6 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if (! $record->isDeletable()) {
Notification::make()
->title('Restore run cannot be archived')
@ -950,8 +918,6 @@ public static function table(Table $table): Table
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
@ -1012,7 +978,7 @@ public static function table(Table $table): Table
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
@ -1082,7 +1048,7 @@ public static function table(Table $table): Table
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
@ -1172,7 +1138,7 @@ public static function table(Table $table): Table
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
$ids = $records->pluck('id')->toArray();
if (! $tenant instanceof Tenant) {
return;
@ -1261,7 +1227,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')
@ -1728,8 +1694,6 @@ public static function createRestoreRun(array $data): RestoreRun
'restore_run_id' => (int) $restoreRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);
@ -1961,7 +1925,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$tenant = $record->tenant;
$backupSet = $record->backupSet;
@ -2129,8 +2092,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
'restore_run_id' => (int) $newRun->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
initiator: $initiator,
);

View File

@ -3,42 +3,12 @@
namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class ListRestoreRuns extends ListRecords
{
protected static string $resource = RestoreRunResource::class;
/**
* @param array<string, mixed> $arguments
* @param array<string, mixed> $context
*/
public function mountAction(string $name, array $arguments = [], array $context = []): mixed
{
if (($context['table'] ?? false) === true && filled($context['recordKey'] ?? null) && in_array($name, ['archive', 'forceDelete', 'rerun'], true)) {
try {
RestoreRunResource::resolveScopedRecordOrFail($context['recordKey']);
} catch (ModelNotFoundException) {
abort(404);
}
}
return parent::mountAction($name, $arguments, $context);
}
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(
$this->getTableFiltersSessionKey(),
request: request(),
tenantFilterName: null,
);
parent::mount();
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;

View File

@ -4,14 +4,8 @@
use App\Filament\Resources\RestoreRunResource;
use Filament\Resources\Pages\ViewRecord;
use Illuminate\Database\Eloquent\Model;
class ViewRestoreRun extends ViewRecord
{
protected static string $resource = RestoreRunResource::class;
protected function resolveRecord(int|string $key): Model
{
return RestoreRunResource::resolveScopedRecordOrFail($key);
}
}

View File

@ -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()

View File

@ -41,10 +41,7 @@
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionSurface;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
use App\Support\Tenants\TenantOperabilityOutcome;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -231,7 +228,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
return static::getEloquentQuery()->whereRaw('1 = 0');
}
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
return static::tenantOperability()->applySelectableScope(
static::getEloquentQuery(),
(new Tenant)->getTable(),
);
@ -516,7 +513,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (
Tenant $record,
StartVerification $verification,
@ -608,12 +605,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 +905,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(),
@ -1099,26 +1081,6 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
}
public static function verificationActionVisible(Tenant $tenant): bool
{
$outcome = static::verificationReadinessOutcome($tenant);
return $outcome->allowed || $outcome->isDeniedForCapability();
}
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
{
$user = auth()->user();
return static::tenantOperability()->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
actor: $user instanceof User ? $user : null,
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
lane: TenantInteractionLane::AdministrativeManagement,
);
}
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
{
$descriptor = static::tenantActionCatalog($tenant, $surface)

View File

@ -87,7 +87,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (
Tenant $record,
StartVerification $verification,
@ -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();

View File

@ -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);
}
}

View File

@ -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(),
];
}
}

View File

@ -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'),
];
}
}

View File

@ -107,7 +107,10 @@ protected function getHeaderActions(): array
->icon('heroicon-o-magnifying-glass')
->form($this->findingsScopeForm())
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
$this->findingsScopeMode = $scope->mode;
$this->findingsTenantId = $scope->tenantId;
@ -139,7 +142,9 @@ protected function getHeaderActions(): array
]);
}
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
: FindingsLifecycleBackfillScope::allTenants();
$user = auth('platform')->user();
@ -281,34 +286,4 @@ private function lastRunForType(string $type): ?OperationRun
->latest('id')
->first();
}
/**
* @param array<string, mixed> $data
*/
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
$scope = FindingsLifecycleBackfillScope::fromArray([
'mode' => $data['scope_mode'] ?? null,
'tenant_id' => $data['tenant_id'] ?? null,
]);
if (! $scope->isSingleTenant()) {
return $scope;
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
{
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
return FindingsLifecycleBackfillScope::allTenants();
}
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
}
}

View File

@ -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,

View File

@ -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()

View File

@ -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),

View File

@ -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,
];
}
}

View File

@ -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();

View File

@ -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

View File

@ -9,8 +9,6 @@
use App\Models\User;
use App\Models\UserTenantPreference;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -49,15 +47,7 @@ public function __invoke(Request $request): RedirectResponse
abort(404);
}
$outcome = app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: TenantOperabilityQuestion::SelectorEligibility,
actor: $user,
workspaceId: $workspaceId,
lane: TenantInteractionLane::StandardActiveOperating,
);
if (! $outcome->allowed) {
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
abort(404);
}

View File

@ -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);

View File

@ -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) {

View File

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
@ -33,11 +32,6 @@ public function __construct(
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs): void
{
if (! $this->operationRun instanceof OperationRun) {

View File

@ -2,8 +2,6 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\TenantSyncWorkerJob;
use App\Models\OperationRun;
use App\Services\OperationRunService;
@ -16,15 +14,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;
@ -42,11 +32,6 @@ public function __construct(
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs): void
{
if (! $this->operationRun instanceof OperationRun) {

View File

@ -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;
}
/**

View File

@ -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;
@ -31,7 +29,6 @@
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Findings\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Services\OperationRunService;
@ -39,16 +36,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 +53,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 +83,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 +91,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 +129,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 +277,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 +308,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 +333,6 @@ public function handle(
];
$phaseResult = [];
$phaseGaps = [];
$phaseGapSubjects = [];
$resumeToken = null;
if ($captureMode === BaselineCaptureMode::FullContent) {
@ -419,7 +361,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 +440,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 +517,6 @@ public function handle(
'count' => $gapsCount,
'by_reason' => $gapsByReason,
...$gapsByReason,
'subjects' => $gapSubjects !== [] ? $gapSubjects : null,
],
'resume_token' => $resumeToken,
'coverage' => [
@ -610,10 +544,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 +789,6 @@ private function completeWithCoverageWarning(
'findings_resolved' => 0,
'severity_breakdown' => [],
];
$updatedContext = $this->withCompareReasonTranslation($updatedContext, $reasonCode->value);
$this->operationRun->update(['context' => $updatedContext]);
@ -966,34 +895,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 +1003,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 +1035,6 @@ private function computeDrift(
): array {
$drift = [];
$evidenceGaps = [];
$evidenceGapSubjects = [];
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
@ -1208,7 +1076,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 +1140,6 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1290,14 +1156,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 +1175,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 +1255,6 @@ private function computeDrift(
if (! $currentEvidence instanceof ResolvedEvidence) {
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
$evidenceGapSubjects['missing_current'][] = $key;
continue;
}
@ -1408,7 +1270,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 +1329,6 @@ private function computeDrift(
return [
'drift' => $drift,
'evidence_gaps' => $evidenceGaps,
'evidence_gap_subjects' => $evidenceGapSubjects,
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
];
}
@ -1980,163 +1840,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
@ -2427,14 +2130,20 @@ private function upsertFindings(
: null;
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
$finding->save();
$severity = (string) $driftItem['severity'];
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
app(FindingWorkflowService::class)->reopenBySystem(
finding: $finding,
tenant: $tenant,
reopenedAt: $observedAt,
operationRunId: (int) $this->operationRun->getKey(),
);
$finding->forceFill([
'status' => Finding::STATUS_REOPENED,
'reopened_at' => $observedAt,
'resolved_at' => null,
'resolved_reason' => null,
'closed_at' => null,
'closed_reason' => null,
'closed_by_user_id' => null,
'sla_days' => $slaDays,
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
]);
$reopenedCount++;
} else {

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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(

View File

@ -4,8 +4,6 @@
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Listeners\SyncRestoreRunToOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
@ -25,10 +23,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(
@ -40,14 +34,6 @@ public function __construct(
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
}
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
{
if (! $this->operationRun) {

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Middleware;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use App\Services\Operations\QueuedExecutionLegitimacyGate;
use Closure;
class EnsureQueuedExecutionLegitimate
{
/**
* @param mixed $job
* @param callable $next
* @return mixed
*/
public function handle($job, Closure $next)
{
$run = $this->resolveRun($job);
if (! $run instanceof OperationRun) {
return $next($job);
}
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
if (! $decision->allowed) {
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
return null;
}
return $next($job);
}
/**
* @param mixed $job
*/
private function resolveRun($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
return $run instanceof OperationRun ? $run : null;
}
if (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
return $run instanceof OperationRun ? $run : null;
}
return null;
}
}

View File

@ -17,7 +17,14 @@ class TrackOperationRun
*/
public function handle($job, Closure $next)
{
$run = $this->resolveRun($job);
// Check if the job has an 'operationRun' property or method
$run = null;
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
} elseif (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
}
if (! $run instanceof OperationRun) {
return $next($job);
@ -26,23 +33,19 @@ public function handle($job, Closure $next)
/** @var OperationRunService $service */
$service = app(OperationRunService::class);
$run->refresh();
if ($run->status === 'completed') {
return null;
}
if ($run->status !== 'running') {
$service->updateRun($run, 'running');
}
// Mark as running
$service->updateRun($run, 'running');
try {
$response = $next($job);
// If the job was released back onto the queue (retry / delay), do not mark the run as completed.
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
return $response;
}
// If the job didn't already mark it as completed/failed, we do it here.
// Re-fetch to check current status
$run->refresh();
if ($run->status === 'running') {
@ -55,24 +58,4 @@ public function handle($job, Closure $next)
throw $e;
}
}
/**
* @param mixed $job
*/
private function resolveRun($job): ?OperationRun
{
if (method_exists($job, 'getOperationRun')) {
$run = $job->getOperationRun();
return $run instanceof OperationRun ? $run : null;
}
if (property_exists($job, 'operationRun')) {
$run = $job->operationRun;
return $run instanceof OperationRun ? $run : null;
}
return null;
}
}

View File

@ -2,7 +2,6 @@
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
@ -38,7 +37,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
return [];
}
public function handle(OperationRunService $runs): void

View File

@ -2,7 +2,6 @@
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun;
use App\Services\OperationRunService;
use Illuminate\Bus\Queueable;
@ -39,7 +38,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
return [];
}
public function handle(OperationRunService $runs): void

View File

@ -2,7 +2,6 @@
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Services\OperationRunService;
@ -34,11 +33,6 @@ public function __construct(
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
{
if (! $this->operationRun instanceof OperationRun) {

View File

@ -2,7 +2,6 @@
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
@ -37,11 +36,6 @@ public function __construct(
$this->operationRun = $operationRun;
}
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate];
}
public function handle(
OperationRunService $runs,
TargetScopeConcurrencyLimiter $limiter,

View File

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -42,7 +41,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
public function handle(

View File

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -53,7 +52,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
public function handle(

View File

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
@ -42,7 +41,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
public function handle(

View File

@ -4,8 +4,6 @@
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\Tenant;
@ -14,7 +12,6 @@
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\OpsUx\AssignmentJobFingerprint;
use App\Support\OpsUx\RunFailureSanitizer;
use Illuminate\Bus\Queueable;
@ -42,14 +39,6 @@ class RestoreAssignmentsJob implements ShouldQueue
public int $backoff = 0;
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
}
/**
* Create a new job instance.
*/
@ -414,8 +403,6 @@ private static function operationRunContext(
'policy_type' => trim($policyType),
'policy_id' => trim($policyId),
'assignment_item_count' => count($assignments),
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'required_capability' => 'tenant.manage',
];
}

View File

@ -2,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupSchedule;
use App\Models\OperationRun;
@ -40,10 +39,6 @@ class RunBackupScheduleJob implements ShouldQueue
public int $tries = 3;
public int $timeout = 300;
public bool $failOnTimeout = true;
/**
* Compatibility-only legacy field.
*
@ -63,7 +58,7 @@ public function __construct(
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
public function handle(

View File

@ -2,8 +2,6 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
@ -25,15 +23,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;
@ -55,7 +45,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
/**
@ -99,6 +89,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
$successCount = 0;
$failedCount = 0;
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
// It will also handle success completion if no exceptions thrown.
// However, InventorySyncService execution logic might be complex with partial failures.
// We might want to explicitly update the OperationRun if partial failures occur.
$result = $inventorySyncService->executeSelection(
$this->operationRun,
$tenant,

View File

@ -2,8 +2,6 @@
namespace App\Jobs;
use App\Jobs\Concerns\BridgesFailedOperationRun;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
@ -22,15 +20,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;
@ -49,7 +39,7 @@ public function __construct(
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void

View File

@ -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;
/**

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