Compare commits

..

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
b4193f1ff9 feat: add onboarding permissions assist 2026-03-14 02:59:06 +01:00
624 changed files with 2244 additions and 59475 deletions

View File

@ -1,76 +0,0 @@
---
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
---
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
## Audit focus
Prioritize:
- workspace and tenant isolation
- route model binding safety
- Filament resources, pages, relation managers, widgets, and actions
- Livewire public properties and serialized state risks
- jobs, queue boundaries, and backend authorization rechecks
- provider access boundaries
- `OperationRun` consistency
- findings, exceptions, review, drift, and baseline workflow integrity
- audit trail completeness
- wrong-tenant regression coverage
- unauthorized action coverage
- workflow misuse and invalid transition coverage
## Output rules
Classify every finding as exactly one of:
- Constitutional Violation
- Architectural Drift
- Workflow Trust Gap
- Test Blind Spot
Assign one severity:
- Severity 1: Critical
- Severity 2: High
- Severity 3: Medium
- Severity 4: Low
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
For each finding provide:
1. Title
2. Classification
3. Severity
4. Affected Area
5. Evidence with specific files, classes, methods, routes, or test gaps
6. Why this matters in TenantPilot
7. Recommended structural correction
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
## Constraints
- Do not praise the codebase.
- Do not focus on style unless it affects architecture or safety.
- Do not suggest random patterns without proving fit.
- Group multiple symptoms under one deeper diagnosis when appropriate.
- Be explicit when a local fix is insufficient and a dedicated spec is required.
## Repository context
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
The strategic priorities are:
- workspace-first context modeling
- capability-first RBAC
- strong auditability
- deterministic workflow semantics
- provider access through canonical boundaries
- minimal duplication of domain logic across UI surfaces
Return the audit as a concise but substantive findings report.

View File

@ -1,104 +0,0 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

View File

@ -70,32 +70,6 @@ ## Active Technologies
- PostgreSQL via Laravel migrations and encrypted model casts (137-platform-provider-identity)
- PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes (139-verify-access-permissions-assist)
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, shared `App\Support\Diff` foundation from Spec 141 (142-rbac-role-definition-diff-ux-upgrade)
- PostgreSQL via Laravel Sail for existing `findings.evidence_jsonb`; no schema or persistence changes (142-rbac-role-definition-diff-ux-upgrade)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4 (143-tenant-lifecycle-operability-context-semantics)
- PostgreSQL via Laravel Eloquent models and workspace/tenant scoped tables (143-tenant-lifecycle-operability-context-semantics)
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Gates and Policies, `OperateHubShell`, `OperationRunLinks` (144-canonical-operation-viewer-context-decoupling)
- PostgreSQL plus session-backed workspace and remembered tenant context (no schema changes) (144-canonical-operation-viewer-context-decoupling)
- 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` (145-tenant-action-taxonomy-lifecycle-safe-visibility)
- 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)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -115,8 +89,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 155-tenant-review-layer: Added 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`
- 001-finding-risk-acceptance: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
- 139-verify-access-permissions-assist: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes
- 137-platform-provider-identity: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
- 136-admin-canonical-tenant: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -1,76 +0,0 @@
---
description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model.
---
You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas.
This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`.
## Audit focus
Prioritize:
- workspace and tenant isolation
- route model binding safety
- Filament resources, pages, relation managers, widgets, and actions
- Livewire public properties and serialized state risks
- jobs, queue boundaries, and backend authorization rechecks
- provider access boundaries
- `OperationRun` consistency
- findings, exceptions, review, drift, and baseline workflow integrity
- audit trail completeness
- wrong-tenant regression coverage
- unauthorized action coverage
- workflow misuse and invalid transition coverage
## Output rules
Classify every finding as exactly one of:
- Constitutional Violation
- Architectural Drift
- Workflow Trust Gap
- Test Blind Spot
Assign one severity:
- Severity 1: Critical
- Severity 2: High
- Severity 3: Medium
- Severity 4: Low
Anything directly touching isolation, RBAC, secrets, or auditability must not be rated Low.
For each finding provide:
1. Title
2. Classification
3. Severity
4. Affected Area
5. Evidence with specific files, classes, methods, routes, or test gaps
6. Why this matters in TenantPilot
7. Recommended structural correction
8. Delivery recommendation: `hotfix`, `follow-up refactor`, or `dedicated spec required`
## Constraints
- Do not praise the codebase.
- Do not focus on style unless it affects architecture or safety.
- Do not suggest random patterns without proving fit.
- Group multiple symptoms under one deeper diagnosis when appropriate.
- Be explicit when a local fix is insufficient and a dedicated spec is required.
## Repository context
TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability.
The strategic priorities are:
- workspace-first context modeling
- capability-first RBAC
- strong auditability
- deterministic workflow semantics
- provider access through canonical boundaries
- minimal duplication of domain logic across UI surfaces
Return the audit as a concise but substantive findings report.

View File

@ -1,105 +0,0 @@
---
description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering.
agent: speckit.specify
---
You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas.
Your task is to produce spec candidates, not implementation code.
Before writing anything, read and use these repository files as binding context:
- `docs/audits/tenantpilot-architecture-audit-constitution.md`
- `docs/audits/2026-03-15-audit-spec-candidates.md`
- `specs/110-ops-ux-enforcement/spec.md`
- `specs/111-findings-workflow-sla/spec.md`
- `specs/134-audit-log-foundation/spec.md`
- `specs/138-managed-tenant-onboarding-draft-identity/spec.md`
## Goal
Turn the existing audit-derived problem clusters into exactly four proposed follow-up spec candidates.
The four candidate themes are:
1. queued execution reauthorization and scope continuity
2. tenant-owned query canon and wrong-tenant guards
3. findings workflow enforcement and audit backstop
4. Livewire context locking and trusted-state reduction
## Numbering rule
- Do not invent or reserve fixed spec numbers unless the current repository state proves they are available.
- If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`.
- Only recommend a numbering strategy; do not force numbering in the output when collisions are possible.
## Output requirements
Create exactly four spec candidates, one per problem class.
For each candidate provide:
1. Candidate label or confirmed spec number
2. Working title
3. Status: `Proposed`
4. Summary
5. Why this is needed now
6. Boundary to existing specs
7. Problem statement
8. Goals
9. Non-goals
10. Scope
11. Target model
12. Key requirements
13. Risks if not implemented
14. Dependencies and sequencing notes
15. Delivery recommendation: `hotfix`, `dedicated spec`, or `phased spec`
16. Suggested implementation priority: `Critical`, `High`, or `Medium`
17. Suggested slug
At the end provide:
A. Recommended implementation order
B. Which candidates can run in parallel
C. Which candidate should start first and why
D. A numbering strategy recommendation if active spec numbers are not yet known
## Writing rules
- Write in English.
- Use formal enterprise spec language.
- Be concrete and opinionated.
- Focus on structural integrity, not patch-level fixes.
- Treat the audit constitution as binding.
- Explicitly say when UI-only authorization is insufficient.
- Explicitly say when Livewire public state must be treated as untrusted input.
- Explicitly say when negative-path regression tests are required.
- Explicitly say when `OperationRun` or audit semantics must be extended or hardened.
- Do not duplicate adjacent specs; state the boundary clearly.
- Do not collapse all four themes into one umbrella spec.
## Candidate-specific direction
### Candidate A — queued execution reauthorization and scope continuity
- Treat this as an execution trust problem, not a simple `authorize()` omission.
- Cover dispatch-time actor and context capture, handle-time scope revalidation, capability reauthorization, execution denial semantics, and audit visibility.
- Define what happens when authorization or tenant operability changes between dispatch and execution.
### Candidate B — tenant-owned query canon and wrong-tenant guards
- Treat this as canonical data-access hardening.
- Cover tenant-owned and workspace-owned query rules, route model binding safety, canonical query paths, anti-pattern elimination, and required wrong-tenant regression tests.
- Focus on ownership enforcement, not generic repository-pattern advice.
### Candidate C — findings workflow enforcement and audit backstop
- Treat this as a workflow-truth problem.
- Cover formal lifecycle enforcement, invalid transition prevention, reopen and recurrence semantics, and audit backstop requirements.
- Make clear how this extends but does not duplicate Spec 111.
### Candidate D — Livewire context locking and trusted-state reduction
- Treat this as a UI/server trust-boundary hardening problem.
- Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests.
- Make clear how this complements but does not duplicate Spec 138.

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Onboarding;
use RuntimeException;
class OnboardingDraftConflictException extends RuntimeException
{
public function __construct(
public readonly int $draftId,
public readonly int $expectedVersion,
public readonly int $actualVersion,
string $message = 'This onboarding draft changed in another tab or session.',
) {
parent::__construct($message);
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Onboarding;
use App\Support\Onboarding\OnboardingLifecycleState;
use RuntimeException;
class OnboardingDraftImmutableException extends RuntimeException
{
public function __construct(
public readonly int $draftId,
public readonly OnboardingLifecycleState $lifecycleState,
string $message = 'This onboarding draft is no longer editable.',
) {
parent::__construct($message);
}
}

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

@ -7,10 +7,6 @@
use App\Models\Tenant;
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;
@ -56,10 +52,10 @@ public function getTenants(): Collection
$tenants = $user->getTenants(Filament::getCurrentOrDefaultPanel());
if ($tenants instanceof Collection) {
return app(TenantOperabilityService::class)->filterSelectable($tenants);
return $tenants;
}
return app(TenantOperabilityService::class)->filterSelectable(collect($tenants));
return collect($tenants);
}
public function selectTenant(int $tenantId): void
@ -70,35 +66,10 @@ 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();
}
}
}
if ($workspaceId === null) {
$this->redirect(route('filament.admin.pages.choose-workspace'));
return;
}
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
$tenant = Tenant::query()
->where('workspace_id', $workspaceId)
->whereKey($tenantId)
->first();
}
$tenant = Tenant::query()
->where('status', 'active')
->whereKey($tenantId)
->first();
if (! $tenant instanceof Tenant) {
abort(404);
@ -108,32 +79,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) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
abort(404);
}
app(WorkspaceContext::class)->rememberLastTenantId((int) $tenant->workspace_id, (int) $tenant->getKey(), request());
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
public function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
private function persistLastTenant(User $user, Tenant $tenant): void
{
if (Schema::hasColumn('users', 'last_tenant_id')) {

View File

@ -1,116 +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\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\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 {
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)),
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
];
})->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

@ -139,11 +139,8 @@ public function table(Table $table): Table
return OperationRunResource::table($table)
->query(function (): Builder {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
if (! is_numeric($tenantFilter)) {
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
}
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$query = OperationRun::query()
->with('user')
@ -157,8 +154,8 @@ public function table(Table $table): Table
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
)
->when(
is_numeric($tenantFilter),
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantFilter),
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
);
return $this->applyActiveTab($query);

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;
@ -20,9 +19,6 @@
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\RunDetailPolling;
use App\Support\RedactionIntegrity;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Notifications\Notification;
@ -60,8 +56,6 @@ protected function getHeaderActions(): array
{
$operateHubShell = app(OperateHubShell::class);
$navigationContext = $this->navigationContext();
$activeTenant = $operateHubShell->activeEntitledTenant(request());
$runTenantId = isset($this->run) ? (int) ($this->run->tenant_id ?? 0) : 0;
$actions = [
Action::make('operate_hub_scope_run_detail')
@ -70,12 +64,14 @@ protected function getHeaderActions(): array
->disabled(),
];
$activeTenant = $operateHubShell->activeEntitledTenant(request());
if ($navigationContext?->backLinkLabel !== null && $navigationContext->backLinkUrl !== null) {
$actions[] = Action::make('operate_hub_back_to_origin_run_detail')
->label($navigationContext->backLinkLabel)
->color('gray')
->url($navigationContext->backLinkUrl);
} elseif ($activeTenant instanceof Tenant && (int) $activeTenant->getKey() === $runTenantId) {
} elseif ($activeTenant instanceof Tenant) {
$actions[] = Action::make('operate_hub_back_to_tenant_run_detail')
->label('← Back to '.$activeTenant->name)
->color('gray')
@ -106,7 +102,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 = [];
@ -139,6 +142,15 @@ public function mount(OperationRun $run): void
$this->authorize('view', $run);
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if (
$activeTenant instanceof Tenant
&& (int) ($run->tenant_id ?? 0) !== (int) $activeTenant->getKey()
) {
abort(404);
}
$this->run = $run->loadMissing(['workspace', 'tenant', 'user']);
$this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null;
}
@ -160,91 +172,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;
}
$context = is_array($this->run->context) ? $this->run->context : [];
$reasonCode = data_get($context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
}
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
$message = $this->run->failure_summary[0]['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
return [
'tone' => 'amber',
'title' => 'Execution blocked',
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
];
}
/**
* @return array{tone: string, title: string, body: string}|null
*/
public function canonicalContextBanner(): ?array
{
if (! isset($this->run)) {
return null;
}
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$runTenant = $this->run->tenant;
if (! $runTenant instanceof Tenant) {
return [
'tone' => 'slate',
'title' => 'Workspace-level run',
'body' => $activeTenant instanceof Tenant
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').'
: 'This canonical workspace view is not tied to any tenant.',
];
}
$messages = ['Run tenant: '.$runTenant->name.'.'];
$tone = 'sky';
$title = null;
if ($activeTenant instanceof Tenant && ! $activeTenant->is($runTenant)) {
$title = 'Current tenant context differs from this run';
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.');
$messages[] = 'This canonical workspace view remains valid without switching tenant context.';
}
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
$title ??= 'Run tenant is not available in the current tenant selector';
$tone = 'amber';
$messages[] = $selectorAvailabilityMessage;
if ($referencedTenant->contextNote !== null) {
$messages[] = $referencedTenant->contextNote;
}
} elseif (! $activeTenant instanceof Tenant) {
$title ??= 'Canonical workspace view';
$messages[] = 'No tenant context is currently selected.';
}
if ($title === null) {
return null;
}
return [
'tone' => $tone,
'title' => $title,
'body' => implode(' ', $messages),
];
}
public function pollInterval(): ?string
{
if (! isset($this->run)) {
@ -395,30 +322,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;
}
}

View File

@ -1,307 +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\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\CanonicalAdminTenantFilterState;
use App\Support\Filament\FilterPresets;
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 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('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('summary.publish_blockers')
->label('Publish blockers')
->formatStateUsing(static function (mixed $state): string {
if (! is_array($state) || $state === []) {
return '0';
}
return (string) count($state);
}),
])
->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([
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,10 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Resources\TenantResource;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
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;
@ -58,25 +54,11 @@ public function getTenants(): Collection
return Tenant::query()->whereRaw('1 = 0')->get();
}
$tenantIds = $user->tenantMemberships()
->pluck('tenant_id');
return Tenant::query()
->withTrashed()
->whereIn('id', $tenantIds)
return $user->tenants()
->where('workspace_id', $this->workspace->getKey())
->where('status', 'active')
->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;
})
->values();
->get();
}
public function goToChooseTenant(): void
@ -93,7 +75,7 @@ public function openTenant(int $tenantId): void
}
$tenant = Tenant::query()
->withTrashed()
->where('status', 'active')
->where('workspace_id', $this->workspace->getKey())
->whereKey($tenantId)
->first();
@ -106,6 +88,6 @@ public function openTenant(int $tenantId): void
abort(404);
}
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}
}

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

@ -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,637 +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\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
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 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('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('Missing dimensions')->placeholder('—'),
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->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('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('Missing'),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options([
'queued' => 'Queued',
'generating' => 'Generating',
'active' => 'Active',
'superseded' => 'Superseded',
'expired' => 'Expired',
'failed' => 'Failed',
]),
Tables\Filters\SelectFilter::make('completeness_state')
->options([
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
])
->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'] : [];
return [
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
'highlights' => [
['label' => 'Operations', 'value' => (string) $operationCount],
['label' => 'Failed operations', 'value' => (string) $failedCount],
['label' => 'Partial operations', '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 => 'Queued',
OperationRunStatus::Running->value => 'Running',
OperationRunStatus::Completed->value => match ($outcome) {
OperationRunOutcome::Succeeded->value => 'Completed',
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
default => 'Completed',
},
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
};
}
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,15 @@
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\InventoryItem;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder;
use App\Services\Findings\FindingExceptionService;
use App\Services\Findings\FindingRiskGovernanceResolver;
use App\Services\Findings\FindingWorkflowService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
@ -38,8 +35,6 @@
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
@ -61,7 +56,6 @@
class FindingResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
protected static ?string $model = Finding::class;
@ -119,8 +113,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;
@ -181,7 +174,17 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('subject_display_name')
->label('Subject')
->placeholder('—')
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
->state(function (Finding $record): ?string {
$state = $record->subject_display_name;
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
}),
TextEntry::make('subject_type')
->label('Subject type')
->formatStateUsing(fn (mixed $state, Finding $record): string => static::subjectTypeLabel($record, $state)),
@ -228,62 +231,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')
@ -656,7 +603,16 @@ public static function table(Table $table): Table
->label('Subject')
->placeholder('—')
->searchable()
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
->formatStateUsing(function (?string $state, Finding $record): ?string {
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
})
->description(fn (Finding $record): ?string => static::driftContextDescription($record)),
Tables\Columns\TextColumn::make('subject_type')
->label('Subject type')
@ -816,7 +772,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->triage($record, $tenant, $user);
$triagedCount++;
} catch (Throwable) {
@ -897,7 +852,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 +926,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->resolve($record, $tenant, $user, $reason);
$resolvedCount++;
} catch (Throwable) {
@ -1047,7 +1000,6 @@ public static function table(Table $table): Table
}
try {
$record = static::resolveProtectedFindingRecordOrFail($record);
$workflow->close($record, $tenant, $user, $reason);
$closedCount++;
} catch (Throwable) {
@ -1075,6 +1027,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 +1109,18 @@ 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'])
->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
}
/**
@ -1172,9 +1196,7 @@ public static function workflowActions(): array
static::assignAction(),
static::resolveAction(),
static::closeAction(),
static::requestExceptionAction(),
static::renewExceptionAction(),
static::revokeExceptionAction(),
static::riskAcceptAction(),
static::reopenAction(),
];
}
@ -1186,7 +1208,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 +1234,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 +1259,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 +1303,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 +1338,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 +1365,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 +1407,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 +1427,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 +1443,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 +1461,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

@ -22,7 +22,6 @@
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDurationInsights;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -158,6 +157,14 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('tenant_id')
->label('Tenant')
->options(function (): array {
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($activeTenant instanceof Tenant) {
return [
(string) $activeTenant->getKey() => $activeTenant->getFilamentName(),
];
}
$user = auth()->user();
if (! $user instanceof User) {
@ -189,6 +196,7 @@ public static function table(Table $table): Table
Tables\Filters\SelectFilter::make('type')
->options(function (): array {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
if ($workspaceId === null) {
return [];
@ -196,6 +204,10 @@ public static function table(Table $table): Table
$types = OperationRun::query()
->where('workspace_id', (int) $workspaceId)
->when(
$activeTenant instanceof Tenant,
fn (Builder $query): Builder => $query->where('tenant_id', (int) $activeTenant->getKey()),
)
->select('type')
->distinct()
->orderBy('type')
@ -221,8 +233,14 @@ public static function table(Table $table): Table
return [];
}
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
$tenantId = $activeTenant instanceof Tenant
? (int) $activeTenant->getKey()
: null;
return OperationRun::query()
->where('workspace_id', (int) $workspaceId)
->when($tenantId, fn (Builder $query): Builder => $query->where('tenant_id', $tenantId))
->whereNotNull('initiator_name')
->select('initiator_name')
->distinct()
@ -255,9 +273,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
$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;
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
@ -304,34 +319,7 @@ 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)),
$referencedTenantLifecycle !== null
? $factory->keyFact(
'Tenant lifecycle',
$referencedTenantLifecycle->presentation->label,
badge: $factory->statusBadge(
$referencedTenantLifecycle->presentation->label,
$referencedTenantLifecycle->presentation->badgeColor,
$referencedTenantLifecycle->presentation->badgeIcon,
$referencedTenantLifecycle->presentation->badgeIconColor,
),
)
: null,
$referencedTenantLifecycle?->selectorAvailabilityMessage() !== null
? $factory->keyFact('Tenant selector context', $referencedTenantLifecycle->selectorAvailabilityMessage())
: null,
$referencedTenantLifecycle?->contextNote !== null
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
: 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,
])),
),
@ -378,7 +366,7 @@ 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 ?? []],
),
@ -460,51 +448,6 @@ private static function summaryCountFacts(
);
}
private static function blockedExecutionReasonCode(OperationRun $record): ?string
{
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
return null;
}
$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;
}
$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>>
*/

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

@ -673,8 +673,7 @@ public static function table(Table $table): Table
->label('Migration review')
->badge()
->formatStateUsing(fn (bool $state): string => $state ? 'Review required' : 'Clear')
->color(fn (bool $state): string => $state ? 'warning' : 'success')
->toggleable(isToggledHiddenByDefault: true),
->color(fn (bool $state): string => $state ? 'warning' : 'success'),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
Tables\Columns\TextColumn::make('last_error_reason_code')
->label('Last error reason')

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'),
);
}
/**
@ -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;
@ -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,8 +2,6 @@
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;
@ -166,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
@ -194,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(),
]);
}
@ -245,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()
@ -379,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

@ -11,9 +11,7 @@
use App\Models\EntraRoleDefinition;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
@ -24,10 +22,7 @@
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\Tenants\TenantActionPolicySurface;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
use App\Support\Badges\BadgeDomain;
@ -39,12 +34,6 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
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;
@ -88,11 +77,6 @@ class TenantResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Settings';
/**
* @var array<string, Collection<int, TenantActionDescriptor>>
*/
protected static array $tenantActionCatalogCache = [];
/**
* Tenant creation is handled exclusively by the onboarding wizard.
* The CRUD create page has been removed.
@ -146,10 +130,9 @@ public static function canDeleteAny(): bool
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->withListRowPrimaryActionLimit(2)
->satisfy(ActionSurfaceSlot::ListHeader, 'List page provides a capability-gated create action.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most two row actions stay primary; lifecycle-adjacent and contextual secondary actions move under "More".')
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Row-level secondary actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
@ -225,18 +208,6 @@ public static function getEloquentQuery(): Builder
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
public static function getGlobalSearchEloquentQuery(): Builder
{
if (app(WorkspaceContext::class)->currentWorkspaceId(request()) === null) {
return static::getEloquentQuery()->whereRaw('1 = 0');
}
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
static::getEloquentQuery(),
(new Tenant)->getTable(),
);
}
public static function table(Table $table): Table
{
return $table
@ -272,23 +243,20 @@ public static function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_current')
->label('Current')
->boolean()
->toggleable(isToggledHiddenByDefault: true),
->boolean(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->description(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->shortDescription)
->sortable(),
Tables\Columns\TextColumn::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus))
->toggleable(isToggledHiddenByDefault: true),
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since()
@ -317,56 +285,11 @@ public static function table(Table $table): Table
]),
])
->actions([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'Resume onboarding')
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-path')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'related_onboarding'),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-arrow-uturn-left')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant restored')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->label ?? 'Archive')
->color('danger')
->icon(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-archive-box-x-mark')
->successNotificationTitle(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->successNotificationTitle ?? 'Tenant archived')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => static::lifecycleActionDescriptor($record, TenantActionSurface::TenantIndexRow)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => static::tenantIndexPrimaryAction($record)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
static::archiveTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
ActionGroup::make([
Actions\Action::make('related_onboarding_overflow')
->label(fn (Tenant $record): string => static::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantIndexRow) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => static::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => static::relatedOnboardingDraftAction($record, TenantActionSurface::TenantIndexRow) instanceof TenantActionDescriptor
&& static::tenantIndexPrimaryAction($record)?->key !== 'related_onboarding'),
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record])),
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
@ -493,6 +416,43 @@ public static function table(Table $table): Table
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->successNotificationTitle('Tenant reactivated')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->restore();
$auditLogger->log(
tenant: $record,
action: 'tenant.restored',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('admin_consent')
->label('Grant admin consent')
@ -516,7 +476,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,
@ -633,6 +593,48 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
@ -837,10 +839,6 @@ public static function infolist(Schema $schema): Schema
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('lifecycle_summary')
->label('Lifecycle summary')
->state(fn (Tenant $record): string => static::tenantLifecyclePresentation($record)->longDescription)
->columnSpanFull(),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
@ -1016,216 +1014,6 @@ protected static function storedPermissionSnapshot(Tenant $tenant): array
return $snapshot;
}
protected static function tenantLifecyclePresentation(Tenant $tenant): TenantLifecyclePresentation
{
return TenantLifecyclePresentation::fromTenant($tenant);
}
public static function tenantOperability(): TenantOperabilityService
{
return app(TenantOperabilityService::class);
}
public static function tenantActionPolicy(): TenantActionPolicySurface
{
return app(TenantActionPolicySurface::class);
}
/**
* @return Collection<int, TenantActionDescriptor>
*/
public static function tenantActionCatalog(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): Collection
{
$cacheKey = static::tenantActionCatalogCacheKey($tenant, $surface);
if (! array_key_exists($cacheKey, static::$tenantActionCatalogCache)) {
static::$tenantActionCatalogCache[$cacheKey] = collect(static::tenantActionPolicy()->catalogForTenant($tenant, $surface))->values();
}
return static::$tenantActionCatalogCache[$cacheKey];
}
public static function lifecycleActionDescriptor(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantIndexRow): ?TenantActionDescriptor
{
return static::tenantActionDescriptorForSurface($tenant, $surface, 'restore')
?? static::tenantActionDescriptorForSurface($tenant, $surface, 'archive');
}
public static function tenantIndexPrimaryAction(Tenant $tenant): ?TenantActionDescriptor
{
$catalog = static::tenantActionCatalog($tenant, TenantActionSurface::TenantIndexRow);
return $catalog[1] ?? null;
}
public static function relatedOnboardingDraft(Tenant $tenant): ?TenantOnboardingSession
{
return static::tenantActionPolicy()->relatedOnboardingDraft($tenant);
}
public static function relatedOnboardingDraftAction(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?TenantActionDescriptor
{
return static::tenantActionDescriptorForSurface($tenant, $surface, 'related_onboarding');
}
public static function relatedOnboardingDraftActionLabel(Tenant $tenant, TenantActionSurface $surface = TenantActionSurface::TenantViewHeader): ?string
{
return static::relatedOnboardingDraftAction($tenant, $surface)?->label;
}
public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
{
$draft = static::relatedOnboardingDraft($tenant);
if (! $draft instanceof TenantOnboardingSession) {
return null;
}
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)
->first(fn (TenantActionDescriptor $descriptor): bool => $descriptor->key === $key);
return $descriptor instanceof TenantActionDescriptor ? $descriptor : null;
}
private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantActionSurface $surface): string
{
$relatedDraft = static::relatedOnboardingDraft($tenant);
return implode(':', [
(string) (auth()->id() ?? 'guest'),
$surface->value,
(string) ($tenant->getKey() ?? 'missing'),
(string) $tenant->status,
(string) ($tenant->updated_at?->getTimestamp() ?? 'no-updated-at'),
(string) ($tenant->deleted_at?->getTimestamp() ?? 'not-deleted'),
(string) ($relatedDraft?->getKey() ?? 'no-draft'),
(string) ($relatedDraft?->workflowStatus()->value ?? 'no-draft-status'),
(string) ($relatedDraft?->lifecycleState()->value ?? 'no-draft-lifecycle-state'),
(string) ($relatedDraft?->updated_at?->getTimestamp() ?? 'no-draft-updated-at'),
(string) ($relatedDraft?->completed_at?->getTimestamp() ?? 'no-draft-completed-at'),
(string) ($relatedDraft?->cancelled_at?->getTimestamp() ?? 'no-draft-cancelled-at'),
]);
}
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$descriptor = static::lifecycleActionDescriptor($record);
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'archive') {
Notification::make()
->title('Archive unavailable')
->body('Only active tenants can be archived from tenant management surfaces.')
->warning()
->send();
return;
}
$record->delete();
$auditLogger->logTenantLifecycleAction(
tenant: $record,
action: AuditActionId::TenantArchived,
actor: $user,
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant archived')
->body($descriptor->successNotificationBody ?? 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.')
->success()
->send();
}
public static function restoreTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$descriptor = static::lifecycleActionDescriptor($record);
if (! $descriptor instanceof TenantActionDescriptor || $descriptor->key !== 'restore') {
Notification::make()
->title('Restore unavailable')
->body('Only archived tenants can be restored from tenant management surfaces.')
->warning()
->send();
return;
}
$record->restore();
$auditLogger->logTenantLifecycleAction(
tenant: $record,
action: AuditActionId::TenantRestored,
actor: $user,
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title($descriptor->successNotificationTitle ?? 'Tenant restored')
->body($descriptor->successNotificationBody ?? 'The tenant is available again in normal tenant management flows and can be selected as active context.')
->success()
->send();
}
public static function getPages(): array
{
return [

View File

@ -4,10 +4,8 @@
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface;
use Filament\Actions;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
@ -20,40 +18,14 @@ protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
UiEnforcement::forAction(
Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to restore tenants.')
->preserveVisibility()
->destructive()
->apply(),
UiEnforcement::forAction(
Action::make('archive')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Archive')
->label('Archive')
->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::archiveTenant($record, $auditLogger);
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record): void {
$record->delete();
})
)
->requireCapability(Capabilities::TENANT_DELETE)

View File

@ -1,14 +1,8 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -19,7 +13,10 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array
{
return [
$this->makeOnboardingEntryAction()
Actions\Action::make('add_tenant')
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding'))
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
@ -27,40 +24,10 @@ protected function getHeaderActions(): array
protected function getTableEmptyStateActions(): array
{
return [
$this->makeOnboardingEntryAction(),
Actions\Action::make('add_tenant')
->label('Add tenant')
->icon('heroicon-m-plus')
->url(route('admin.onboarding')),
];
}
private function makeOnboardingEntryAction(): Actions\Action
{
$descriptor = TenantResource::tenantActionPolicy()->onboardingEntryDescriptor($this->accessibleResumableDraftCount());
return Actions\Action::make('add_tenant')
->label($descriptor->label)
->icon($descriptor->icon)
->url(route('admin.onboarding'));
}
private function accessibleResumableDraftCount(): int
{
$user = auth()->user();
if (! $user instanceof User) {
return 0;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return 0;
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
return 0;
}
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $workspace)->count();
}
}

View File

@ -11,7 +11,7 @@
use App\Jobs\RefreshTenantRbacHealthJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
@ -20,7 +20,6 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Tenants\TenantActionSurface;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
@ -64,11 +63,6 @@ protected function getHeaderActions(): array
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('related_onboarding')
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
Actions\Action::make('admin_consent')
->label('Grant admin consent')
->icon('heroicon-o-clipboard-document')
@ -87,7 +81,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,
@ -270,34 +264,34 @@ protected function getHeaderActions(): array
->preserveVisibility()
->requireCapability(Capabilities::PROVIDER_RUN)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
->color('success')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Restore tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Restore this archived tenant to make it available again in normal management flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'restore')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::restoreTenant($record, $auditLogger);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->destructive()
->apply(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Archive')
->label('Deactivate')
->color('danger')
->icon(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->modalHeading(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalHeading ?? 'Archive tenant')
->modalDescription(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->modalDescription ?? 'Archive this tenant to retain it for inspection while removing it from active operating flows.')
->visible(fn (Tenant $record): bool => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->key === 'archive')
->action(function (Tenant $record, WorkspaceAuditLogger $auditLogger): void {
TenantResource::archiveTenant($record, $auditLogger);
->icon('heroicon-o-archive-box-x-mark')
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->getKey(),
status: 'success',
context: [
'metadata' => [
'internal_tenant_id' => (int) $record->getKey(),
'tenant_guid' => (string) $record->tenant_id,
],
]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
})
)
->preserveVisibility()

View File

@ -1,562 +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\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\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 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('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('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('Missing'),
Tables\Columns\IconColumn::make('summary.has_ready_export')
->label('Export')
->boolean(),
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([
'complete' => 'Complete',
'partial' => 'Partial',
'missing' => 'Missing',
'stale' => 'Stale',
]),
\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(),
Str::headline((string) $snapshot->completeness_state),
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
),
])
->all();
}
/**
* @return array<string, mixed>
*/
private static function summaryPresentation(TenantReview $record): array
{
$summary = is_array($record->summary) ? $record->summary : [];
return [
'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' => [],
];
}
}

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

@ -6,6 +6,7 @@
use App\Filament\Resources\FindingResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
@ -41,7 +42,16 @@ public function table(Table $table): Table
->label('Subject')
->placeholder('—')
->limit(40)
->state(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName())
->formatStateUsing(function (?string $state, Finding $record): ?string {
if (is_string($state) && trim($state) !== '') {
return $state;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
})
->description(function (Finding $record): ?string {
if (Arr::get($record->evidence_jsonb ?? [], 'summary.kind') !== 'rbac_role_definition') {
return null;
@ -49,7 +59,17 @@ public function table(Table $table): Table
return __('findings.drift.rbac_role_definition');
})
->tooltip(fn (Finding $record): ?string => $record->resolvedSubjectDisplayName()),
->tooltip(function (Finding $record): ?string {
$displayName = $record->subject_display_name;
if (is_string($displayName) && trim($displayName) !== '') {
return $displayName;
}
$fallback = Arr::get($record->evidence_jsonb ?? [], 'display_name');
return is_string($fallback) && trim($fallback) !== '' ? trim($fallback) : null;
}),
TextColumn::make('severity')
->badge()
->sortable()
@ -86,7 +106,13 @@ private function getQuery(): Builder
$tenantId = $tenant instanceof Tenant ? $tenant->getKey() : null;
return Finding::query()
->withSubjectDisplayName()
->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
])
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
->latest('created_at');

View File

@ -5,7 +5,6 @@
namespace App\Filament\Widgets\Tenant;
use App\Models\Tenant;
use App\Support\Tenants\TenantLifecyclePresentation;
use Filament\Facades\Filament;
use Filament\Widgets\Widget;
@ -24,7 +23,6 @@ protected function getViewData(): array
return [
'tenant' => $tenant instanceof Tenant ? $tenant : null,
'presentation' => $tenant instanceof Tenant ? TenantLifecyclePresentation::fromTenant($tenant) : null,
];
}
}

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

@ -8,7 +8,6 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Tenants\TenantOperabilityService;
use App\Services\Verification\StartVerification;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
@ -190,15 +189,9 @@ protected function getViewData(): array
$user = auth()->user();
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
$canOperate = app(TenantOperabilityService::class)->decisionFor($tenant)->canOperate;
$canStart = $isTenantMember
&& $canOperate
&& $user->can(Capabilities::PROVIDER_RUN, $tenant);
$lifecycleNotice = $isTenantMember && ! $canOperate
? 'Verification can be started from tenant management only while the tenant is active.'
: null;
$runData = null;
if ($run instanceof OperationRun) {
@ -227,10 +220,8 @@ protected function getViewData(): array
'report' => $report,
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
'isInProgress' => $isInProgress,
'showStartAction' => $isTenantMember && $canOperate,
'canStart' => $canStart,
'startTooltip' => $isTenantMember && $canOperate && ! $canStart ? UiTooltips::insufficientPermission() : null,
'lifecycleNotice' => $lifecycleNotice,
'startTooltip' => $isTenantMember && ! $canStart ? UiTooltips::insufficientPermission() : null,
];
}
}

View File

@ -4,7 +4,6 @@
namespace App\Http\Controllers;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
@ -16,31 +15,14 @@ public function __invoke(Request $request): RedirectResponse
{
Filament::setTenant(null, true);
$workspaceContext = app(WorkspaceContext::class);
$workspaceContext->clearRememberedTenantContext($request);
app(WorkspaceContext::class)->clearLastTenantId($request);
$previousUrl = url()->previous();
$previousHost = parse_url((string) $previousUrl, PHP_URL_HOST);
$previousPath = (string) (parse_url((string) $previousUrl, PHP_URL_PATH) ?? '');
if ($previousHost !== null && $previousHost !== $request->getHost()) {
return redirect()->route('admin.operations.index');
}
if (TenantPageCategory::fromPath($previousPath) === TenantPageCategory::TenantBound) {
$workspace = $workspaceContext->currentWorkspace($request);
if ($workspace !== null) {
return redirect()->route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]);
}
return redirect()->route('admin.home');
}
if ($previousPath === '' || $previousPath === '/admin/clear-tenant-context') {
return redirect()->route('admin.operations.index');
return redirect()->to('/admin/operations');
}
return redirect()->to((string) $previousUrl);

View File

@ -8,9 +8,6 @@
use App\Models\Tenant;
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;
@ -37,6 +34,7 @@ public function __invoke(Request $request): RedirectResponse
]);
$tenant = Tenant::query()
->where('status', 'active')
->where('workspace_id', $workspaceId)
->whereKey($validated['tenant_id'])
->first();
@ -49,23 +47,9 @@ 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) {
abort(404);
}
$this->persistLastTenant($user, $tenant);
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, $request)) {
abort(404);
}
app(WorkspaceContext::class)->rememberLastTenantId((int) $workspaceId, (int) $tenant->getKey(), $request);
return redirect()->to(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
}

View File

@ -11,7 +11,6 @@
use App\Support\Workspaces\WorkspaceContext;
use App\Support\Workspaces\WorkspaceIntendedUrl;
use App\Support\Workspaces\WorkspaceRedirectResolver;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -48,8 +47,6 @@ public function __invoke(Request $request): RedirectResponse
$prevWorkspaceId = $context->currentWorkspaceId($request);
$context->setCurrentWorkspace($workspace, $user, $request);
$context->rememberedTenant($request);
Filament::setTenant(null, true);
/** @var WorkspaceAuditLogger $auditLogger */
$auditLogger = app(WorkspaceAuditLogger::class);

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\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,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Operations\TenantSyncWorkerJob;
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

@ -29,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;
@ -2131,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,79 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
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 Queueable;
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

@ -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;
@ -36,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,118 +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 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;
@ -35,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) {
@ -55,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());
@ -76,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,
);
@ -147,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,
@ -184,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(),
];
}
@ -300,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 {
@ -326,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(),
@ -346,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,
);
@ -361,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;
@ -378,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(),
]);
}
@ -421,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(),
]);
}
@ -456,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.
*
@ -574,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,
@ -676,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;
@ -59,7 +58,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\Tenant;
@ -46,7 +45,7 @@ public function __construct(
*/
public function middleware(): array
{
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
return [new TrackOperationRun];
}
/**
@ -90,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,7 +2,6 @@
namespace App\Jobs;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Policy;
@ -40,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

@ -18,14 +18,6 @@ class AuditLog extends Model
{
use HasFactory;
/**
* @var array<int, string>
*/
private const INTERNAL_METADATA_KEYS = [
'_actor_type',
'_dedupe_key',
];
protected $guarded = [];
protected $casts = [
@ -210,12 +202,7 @@ public function contextItems(): array
}
foreach ($metadata as $key => $value) {
if (
in_array($key, $seen, true)
|| in_array($key, ['before', 'after'], true)
|| str_starts_with((string) $key, '_')
|| in_array((string) $key, self::INTERNAL_METADATA_KEYS, true)
) {
if (in_array($key, $seen, true) || in_array($key, ['before', 'after'], true)) {
continue;
}

View File

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EvidenceSnapshot extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'summary' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<OperationRun, $this>
*/
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function initiator(): BelongsTo
{
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
/**
* @return HasMany<EvidenceSnapshotItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(EvidenceSnapshotItem::class)->orderBy('sort_order')->orderBy('id');
}
/**
* @return HasMany<ReviewPack, $this>
*/
public function reviewPacks(): HasMany
{
return $this->hasMany(ReviewPack::class);
}
/**
* @return HasMany<TenantReview, $this>
*/
public function tenantReviews(): HasMany
{
return $this->hasMany(TenantReview::class);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForTenant(Builder $query, int $tenantId): Builder
{
return $query->where('tenant_id', $tenantId);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('status', EvidenceSnapshotStatus::Active->value);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeCurrent(Builder $query): Builder
{
return $query
->whereIn('status', [
EvidenceSnapshotStatus::Queued->value,
EvidenceSnapshotStatus::Generating->value,
EvidenceSnapshotStatus::Active->value,
]);
}
public function isCurrent(): bool
{
return in_array((string) $this->status, [
EvidenceSnapshotStatus::Queued->value,
EvidenceSnapshotStatus::Generating->value,
EvidenceSnapshotStatus::Active->value,
], true);
}
public function completenessState(): EvidenceCompletenessState
{
return EvidenceCompletenessState::tryFrom((string) $this->completeness_state)
?? EvidenceCompletenessState::Missing;
}
}

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EvidenceSnapshotItem extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'required' => 'boolean',
'measured_at' => 'datetime',
'freshness_at' => 'datetime',
'summary_payload' => 'array',
'sort_order' => 'integer',
];
}
/**
* @return BelongsTo<EvidenceSnapshot, $this>
*/
public function snapshot(): BelongsTo
{
return $this->belongsTo(EvidenceSnapshot::class, 'evidence_snapshot_id');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -3,12 +3,9 @@
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Arr;
class Finding extends Model
{
@ -99,14 +96,6 @@ public function closedByUser(): BelongsTo
return $this->belongsTo(User::class, 'closed_by_user_id');
}
/**
* @return HasOne<FindingException, $this>
*/
public function findingException(): HasOne
{
return $this->hasOne(FindingException::class);
}
/**
* @return array<int, string>
*/
@ -169,15 +158,10 @@ public function hasOpenStatus(): bool
return self::isOpenStatus($this->status);
}
public function isRiskAccepted(): bool
{
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
}
public function acknowledge(User $user): self
public function acknowledge(User $user): void
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {
return $this;
return;
}
$this->forceFill([
@ -187,62 +171,28 @@ public function acknowledge(User $user): self
]);
$this->save();
return $this;
}
public function resolve(string $reason): self
{
$this->forceFill([
'status' => self::STATUS_RESOLVED,
'resolved_at' => now(),
'resolved_reason' => $reason,
]);
$this->save();
return $this;
}
/**
* @param array<string, mixed> $evidence
* Auto-resolve the finding.
*/
public function reopen(array $evidence): self
public function resolve(string $reason): void
{
$this->forceFill([
'status' => self::STATUS_NEW,
'resolved_at' => null,
'resolved_reason' => null,
'evidence_jsonb' => $evidence,
]);
$this->status = self::STATUS_RESOLVED;
$this->resolved_at = now();
$this->resolved_reason = $reason;
$this->save();
return $this;
}
public function resolvedSubjectDisplayName(): ?string
/**
* Re-open a resolved finding.
*/
public function reopen(array $evidence): void
{
$displayName = $this->getAttribute('subject_display_name');
if (is_string($displayName) && trim($displayName) !== '') {
return trim($displayName);
}
$fallback = Arr::get($this->evidence_jsonb ?? [], 'display_name');
$fallback = is_string($fallback) ? trim($fallback) : null;
return $fallback !== '' ? $fallback : null;
}
public function scopeWithSubjectDisplayName(Builder $query): Builder
{
return $query->addSelect([
'subject_display_name' => InventoryItem::query()
->select('display_name')
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
->limit(1),
]);
$this->status = self::STATUS_NEW;
$this->resolved_at = null;
$this->resolved_reason = null;
$this->evidence_jsonb = $evidence;
$this->save();
}
}

View File

@ -1,243 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FindingException extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string STATUS_PENDING = 'pending';
public const string STATUS_ACTIVE = 'active';
public const string STATUS_EXPIRING = 'expiring';
public const string STATUS_EXPIRED = 'expired';
public const string STATUS_REJECTED = 'rejected';
public const string STATUS_REVOKED = 'revoked';
public const string STATUS_SUPERSEDED = 'superseded';
public const string VALIDITY_VALID = 'valid';
public const string VALIDITY_EXPIRING = 'expiring';
public const string VALIDITY_EXPIRED = 'expired';
public const string VALIDITY_REVOKED = 'revoked';
public const string VALIDITY_REJECTED = 'rejected';
public const string VALIDITY_MISSING_SUPPORT = 'missing_support';
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'requested_at' => 'datetime',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
'revoked_at' => 'datetime',
'effective_from' => 'datetime',
'expires_at' => 'datetime',
'review_due_at' => 'datetime',
'evidence_summary' => 'array',
];
}
/**
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/**
* @return BelongsTo<Finding, $this>
*/
public function finding(): BelongsTo
{
return $this->belongsTo(Finding::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function requester(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function approver(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
/**
* @return BelongsTo<FindingExceptionDecision, $this>
*/
public function currentDecision(): BelongsTo
{
return $this->belongsTo(FindingExceptionDecision::class, 'current_decision_id');
}
/**
* @return HasMany<FindingExceptionDecision, $this>
*/
public function decisions(): HasMany
{
return $this->hasMany(FindingExceptionDecision::class)
->orderBy('decided_at')
->orderBy('id');
}
/**
* @return HasMany<FindingExceptionEvidenceReference, $this>
*/
public function evidenceReferences(): HasMany
{
return $this->hasMany(FindingExceptionEvidenceReference::class)
->orderBy('id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeForFinding(Builder $query, Finding $finding): Builder
{
return $query->where('finding_id', (int) $finding->getKey());
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopePending(Builder $query): Builder
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeCurrent(Builder $query): Builder
{
return $query->whereIn('status', [
self::STATUS_PENDING,
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
]);
}
public function isPending(): bool
{
return (string) $this->status === self::STATUS_PENDING;
}
public function isActiveLike(): bool
{
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
], true);
}
public function hasPriorApproval(): bool
{
return $this->approved_at !== null
&& $this->effective_from !== null
&& is_numeric($this->approved_by_user_id);
}
public function hasValidGovernance(): bool
{
return in_array((string) $this->current_validity_state, [
self::VALIDITY_VALID,
self::VALIDITY_EXPIRING,
], true);
}
public function currentDecisionType(): ?string
{
$decision = $this->relationLoaded('currentDecision')
? $this->currentDecision
: $this->currentDecision()->first();
return $decision instanceof FindingExceptionDecision
? (string) $decision->decision_type
: null;
}
public function isPendingRenewal(): bool
{
return $this->isPending()
&& $this->hasPriorApproval()
&& $this->currentDecisionType() === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED;
}
public function requiresFreshDecisionForFinding(Finding $finding): bool
{
return ! $finding->isRiskAccepted()
&& ! $this->isPending()
&& $this->hasValidGovernance();
}
public function canBeRenewed(): bool
{
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
self::STATUS_EXPIRED,
], true);
}
public function canBeRevoked(): bool
{
if ($this->isPendingRenewal()) {
return true;
}
return in_array((string) $this->status, [
self::STATUS_ACTIVE,
self::STATUS_EXPIRING,
], true);
}
}

View File

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use LogicException;
class FindingExceptionDecision extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
public const string TYPE_REQUESTED = 'requested';
public const string TYPE_APPROVED = 'approved';
public const string TYPE_REJECTED = 'rejected';
public const string TYPE_RENEWAL_REQUESTED = 'renewal_requested';
public const string TYPE_RENEWED = 'renewed';
public const string TYPE_REVOKED = 'revoked';
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'effective_from' => 'datetime',
'expires_at' => 'datetime',
'metadata' => 'array',
'decided_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(static function (): void {
throw new LogicException('Finding exception decisions are append-only.');
});
static::deleting(static function (): void {
throw new LogicException('Finding exception decisions are append-only.');
});
}
/**
* @return BelongsTo<FindingException, $this>
*/
public function exception(): BelongsTo
{
return $this->belongsTo(FindingException::class, 'finding_exception_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
}

View File

@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FindingExceptionEvidenceReference extends Model
{
use DerivesWorkspaceIdFromTenant;
use HasFactory;
protected $guarded = [];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'summary_payload' => 'array',
'measured_at' => 'datetime',
];
}
/**
* @return BelongsTo<FindingException, $this>
*/
public function exception(): BelongsTo
{
return $this->belongsTo(FindingException::class, 'finding_exception_id');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@ -49,7 +49,7 @@ protected static function booted(): void
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class)->withTrashed();
return $this->belongsTo(Tenant::class);
}
public function workspace(): BelongsTo

View File

@ -71,22 +71,6 @@ public function initiator(): BelongsTo
return $this->belongsTo(User::class, 'initiated_by_user_id');
}
/**
* @return BelongsTo<EvidenceSnapshot, $this>
*/
public function evidenceSnapshot(): BelongsTo
{
return $this->belongsTo(EvidenceSnapshot::class);
}
/**
* @return BelongsTo<TenantReview, $this>
*/
public function tenantReview(): BelongsTo
{
return $this->belongsTo(TenantReview::class);
}
/**
* @param Builder<self> $query
* @return Builder<self>

View File

@ -2,7 +2,6 @@
namespace App\Models;
use App\Support\Tenants\TenantLifecycle;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasName;
use Illuminate\Database\Eloquent\Builder;
@ -115,12 +114,12 @@ public static function activeQuery(): Builder
{
return static::query()
->whereNull('deleted_at')
->where('status', TenantLifecycle::Active->value);
->where('status', self::STATUS_ACTIVE);
}
public function makeCurrent(): void
{
if (! $this->isSelectableAsContext()) {
if ($this->trashed() || $this->status !== self::STATUS_ACTIVE) {
throw new RuntimeException('Only active tenants can be made current.');
}
@ -261,21 +260,6 @@ public function auditLogs(): HasMany
return $this->hasMany(AuditLog::class);
}
public function findingExceptions(): HasMany
{
return $this->hasMany(FindingException::class);
}
public function evidenceSnapshots(): HasMany
{
return $this->hasMany(EvidenceSnapshot::class);
}
public function tenantReviews(): HasMany
{
return $this->hasMany(TenantReview::class);
}
public function settings(): HasMany
{
return $this->hasMany(TenantSetting::class);
@ -353,36 +337,6 @@ public function scopeForTenant(Builder $query, self|int|string $tenant): Builder
public function isActive(): bool
{
return $this->isSelectableAsContext();
}
public function lifecycle(): TenantLifecycle
{
return TenantLifecycle::fromTenant($this);
}
public function isDraft(): bool
{
return $this->lifecycle() === TenantLifecycle::Draft;
}
public function isOnboarding(): bool
{
return $this->lifecycle() === TenantLifecycle::Onboarding;
}
public function isArchived(): bool
{
return $this->lifecycle() === TenantLifecycle::Archived;
}
public function isSelectableAsContext(): bool
{
return ! $this->trashed() && $this->lifecycle()->canSelectAsContext();
}
public function canResumeOnboarding(): bool
{
return ! $this->trashed() && $this->lifecycle()->canResumeOnboarding();
return ! $this->trashed() && ($this->status ?? 'active') === 'active';
}
}

View File

@ -4,9 +4,7 @@
namespace App\Models;
use App\Support\Onboarding\OnboardingCheckpoint;
use App\Support\Onboarding\OnboardingDraftStatus;
use App\Support\Onboarding\OnboardingLifecycleState;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -43,10 +41,6 @@ class TenantOnboardingSession extends Model
protected $casts = [
'state' => 'array',
'version' => 'integer',
'lifecycle_state' => OnboardingLifecycleState::class,
'current_checkpoint' => OnboardingCheckpoint::class,
'last_completed_checkpoint' => OnboardingCheckpoint::class,
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
];
@ -82,7 +76,7 @@ public function workspace(): BelongsTo
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class)->withTrashed();
return $this->belongsTo(Tenant::class);
}
/**
@ -114,95 +108,29 @@ public function scopeResumable(Builder $query): Builder
public function isCompleted(): bool
{
return $this->completed_at !== null
|| $this->lifecycleState() === OnboardingLifecycleState::Completed;
return $this->completed_at !== null;
}
public function isCancelled(): bool
{
return $this->cancelled_at !== null
|| $this->lifecycleState() === OnboardingLifecycleState::Cancelled;
}
public function workflowStatus(): OnboardingDraftStatus
{
return OnboardingDraftStatus::fromLifecycleState($this->lifecycleState());
return $this->cancelled_at !== null;
}
public function status(): OnboardingDraftStatus
{
return $this->workflowStatus();
}
if ($this->isCancelled()) {
return OnboardingDraftStatus::Cancelled;
}
public function isWorkflowResumable(): bool
{
return $this->workflowStatus()->isResumable();
if ($this->isCompleted()) {
return OnboardingDraftStatus::Completed;
}
return OnboardingDraftStatus::Draft;
}
public function isResumable(): bool
{
return $this->isWorkflowResumable();
}
public function isWorkflowTerminal(): bool
{
return $this->lifecycleState()->isTerminal();
}
public function isTerminal(): bool
{
return $this->isWorkflowTerminal();
}
public function lifecycleState(): OnboardingLifecycleState
{
if ($this->lifecycle_state instanceof OnboardingLifecycleState) {
return $this->lifecycle_state;
}
if (is_string($this->lifecycle_state) && OnboardingLifecycleState::tryFrom($this->lifecycle_state) instanceof OnboardingLifecycleState) {
return OnboardingLifecycleState::from($this->lifecycle_state);
}
if ($this->completed_at !== null) {
return OnboardingLifecycleState::Completed;
}
if ($this->cancelled_at !== null) {
return OnboardingLifecycleState::Cancelled;
}
return OnboardingLifecycleState::Draft;
}
public function currentCheckpoint(): ?OnboardingCheckpoint
{
if ($this->current_checkpoint instanceof OnboardingCheckpoint) {
return $this->current_checkpoint;
}
if (is_string($this->current_checkpoint) && OnboardingCheckpoint::tryFrom($this->current_checkpoint) instanceof OnboardingCheckpoint) {
return OnboardingCheckpoint::from($this->current_checkpoint);
}
return OnboardingCheckpoint::fromCurrentStep($this->current_step);
}
public function lastCompletedCheckpoint(): ?OnboardingCheckpoint
{
if ($this->last_completed_checkpoint instanceof OnboardingCheckpoint) {
return $this->last_completed_checkpoint;
}
if (is_string($this->last_completed_checkpoint) && OnboardingCheckpoint::tryFrom($this->last_completed_checkpoint) instanceof OnboardingCheckpoint) {
return OnboardingCheckpoint::from($this->last_completed_checkpoint);
}
return null;
}
public function expectedVersion(): int
{
return max(1, (int) ($this->version ?? 1));
return $this->status()->isResumable();
}
}

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