Compare commits
6 Commits
150-tenant
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f2629493 | |||
| b1e1e06861 | |||
| a74ab12f04 | |||
| 5ec62cd117 | |||
| ec71c2d4e7 | |||
| 1f3619bd16 |
15
.github/agents/copilot-instructions.md
vendored
15
.github/agents/copilot-instructions.md
vendored
@ -87,6 +87,15 @@ ## Active Technologies
|
||||
- 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)
|
||||
|
||||
@ -106,8 +115,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 149-queued-execution-reauthorization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams
|
||||
- 148-central-tenant-operability-policy: Added 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`
|
||||
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||
- 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
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
72
app/Filament/Concerns/InteractsWithTenantOwnedRecords.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ trait ResolvesPanelTenantContext
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
@ -24,6 +24,16 @@ 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();
|
||||
@ -34,4 +44,9 @@ protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
||||
{
|
||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
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;
|
||||
@ -21,6 +22,10 @@ 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();
|
||||
|
||||
|
||||
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
503
app/Filament/Pages/Monitoring/FindingExceptionsQueue.php
Normal file
@ -0,0 +1,503 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
307
app/Filament/Pages/Reviews/ReviewRegister.php
Normal file
@ -0,0 +1,307 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@
|
||||
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
|
||||
{
|
||||
@ -41,7 +43,8 @@ class TenantRequiredPermissions extends Page
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
public ?Tenant $scopedTenant = null;
|
||||
#[Locked]
|
||||
public ?int $scopedTenantId = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
@ -50,7 +53,7 @@ public static function canAccess(): bool
|
||||
|
||||
public function currentTenant(): ?Tenant
|
||||
{
|
||||
return $this->scopedTenant;
|
||||
return $this->trustedScopedTenant();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
@ -61,7 +64,7 @@ public function mount(): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->scopedTenant = $tenant;
|
||||
$this->scopedTenantId = (int) $tenant->getKey();
|
||||
$this->heading = $tenant->getFilamentName();
|
||||
$this->subheading = 'Required permissions';
|
||||
|
||||
@ -143,7 +146,7 @@ public function resetFilters(): void
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
@ -172,7 +175,7 @@ private function refreshViewModel(): void
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||
@ -183,7 +186,7 @@ public function reRunVerificationUrl(): string
|
||||
|
||||
public function manageProviderConnectionUrl(): ?string
|
||||
{
|
||||
$tenant = $this->scopedTenant;
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -234,4 +237,47 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||
use App\Support\Onboarding\OnboardingDraftStage;
|
||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||
@ -88,6 +89,7 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Locked;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
@ -123,8 +125,14 @@ protected function getLayoutData(): array
|
||||
|
||||
public ?Tenant $managedTenant = null;
|
||||
|
||||
#[Locked]
|
||||
public ?int $managedTenantId = null;
|
||||
|
||||
public ?TenantOnboardingSession $onboardingSession = null;
|
||||
|
||||
#[Locked]
|
||||
public ?int $onboardingSessionId = null;
|
||||
|
||||
public ?int $onboardingSessionVersion = null;
|
||||
|
||||
public ?int $selectedProviderConnectionId = null;
|
||||
@ -151,6 +159,8 @@ protected function getLayoutData(): array
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (isset($this->workspace)) {
|
||||
$actions[] = Action::make('back_to_workspace')
|
||||
@ -170,10 +180,10 @@ protected function getHeaderActions(): array
|
||||
$actions[] = Action::make('view_linked_tenant')
|
||||
->label($this->linkedTenantActionLabel())
|
||||
->color('gray')
|
||||
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
|
||||
->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
|
||||
}
|
||||
|
||||
if ($this->canResumeDraft($this->onboardingSession)) {
|
||||
if ($this->canResumeDraft($draft)) {
|
||||
$actions[] = Action::make('cancel_onboarding_draft')
|
||||
->label('Cancel draft')
|
||||
->color('danger')
|
||||
@ -184,7 +194,7 @@ protected function getHeaderActions(): array
|
||||
->action(fn () => $this->cancelOnboardingDraft());
|
||||
}
|
||||
|
||||
if ($this->canDeleteDraft($this->onboardingSession)) {
|
||||
if ($this->canDeleteDraft($draft)) {
|
||||
$actions[] = Action::make('delete_onboarding_draft_header')
|
||||
->label('Delete draft')
|
||||
->color('danger')
|
||||
@ -202,17 +212,18 @@ protected function getHeaderActions(): array
|
||||
private function canViewLinkedTenant(): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $user instanceof User || ! $this->managedTenant instanceof Tenant) {
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($this->managedTenant)) {
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $this->managedTenant,
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
||||
actor: $user,
|
||||
workspaceId: (int) $this->workspace->getKey(),
|
||||
@ -222,13 +233,15 @@ private function canViewLinkedTenant(): bool
|
||||
|
||||
private function linkedTenantActionLabel(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'View tenant';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'View tenant (%s)',
|
||||
TenantLifecyclePresentation::fromTenant($this->managedTenant)->label,
|
||||
TenantLifecyclePresentation::fromTenant($tenant)->label,
|
||||
);
|
||||
}
|
||||
|
||||
@ -712,7 +725,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
||||
$tenant = $draft->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||
$this->managedTenant = $tenant;
|
||||
$this->setManagedTenant($tenant);
|
||||
}
|
||||
|
||||
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
|
||||
@ -801,7 +814,9 @@ private function draftPickerSchema(): array
|
||||
*/
|
||||
private function resumeContextSchema(): array
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -814,19 +829,19 @@ private function resumeContextSchema(): array
|
||||
->schema([
|
||||
Text::make('Tenant')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
|
||||
Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft))
|
||||
->weight(FontWeight::SemiBold),
|
||||
Text::make('Current stage')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
|
||||
Text::make(fn () => $this->draftStageLabel($this->currentOnboardingSessionRecord() ?? $draft))
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
|
||||
->color(fn () => $this->draftStageColor($this->currentOnboardingSessionRecord() ?? $draft)),
|
||||
Text::make('Started by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
|
||||
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->startedByUser?->name ?? 'Unknown'),
|
||||
Text::make('Last updated by')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
|
||||
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->updatedByUser?->name ?? 'Unknown'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@ -836,11 +851,13 @@ private function resumeContextSchema(): array
|
||||
*/
|
||||
private function nonResumableSummarySchema(): array
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statusLabel = $this->onboardingSession->status()->label();
|
||||
$statusLabel = $draft->status()->label();
|
||||
|
||||
return [
|
||||
Callout::make("This onboarding draft is {$statusLabel}.")
|
||||
@ -855,16 +872,16 @@ private function nonResumableSummarySchema(): array
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => $statusLabel)
|
||||
->badge()
|
||||
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
|
||||
->color(fn () => $this->draftStatusColor($this->currentOnboardingSessionRecord() ?? $draft)),
|
||||
Text::make('Primary domain')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
|
||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['primary_domain'] ?? null) ?: '—')),
|
||||
Text::make('Environment')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
|
||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['environment'] ?? null) ?: '—')),
|
||||
Text::make('Notes')
|
||||
->color('gray'),
|
||||
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
|
||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['notes'] ?? null) ?: '—')),
|
||||
]),
|
||||
SchemaActions::make([
|
||||
Action::make('back_to_workspace_summary')
|
||||
@ -882,7 +899,7 @@ private function nonResumableSummarySchema(): array
|
||||
->modalHeading('Delete onboarding draft')
|
||||
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
|
||||
->modalSubmitActionLabel('Delete draft')
|
||||
->visible(fn (): bool => $this->canDeleteDraft($this->onboardingSession))
|
||||
->visible(fn (): bool => $this->canDeleteDraft($this->currentOnboardingSessionRecord() ?? $draft))
|
||||
->action(fn () => $this->deleteOnboardingDraft()),
|
||||
]),
|
||||
];
|
||||
@ -892,7 +909,7 @@ private function startNewOnboardingDraft(): void
|
||||
{
|
||||
$this->showDraftPicker = false;
|
||||
$this->showStartState = true;
|
||||
$this->managedTenant = null;
|
||||
$this->setManagedTenant(null);
|
||||
$this->setOnboardingSession(null);
|
||||
$this->selectedProviderConnectionId = null;
|
||||
$this->selectedBootstrapOperationTypes = [];
|
||||
@ -944,9 +961,20 @@ private function cancelOnboardingDraft(): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->authorize('cancel', $this->onboardingSession);
|
||||
$this->authorizeWorkspaceMember($user);
|
||||
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
|
||||
$this->onboardingSessionId ?? $this->onboardingSession,
|
||||
$user,
|
||||
$this->workspace,
|
||||
app(OnboardingDraftResolver::class),
|
||||
);
|
||||
|
||||
$this->setOnboardingSession($draft);
|
||||
|
||||
$this->authorize('cancel', $draft);
|
||||
|
||||
if (! $this->canResumeDraft($draft)) {
|
||||
Notification::make()
|
||||
->title('Draft is not resumable')
|
||||
->warning()
|
||||
@ -1007,8 +1035,7 @@ private function cancelOnboardingDraft(): void
|
||||
],
|
||||
);
|
||||
|
||||
$this->managedTenant = $normalizedTenant;
|
||||
$this->onboardingSession->setRelation('tenant', $normalizedTenant);
|
||||
$this->setManagedTenant($normalizedTenant);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
@ -1031,9 +1058,20 @@ private function deleteOnboardingDraft(): void
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$this->authorize('cancel', $this->onboardingSession);
|
||||
$this->authorizeWorkspaceMember($user);
|
||||
|
||||
if (! $this->canDeleteDraft($this->onboardingSession)) {
|
||||
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
|
||||
$this->onboardingSessionId ?? $this->onboardingSession,
|
||||
$user,
|
||||
$this->workspace,
|
||||
app(OnboardingDraftResolver::class),
|
||||
);
|
||||
|
||||
$this->setOnboardingSession($draft);
|
||||
|
||||
$this->authorize('cancel', $draft);
|
||||
|
||||
if (! $this->canDeleteDraft($draft)) {
|
||||
Notification::make()
|
||||
->title('Draft cannot be deleted')
|
||||
->warning()
|
||||
@ -1042,7 +1080,6 @@ private function deleteOnboardingDraft(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$draft = $this->onboardingSession;
|
||||
$draftId = (int) $draft->getKey();
|
||||
$draftTitle = $this->draftTitle($draft);
|
||||
$draftStatus = $draft->status()->value;
|
||||
@ -1070,7 +1107,7 @@ private function deleteOnboardingDraft(): void
|
||||
targetLabel: $draftTitle,
|
||||
);
|
||||
|
||||
$this->managedTenant = null;
|
||||
$this->setManagedTenant(null);
|
||||
$this->setOnboardingSession(null);
|
||||
|
||||
Notification::make()
|
||||
@ -1083,8 +1120,10 @@ private function deleteOnboardingDraft(): void
|
||||
|
||||
private function showsNonResumableSummary(): bool
|
||||
{
|
||||
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||
&& ! $this->canResumeDraft($this->onboardingSession);
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
return $draft instanceof TenantOnboardingSession
|
||||
&& ! $this->canResumeDraft($draft);
|
||||
}
|
||||
|
||||
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
|
||||
@ -1117,11 +1156,13 @@ private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App
|
||||
|
||||
private function shouldShowDraftLandingAction(): bool
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
if (! $this->canResumeDraft($draft)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1219,14 +1260,95 @@ private function expectedDraftVersion(): ?int
|
||||
private function setOnboardingSession(?TenantOnboardingSession $draft): void
|
||||
{
|
||||
$this->onboardingSession = $draft;
|
||||
$this->onboardingSessionId = $draft instanceof TenantOnboardingSession
|
||||
? (int) $draft->getKey()
|
||||
: null;
|
||||
$this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession
|
||||
? $draft->expectedVersion()
|
||||
: null;
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
|
||||
$this->setManagedTenant($draft->tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant_id !== null) {
|
||||
$this->managedTenantId = (int) $draft->tenant_id;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setManagedTenant(null);
|
||||
}
|
||||
|
||||
private function setManagedTenant(?Tenant $tenant): void
|
||||
{
|
||||
$this->managedTenant = $tenant;
|
||||
$this->managedTenantId = $tenant instanceof Tenant
|
||||
? (int) $tenant->getKey()
|
||||
: null;
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession && $tenant instanceof Tenant) {
|
||||
$this->onboardingSession->setRelation('tenant', $tenant);
|
||||
}
|
||||
}
|
||||
|
||||
private function currentOnboardingSessionRecord(): ?TenantOnboardingSession
|
||||
{
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession
|
||||
&& $this->onboardingSessionId !== null
|
||||
&& (int) $this->onboardingSession->getKey() === $this->onboardingSessionId) {
|
||||
return $this->onboardingSession;
|
||||
}
|
||||
|
||||
if ($this->onboardingSessionId === null) {
|
||||
return $this->onboardingSession;
|
||||
}
|
||||
|
||||
$query = TenantOnboardingSession::query()
|
||||
->with(['tenant', 'startedByUser', 'updatedByUser'])
|
||||
->whereKey($this->onboardingSessionId);
|
||||
|
||||
if (isset($this->workspace)) {
|
||||
$query->where('workspace_id', (int) $this->workspace->getKey());
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
private function currentManagedTenantRecord(): ?Tenant
|
||||
{
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession && $draft->tenant instanceof Tenant) {
|
||||
return $draft->tenant;
|
||||
}
|
||||
|
||||
if ($this->managedTenant instanceof Tenant
|
||||
&& $this->managedTenantId !== null
|
||||
&& (int) $this->managedTenant->getKey() === $this->managedTenantId) {
|
||||
return $this->managedTenant;
|
||||
}
|
||||
|
||||
if ($this->managedTenantId === null) {
|
||||
return $this->managedTenant;
|
||||
}
|
||||
|
||||
$query = Tenant::query()->withTrashed()->whereKey($this->managedTenantId);
|
||||
|
||||
if (isset($this->workspace)) {
|
||||
$query->where('workspace_id', (int) $this->workspace->getKey());
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
private function refreshOnboardingDraftFromBackend(): void
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1237,15 +1359,11 @@ private function refreshOnboardingDraftFromBackend(): void
|
||||
}
|
||||
|
||||
$this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve(
|
||||
$this->onboardingSession,
|
||||
$draft,
|
||||
$user,
|
||||
$this->workspace,
|
||||
));
|
||||
|
||||
if ($this->onboardingSession->tenant instanceof Tenant) {
|
||||
$this->managedTenant = $this->onboardingSession->tenant;
|
||||
}
|
||||
|
||||
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
||||
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
|
||||
$this->initializeWizardData();
|
||||
@ -1275,11 +1393,13 @@ private function handleImmutableDraft(string $title = 'This onboarding draft is
|
||||
|
||||
private function lifecycleState(): OnboardingLifecycleState
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return OnboardingLifecycleState::Draft;
|
||||
}
|
||||
|
||||
return $this->lifecycleService()->snapshot($this->onboardingSession)['lifecycle_state'];
|
||||
return $this->lifecycleService()->snapshot($draft)['lifecycle_state'];
|
||||
}
|
||||
|
||||
private function lifecycleStateLabel(): string
|
||||
@ -1302,30 +1422,38 @@ private function lifecycleStateColor(): string
|
||||
|
||||
private function currentCheckpointLabel(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return OnboardingCheckpoint::Identify->label();
|
||||
}
|
||||
|
||||
return ($this->lifecycleService()->snapshot($this->onboardingSession)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
|
||||
return ($this->lifecycleService()->snapshot($draft)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
|
||||
?? OnboardingCheckpoint::Identify->label();
|
||||
}
|
||||
|
||||
public function shouldPollCheckpointLifecycle(): bool
|
||||
{
|
||||
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||
&& $this->lifecycleService()->hasActiveCheckpoint($this->onboardingSession);
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
return $draft instanceof TenantOnboardingSession
|
||||
&& $this->lifecycleService()->hasActiveCheckpoint($draft);
|
||||
}
|
||||
|
||||
public function refreshCheckpointLifecycle(): void
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($this->onboardingSession));
|
||||
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($draft));
|
||||
|
||||
if ($this->managedTenant instanceof Tenant) {
|
||||
$this->managedTenant->refresh();
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->setManagedTenant($tenant->fresh());
|
||||
}
|
||||
|
||||
$this->initializeWizardData();
|
||||
@ -1351,20 +1479,24 @@ private function initializeWizardData(): void
|
||||
$this->data['new_connection']['is_default'] ??= true;
|
||||
}
|
||||
|
||||
if ($this->managedTenant instanceof Tenant) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
|
||||
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
|
||||
$this->data['name'] ??= (string) $this->managedTenant->name;
|
||||
$this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? '');
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
$notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null;
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
|
||||
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
|
||||
$this->data['name'] ??= (string) $tenant->name;
|
||||
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
|
||||
|
||||
$notes = is_array($tenant->metadata) ? ($tenant->metadata['notes'] ?? null) : null;
|
||||
if (is_string($notes) && trim($notes) !== '') {
|
||||
$this->data['notes'] ??= trim($notes);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if ($draft instanceof TenantOnboardingSession) {
|
||||
$state = is_array($draft->state) ? $draft->state : [];
|
||||
|
||||
if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') {
|
||||
$this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']);
|
||||
@ -1394,13 +1526,13 @@ private function initializeWizardData(): void
|
||||
}
|
||||
}
|
||||
|
||||
$providerConnectionId = $this->resolvePersistedProviderConnectionId($this->onboardingSession->state['provider_connection_id'] ?? null);
|
||||
$providerConnectionId = $this->resolvePersistedProviderConnectionId($draft->state['provider_connection_id'] ?? null);
|
||||
if ($providerConnectionId !== null) {
|
||||
$this->data['provider_connection_id'] = $providerConnectionId;
|
||||
$this->selectedProviderConnectionId = $providerConnectionId;
|
||||
}
|
||||
|
||||
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
||||
if (is_array($types)) {
|
||||
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
||||
}
|
||||
@ -1418,7 +1550,7 @@ private function initializeWizardData(): void
|
||||
private function computeWizardStartStep(): int
|
||||
{
|
||||
return app(OnboardingDraftStageResolver::class)
|
||||
->resolve($this->onboardingSession)
|
||||
->resolve($this->currentOnboardingSessionRecord())
|
||||
->wizardStep();
|
||||
}
|
||||
|
||||
@ -1427,13 +1559,15 @@ private function computeWizardStartStep(): int
|
||||
*/
|
||||
private function providerConnectionOptions(): array
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', $this->managedTenant->getKey())
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('display_name')
|
||||
->pluck('display_name', 'id')
|
||||
@ -1450,11 +1584,13 @@ private function verificationStatusLabel(): string
|
||||
|
||||
private function verificationStatus(): string
|
||||
{
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
$draft = $this->currentOnboardingSessionRecord();
|
||||
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
return 'not_started';
|
||||
}
|
||||
|
||||
return $this->lifecycleService()->verificationStatus($this->onboardingSession, $this->selectedProviderConnectionId);
|
||||
return $this->lifecycleService()->verificationStatus($draft, $this->selectedProviderConnectionId);
|
||||
}
|
||||
|
||||
private function verificationStatusFromRunOutcome(OperationRun $run): string
|
||||
@ -1951,6 +2087,19 @@ private function authorizeEditableDraft(User $user): void
|
||||
return;
|
||||
}
|
||||
|
||||
$expectedVersion = $this->expectedDraftVersion();
|
||||
|
||||
$this->setOnboardingSession(app(TrustedStateResolver::class)->resolveOnboardingDraft(
|
||||
$this->onboardingSessionId ?? $this->onboardingSession,
|
||||
$user,
|
||||
$this->workspace,
|
||||
app(OnboardingDraftResolver::class),
|
||||
));
|
||||
|
||||
if ($expectedVersion !== null) {
|
||||
$this->onboardingSessionVersion = $expectedVersion;
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->onboardingSession);
|
||||
|
||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||
@ -1958,6 +2107,27 @@ private function authorizeEditableDraft(User $user): void
|
||||
}
|
||||
}
|
||||
|
||||
private function trustedManagedTenantForUser(User $user): Tenant
|
||||
{
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $tenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = app(WorkspaceContext::class)->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
||||
|
||||
$this->setManagedTenant($tenant);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||
{
|
||||
if (! $draft instanceof TenantOnboardingSession) {
|
||||
@ -1982,9 +2152,11 @@ private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||
|
||||
private function authorizeWorkspaceMember(User $user): void
|
||||
{
|
||||
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
||||
abort(404);
|
||||
}
|
||||
$this->workspace = app(TrustedStateResolver::class)->currentWorkspaceForMember(
|
||||
$user,
|
||||
app(WorkspaceContext::class),
|
||||
request(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
||||
@ -2181,7 +2353,7 @@ public function identifyManagedTenant(array $data): void
|
||||
resourceId: (string) $tenant->getKey(),
|
||||
);
|
||||
|
||||
$this->managedTenant = $tenant;
|
||||
$this->setManagedTenant($tenant);
|
||||
$this->setOnboardingSession($session);
|
||||
});
|
||||
} catch (OnboardingDraftConflictException) {
|
||||
@ -2220,13 +2392,11 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
$tenant = $this->trustedManagedTenantForUser($user);
|
||||
|
||||
$connection = ProviderConnection::query()
|
||||
->where('workspace_id', (int) $this->workspace->getKey())
|
||||
->where('tenant_id', $this->managedTenant->getKey())
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereKey($providerConnectionId)
|
||||
->first();
|
||||
|
||||
@ -2272,7 +2442,7 @@ public function selectProviderConnection(int $providerConnectionId): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||
],
|
||||
@ -2305,11 +2475,7 @@ public function createProviderConnection(array $data): void
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -2498,7 +2664,7 @@ public function createProviderConnection(array $data): void
|
||||
context: [
|
||||
'metadata' => [
|
||||
'workspace_id' => (int) $this->workspace->getKey(),
|
||||
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||
'tenant_db_id' => (int) $tenant->getKey(),
|
||||
'provider_connection_id' => (int) $connection->getKey(),
|
||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||
],
|
||||
@ -2540,7 +2706,9 @@ public function startVerification(): void
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
try {
|
||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
|
||||
Notification::make()
|
||||
->title('Identify a managed tenant first')
|
||||
->warning()
|
||||
@ -2549,8 +2717,6 @@ public function startVerification(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
@ -2751,6 +2917,15 @@ public function startVerification(): void
|
||||
|
||||
public function refreshVerificationStatus(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->authorizeWorkspaceMember($user);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
$this->refreshCheckpointLifecycle();
|
||||
|
||||
Notification::make()
|
||||
@ -2773,11 +2948,7 @@ public function startBootstrap(array $operationTypes): void
|
||||
$this->authorizeWorkspaceMember($user);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -3112,23 +3283,27 @@ private function canCompleteOnboarding(): bool
|
||||
|
||||
private function completionSummaryTenantLine(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$name = $this->managedTenant->name ?? '—';
|
||||
$tenantId = $this->managedTenant->graphTenantId();
|
||||
$name = $tenant->name ?? '—';
|
||||
$tenantId = $tenant->graphTenantId();
|
||||
|
||||
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
|
||||
}
|
||||
|
||||
private function completionSummaryConnectionLabel(): string
|
||||
{
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
$tenant = $this->currentManagedTenantRecord();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
||||
$connection = $this->resolveSelectedProviderConnection($tenant);
|
||||
|
||||
if (! $connection instanceof ProviderConnection) {
|
||||
return 'Not configured';
|
||||
@ -3256,16 +3431,14 @@ public function completeOnboarding(): void
|
||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
|
||||
$this->authorizeEditableDraft($user);
|
||||
|
||||
if (! $this->managedTenant instanceof Tenant) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tenant = $this->trustedManagedTenantForUser($user);
|
||||
|
||||
$completionOutcome = app(TenantOperabilityService::class)->outcomeFor(
|
||||
tenant: $this->managedTenant,
|
||||
tenant: $tenant,
|
||||
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
||||
actor: $user,
|
||||
workspaceId: (int) $this->workspace->getKey(),
|
||||
@ -3316,7 +3489,7 @@ public function completeOnboarding(): void
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = $this->managedTenant->fresh();
|
||||
$tenant = $tenant->fresh();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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;
|
||||
@ -64,6 +65,7 @@
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
@ -581,6 +583,8 @@ 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()) {
|
||||
@ -622,6 +626,8 @@ 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()) {
|
||||
@ -662,6 +668,8 @@ 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()) {
|
||||
@ -919,17 +927,32 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||
{
|
||||
return static::getEloquentQuery()->withTrashed();
|
||||
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;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
@ -13,15 +12,7 @@ class EditBackupSchedule extends EditRecord
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
$record = BackupScheduleResource::getEloquentQuery()
|
||||
->withTrashed()
|
||||
->find($key);
|
||||
|
||||
if ($record === null) {
|
||||
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||
}
|
||||
|
||||
return $record;
|
||||
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
|
||||
@ -5,18 +5,32 @@
|
||||
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
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
@ -40,4 +54,14 @@ private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: [],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
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;
|
||||
@ -24,6 +25,19 @@ 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)
|
||||
@ -48,7 +62,7 @@ public function table(Table $table): Table
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
@ -87,6 +101,7 @@ 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);
|
||||
@ -97,4 +112,32 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@ -56,6 +57,7 @@
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
@ -120,13 +122,12 @@ public static function canCreate(): bool
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
return static::getTenantOwnedEloquentQuery();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
{
|
||||
@ -24,6 +25,11 @@ 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 = [
|
||||
|
||||
@ -43,6 +43,27 @@ 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')
|
||||
@ -77,7 +98,7 @@ public function table(Table $table): Table
|
||||
->color('danger')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->requiresConfirmation()
|
||||
->action(function (BackupItem $record): void {
|
||||
->action(function (mixed $record): void {
|
||||
$backupSet = $this->getOwnerRecord();
|
||||
|
||||
$user = auth()->user();
|
||||
@ -94,7 +115,7 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = [(int) $record->getKey()];
|
||||
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
@ -173,14 +194,7 @@ public function table(Table $table): Table
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$backupItemIds = $records
|
||||
->pluck('id')
|
||||
->map(fn (mixed $value): int => (int) $value)
|
||||
->filter(fn (int $value): bool => $value > 0)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
||||
|
||||
if ($backupItemIds === []) {
|
||||
return;
|
||||
@ -434,4 +448,68 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
@ -9,7 +11,6 @@
|
||||
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;
|
||||
@ -33,12 +34,16 @@
|
||||
|
||||
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';
|
||||
@ -188,17 +193,15 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = static::panelTenantContext();
|
||||
|
||||
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'),
|
||||
)
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key);
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultUrl(Model $record): string
|
||||
{
|
||||
$tenant = $record instanceof EntraGroup && $record->tenant instanceof Tenant
|
||||
@ -216,19 +219,6 @@ 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
|
||||
*/
|
||||
|
||||
@ -8,11 +8,17 @@
|
||||
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();
|
||||
|
||||
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
@ -0,0 +1,637 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
488
app/Filament/Resources/FindingExceptionResource.php
Normal file
488
app/Filament/Resources/FindingExceptionResource.php
Normal file
@ -0,0 +1,488 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<?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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,208 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -2,14 +2,18 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
@ -34,6 +38,8 @@
|
||||
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;
|
||||
@ -55,6 +61,7 @@
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = Finding::class;
|
||||
@ -112,7 +119,8 @@ public static function canView(Model $record): bool
|
||||
}
|
||||
|
||||
if ($record instanceof Finding) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey()
|
||||
&& (int) $record->workspace_id === (int) $tenant->workspace_id;
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -220,6 +228,62 @@ 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')
|
||||
@ -752,6 +816,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->triage($record, $tenant, $user);
|
||||
$triagedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -832,6 +897,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||
$assignedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -906,6 +972,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->resolve($record, $tenant, $user, $reason);
|
||||
$resolvedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -980,6 +1047,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
try {
|
||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||
$workflow->close($record, $tenant, $user, $reason);
|
||||
$closedCount++;
|
||||
} catch (Throwable) {
|
||||
@ -1007,79 +1075,6 @@ 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')
|
||||
@ -1089,12 +1084,19 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
||||
->withSubjectDisplayName();
|
||||
}
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
->withSubjectDisplayName()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1170,7 +1172,9 @@ public static function workflowActions(): array
|
||||
static::assignAction(),
|
||||
static::resolveAction(),
|
||||
static::closeAction(),
|
||||
static::riskAcceptAction(),
|
||||
static::requestExceptionAction(),
|
||||
static::renewExceptionAction(),
|
||||
static::revokeExceptionAction(),
|
||||
static::reopenAction(),
|
||||
];
|
||||
}
|
||||
@ -1182,7 +1186,7 @@ public static function triageAction(): Actions\Action
|
||||
->label('Triage')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
Finding::STATUS_NEW,
|
||||
Finding::STATUS_REOPENED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
@ -1208,7 +1212,7 @@ public static function startProgressAction(): Actions\Action
|
||||
->label('Start progress')
|
||||
->icon('heroicon-o-play')
|
||||
->color('info')
|
||||
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
||||
Finding::STATUS_TRIAGED,
|
||||
Finding::STATUS_ACKNOWLEDGED,
|
||||
], true))
|
||||
@ -1233,7 +1237,7 @@ public static function assignAction(): Actions\Action
|
||||
->label('Assign')
|
||||
->icon('heroicon-o-user-plus')
|
||||
->color('gray')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->fillForm(fn (Finding $record): array => [
|
||||
'assignee_user_id' => $record->assignee_user_id,
|
||||
'owner_user_id' => $record->owner_user_id,
|
||||
@ -1277,7 +1281,7 @@ public static function resolveAction(): Actions\Action
|
||||
->label('Resolve')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
@ -1312,6 +1316,7 @@ 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')
|
||||
@ -1339,36 +1344,153 @@ public static function closeAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
public static function riskAcceptAction(): Actions\Action
|
||||
public static function requestExceptionAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('risk_accept')
|
||||
->label('Risk accept')
|
||||
->icon('heroicon-o-shield-check')
|
||||
Actions\Action::make('request_exception')
|
||||
->label('Request exception')
|
||||
->icon('heroicon-o-shield-exclamation')
|
||||
->color('warning')
|
||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
->label('Risk acceptance reason')
|
||||
->rows(3)
|
||||
Select::make('owner_user_id')
|
||||
->label('Owner')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->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(),
|
||||
])
|
||||
->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'] ?? ''),
|
||||
),
|
||||
);
|
||||
->action(function (Finding $record, array $data, FindingExceptionService $service): void {
|
||||
static::runExceptionRequestMutation($record, $data, $service);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||
->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)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply();
|
||||
}
|
||||
@ -1381,7 +1503,7 @@ public static function reopenAction(): Actions\Action
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
@ -1401,6 +1523,7 @@ 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();
|
||||
|
||||
@ -1417,6 +1540,15 @@ 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) {
|
||||
@ -1435,6 +1567,194 @@ 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>
|
||||
*/
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
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;
|
||||
|
||||
@ -32,14 +33,26 @@ 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
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
$this->syncCanonicalAdminTenantFilterState();
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
@ -246,15 +259,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
protected function buildAllMatchingQuery(): Builder
|
||||
{
|
||||
$query = Finding::query();
|
||||
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('tenant_id', (int) $tenantId);
|
||||
$query = FindingResource::getEloquentQuery();
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
@ -304,6 +309,16 @@ 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);
|
||||
|
||||
@ -8,11 +8,17 @@
|
||||
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 [
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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;
|
||||
@ -38,6 +39,7 @@
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
@ -334,13 +336,15 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->with('lastSeenRun');
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -4,8 +4,14 @@
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
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;
|
||||
@ -54,7 +56,9 @@
|
||||
|
||||
class PolicyResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static ?string $model = Policy::class;
|
||||
|
||||
@ -1010,16 +1014,25 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->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 [
|
||||
|
||||
@ -3,12 +3,20 @@
|
||||
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 [
|
||||
@ -22,4 +30,14 @@ protected function getTableEmptyStateActions(): array
|
||||
PolicyResource::makeSyncAction(),
|
||||
];
|
||||
}
|
||||
|
||||
private function syncCanonicalAdminTenantFilterState(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: [],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
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
|
||||
@ -24,6 +25,11 @@ 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()];
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@ -31,6 +32,19 @@ 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)
|
||||
@ -55,7 +69,8 @@ public function table(Table $table): Table
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true),
|
||||
])
|
||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
||||
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
@ -178,4 +193,26 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
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;
|
||||
@ -59,7 +61,9 @@
|
||||
|
||||
class PolicyVersionResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
use ScopesGlobalSearchToTenant;
|
||||
|
||||
protected static ?string $model = PolicyVersion::class;
|
||||
|
||||
@ -893,7 +897,6 @@ 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);
|
||||
@ -903,8 +906,7 @@ public static function getEloquentQuery(): Builder
|
||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||
);
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
return static::getTenantOwnedEloquentQuery()
|
||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
@ -918,6 +920,36 @@ 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,
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewPolicyVersion extends ViewRecord
|
||||
{
|
||||
@ -16,6 +17,11 @@ 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 [
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@ -66,6 +67,7 @@
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
@ -242,18 +244,44 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
||||
->with('backupSet');
|
||||
}
|
||||
|
||||
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'),
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -846,6 +874,8 @@ 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) {
|
||||
@ -877,6 +907,8 @@ 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')
|
||||
@ -918,6 +950,8 @@ 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,
|
||||
@ -978,7 +1012,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1048,7 +1082,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1138,7 +1172,7 @@ public static function table(Table $table): Table
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
@ -1927,6 +1961,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
\App\Services\Intune\AuditLogger $auditLogger,
|
||||
HasTable $livewire
|
||||
) {
|
||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
||||
$tenant = $record->tenant;
|
||||
$backupSet = $record->backupSet;
|
||||
|
||||
|
||||
@ -3,12 +3,42 @@
|
||||
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;
|
||||
|
||||
@ -4,8 +4,14 @@
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
@ -164,6 +166,21 @@ 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
|
||||
@ -177,6 +194,33 @@ 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -201,6 +245,10 @@ 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()
|
||||
@ -331,7 +379,23 @@ public static function executeGeneration(array $data): void
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
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;
|
||||
}
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
|
||||
562
app/Filament/Resources/TenantReviewResource.php
Normal file
562
app/Filament/Resources/TenantReviewResource.php
Normal file
@ -0,0 +1,562 @@
|
||||
<?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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -107,10 +107,7 @@ protected function getHeaderActions(): array
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->form($this->findingsScopeForm())
|
||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||
'mode' => $data['scope_mode'] ?? null,
|
||||
'tenant_id' => $data['tenant_id'] ?? null,
|
||||
]);
|
||||
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
||||
|
||||
$this->findingsScopeMode = $scope->mode;
|
||||
$this->findingsTenantId = $scope->tenantId;
|
||||
@ -142,9 +139,7 @@ protected function getHeaderActions(): array
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
|
||||
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
|
||||
: FindingsLifecycleBackfillScope::allTenants();
|
||||
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
||||
|
||||
$user = auth('platform')->user();
|
||||
|
||||
@ -286,4 +281,34 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,6 +131,7 @@ 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')
|
||||
@ -146,6 +147,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
|
||||
@ -158,6 +160,11 @@ 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 : [];
|
||||
@ -173,6 +180,7 @@ protected function getViewData(): array
|
||||
'canManage' => $canManage,
|
||||
'downloadUrl' => $downloadUrl,
|
||||
'failedReason' => $failedReason,
|
||||
'reviewUrl' => $reviewUrl,
|
||||
];
|
||||
}
|
||||
|
||||
@ -200,6 +208,7 @@ private function emptyState(): array
|
||||
'canManage' => false,
|
||||
'downloadUrl' => null,
|
||||
'failedReason' => null,
|
||||
'reviewUrl' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,7 +177,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
if ($this->isLivewireUpdatePath($path)) {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
@ -193,6 +193,11 @@ 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);
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
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;
|
||||
@ -2130,20 +2131,14 @@ private function upsertFindings(
|
||||
: null;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
|
||||
$severity = (string) $driftItem['severity'];
|
||||
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
$finding->save();
|
||||
|
||||
$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),
|
||||
]);
|
||||
app(FindingWorkflowService::class)->reopenBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: (int) $this->operationRun->getKey(),
|
||||
);
|
||||
|
||||
$reopenedCount++;
|
||||
} else {
|
||||
|
||||
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
79
app/Jobs/ComposeTenantReviewJob.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,12 @@
|
||||
|
||||
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;
|
||||
@ -34,7 +35,7 @@ public function __construct(
|
||||
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
@ -54,12 +55,20 @@ 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, $operationRunService);
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||
|
||||
@ -67,60 +76,44 @@ public function handle(OperationRunService $operationRunService): void
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, 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);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$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'] : []);
|
||||
|
||||
// 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);
|
||||
$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);
|
||||
|
||||
// 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,
|
||||
);
|
||||
@ -154,16 +147,24 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
||||
'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,
|
||||
@ -183,18 +184,113 @@ 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($storedReports, $findings, Tenant $tenant): array
|
||||
private function computeDataFreshness($items): array
|
||||
{
|
||||
return [
|
||||
'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(),
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -204,12 +300,15 @@ private function computeDataFreshness($storedReports, $findings, Tenant $tenant)
|
||||
* @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 {
|
||||
@ -227,6 +326,12 @@ 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(),
|
||||
@ -241,16 +346,14 @@ 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(
|
||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($entraAdminRoles, $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(
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($permissionPosture, $includePii),
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
@ -258,8 +361,10 @@ private function buildFileMap(
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'risk_acceptance' => $riskAcceptance,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
@ -273,18 +378,33 @@ private function buildFileMap(
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
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(),
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -301,17 +421,31 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -322,6 +456,15 @@ 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.
|
||||
*
|
||||
@ -431,9 +574,98 @@ 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]);
|
||||
$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)],
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
@ -444,4 +676,13 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,14 @@ class AuditLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const INTERNAL_METADATA_KEYS = [
|
||||
'_actor_type',
|
||||
'_dedupe_key',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
@ -202,7 +210,12 @@ public function contextItems(): array
|
||||
}
|
||||
|
||||
foreach ($metadata as $key => $value) {
|
||||
if (in_array($key, $seen, true) || in_array($key, ['before', 'after'], true)) {
|
||||
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)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
137
app/Models/EvidenceSnapshot.php
Normal file
137
app/Models/EvidenceSnapshot.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
48
app/Models/EvidenceSnapshotItem.php
Normal file
48
app/Models/EvidenceSnapshotItem.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EvidenceSnapshotItem extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'measured_at' => 'datetime',
|
||||
'freshness_at' => 'datetime',
|
||||
'summary_payload' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function snapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class, 'evidence_snapshot_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
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
|
||||
@ -98,6 +99,14 @@ 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>
|
||||
*/
|
||||
@ -160,10 +169,15 @@ public function hasOpenStatus(): bool
|
||||
return self::isOpenStatus($this->status);
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): void
|
||||
public function isRiskAccepted(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): self
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
@ -173,29 +187,38 @@ public function acknowledge(User $user): void
|
||||
]);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resolve the finding.
|
||||
* @param array<string, mixed> $evidence
|
||||
*/
|
||||
public function resolve(string $reason): void
|
||||
public function reopen(array $evidence): self
|
||||
{
|
||||
$this->status = self::STATUS_RESOLVED;
|
||||
$this->resolved_at = now();
|
||||
$this->resolved_reason = $reason;
|
||||
$this->save();
|
||||
}
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_NEW,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Re-open a resolved finding.
|
||||
*/
|
||||
public function reopen(array $evidence): void
|
||||
{
|
||||
$this->status = self::STATUS_NEW;
|
||||
$this->resolved_at = null;
|
||||
$this->resolved_reason = null;
|
||||
$this->evidence_jsonb = $evidence;
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolvedSubjectDisplayName(): ?string
|
||||
|
||||
243
app/Models/FindingException.php
Normal file
243
app/Models/FindingException.php
Normal file
@ -0,0 +1,243 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
71
app/Models/FindingExceptionDecision.php
Normal file
71
app/Models/FindingExceptionDecision.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
45
app/Models/FindingExceptionEvidenceReference.php
Normal file
45
app/Models/FindingExceptionEvidenceReference.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -71,6 +71,22 @@ public function initiator(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function evidenceSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TenantReview, $this>
|
||||
*/
|
||||
public function tenantReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantReview::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
|
||||
@ -261,6 +261,21 @@ 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);
|
||||
|
||||
195
app/Models/TenantReview.php
Normal file
195
app/Models/TenantReview.php
Normal file
@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use App\Support\TenantReviewStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TenantReview extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'summary' => 'array',
|
||||
'generated_at' => 'datetime',
|
||||
'published_at' => 'datetime',
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function evidenceSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<OperationRun, $this>
|
||||
*/
|
||||
public function operationRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OperationRun::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function publisher(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'published_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<ReviewPack, $this>
|
||||
*/
|
||||
public function currentExportReviewPack(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ReviewPack::class, 'current_export_review_pack_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<self, $this>
|
||||
*/
|
||||
public function supersededByReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'superseded_by_review_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<self, $this>
|
||||
*/
|
||||
public function supersededReviews(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'superseded_by_review_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<TenantReviewSection, $this>
|
||||
*/
|
||||
public function sections(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantReviewSection::class)->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ReviewPack, $this>
|
||||
*/
|
||||
public function reviewPacks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ReviewPack::class)->latest('generated_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
||||
{
|
||||
return $query->where('workspace_id', $workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', TenantReviewStatus::Published->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeMutable(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
TenantReviewStatus::Draft->value,
|
||||
TenantReviewStatus::Ready->value,
|
||||
TenantReviewStatus::Failed->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function statusEnum(): TenantReviewStatus
|
||||
{
|
||||
return TenantReviewStatus::from((string) $this->status);
|
||||
}
|
||||
|
||||
public function completenessEnum(): TenantReviewCompletenessState
|
||||
{
|
||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this->statusEnum()->isPublished();
|
||||
}
|
||||
|
||||
public function isMutable(): bool
|
||||
{
|
||||
return $this->statusEnum()->isMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function publishBlockers(): array
|
||||
{
|
||||
$summary = is_array($this->summary) ? $this->summary : [];
|
||||
$blockers = $summary['publish_blockers'] ?? [];
|
||||
|
||||
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
||||
}
|
||||
}
|
||||
70
app/Models/TenantReviewSection.php
Normal file
70
app/Models/TenantReviewSection.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantReviewSection extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'summary_payload' => 'array',
|
||||
'render_payload' => 'array',
|
||||
'measured_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<TenantReview, $this>
|
||||
*/
|
||||
public function tenantReview(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TenantReview::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Workspace, $this>
|
||||
*/
|
||||
public function workspace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Workspace::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeRequired(Builder $query): Builder
|
||||
{
|
||||
return $query->where('required', true);
|
||||
}
|
||||
|
||||
public function completenessEnum(): TenantReviewCompletenessState
|
||||
{
|
||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? TenantReviewCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,10 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BackupSchedulePolicy
|
||||
@ -15,7 +18,7 @@ class BackupSchedulePolicy
|
||||
|
||||
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
||||
{
|
||||
$tenant ??= Tenant::current();
|
||||
$tenant ??= $this->resolvedTenant();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
||||
@ -26,58 +29,74 @@ public function viewAny(User $user): bool
|
||||
return $this->isTenantMember($user);
|
||||
}
|
||||
|
||||
public function view(User $user, BackupSchedule $schedule): bool
|
||||
public function view(User $user, BackupSchedule $schedule): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $this->isTenantMember($user, $tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return (int) $schedule->tenant_id === (int) $tenant->getKey();
|
||||
return (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
? true
|
||||
: Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
}
|
||||
|
||||
public function update(User $user, BackupSchedule $schedule): bool
|
||||
public function update(User $user, BackupSchedule $schedule): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, BackupSchedule $schedule): bool
|
||||
public function delete(User $user, BackupSchedule $schedule): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public function restore(User $user, BackupSchedule $schedule): bool
|
||||
public function restore(User $user, BackupSchedule $schedule): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, BackupSchedule $schedule): bool
|
||||
public function forceDelete(User $user, BackupSchedule $schedule): Response|bool
|
||||
{
|
||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_DELETE);
|
||||
}
|
||||
|
||||
protected function authorizeScheduleAction(User $user, BackupSchedule $schedule, string $capability): Response|bool
|
||||
{
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $this->isTenantMember($user, $tenant)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $tenant instanceof Tenant || (int) $schedule->tenant_id !== (int) $tenant->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows($capability, $tenant)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
protected function resolvedTenant(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant);
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class EntraGroupPolicy
|
||||
{
|
||||
@ -24,25 +25,29 @@ public function viewAny(User $user): bool
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, EntraGroup $group): bool
|
||||
public function view(User $user, EntraGroup $group): Response|bool
|
||||
{
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return (int) $group->tenant_id === (int) $tenant->getKey();
|
||||
if ((int) $group->tenant_id !== (int) $tenant->getKey()) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolvedTenant(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
69
app/Policies/EvidenceSnapshotPolicy.php
Normal file
69
app/Policies/EvidenceSnapshotPolicy.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class EvidenceSnapshotPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, EvidenceSnapshot $snapshot): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, EvidenceSnapshot $snapshot): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
}
|
||||
}
|
||||
138
app/Policies/FindingExceptionPolicy.php
Normal file
138
app/Policies/FindingExceptionPolicy.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\FindingException;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class FindingExceptionPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, FindingException $exception): Response|bool
|
||||
{
|
||||
$tenant = $this->authorizedTenantOrNull($user, $exception);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
||||
}
|
||||
|
||||
public function approve(User $user, FindingException $exception): Response|bool
|
||||
{
|
||||
return $this->authorizeCanonicalApproval($user, $exception);
|
||||
}
|
||||
|
||||
public function reject(User $user, FindingException $exception): Response|bool
|
||||
{
|
||||
return $this->authorizeCanonicalApproval($user, $exception);
|
||||
}
|
||||
|
||||
private function authorizeCanonicalApproval(User $user, FindingException $exception): Response|bool
|
||||
{
|
||||
$tenant = $exception->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId) || $workspaceId !== (int) $exception->workspace_id) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
/** @var WorkspaceCapabilityResolver $resolver */
|
||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||
|
||||
if (! $resolver->isMember($user, $workspace)) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
private function authorizedTenantOrNull(User $user, FindingException $exception): ?Tenant
|
||||
{
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $exception->tenant_id !== (int) $tenant->getKey()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function resolvedTenant(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
||||
|
||||
if (! is_int($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
return $tenant instanceof Tenant && (int) $tenant->workspace_id === $workspaceId ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,10 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class FindingPolicy
|
||||
{
|
||||
@ -15,7 +18,7 @@ class FindingPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
@ -28,31 +31,23 @@ public function viewAny(User $user): bool
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, Finding $finding): bool
|
||||
public function view(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = $this->authorizedTenantOrNull($user, $finding);
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
||||
}
|
||||
|
||||
public function update(User $user, Finding $finding): bool
|
||||
public function update(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->triage($user, $finding);
|
||||
}
|
||||
|
||||
public function triage(User $user, Finding $finding): bool
|
||||
public function triage(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithAnyCapability($user, $finding, [
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
@ -60,32 +55,32 @@ public function triage(User $user, Finding $finding): bool
|
||||
]);
|
||||
}
|
||||
|
||||
public function assign(User $user, Finding $finding): bool
|
||||
public function assign(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_ASSIGN);
|
||||
}
|
||||
|
||||
public function resolve(User $user, Finding $finding): bool
|
||||
public function resolve(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RESOLVE);
|
||||
}
|
||||
|
||||
public function close(User $user, Finding $finding): bool
|
||||
public function close(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_CLOSE);
|
||||
}
|
||||
|
||||
public function riskAccept(User $user, Finding $finding): bool
|
||||
public function riskAccept(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RISK_ACCEPT);
|
||||
}
|
||||
|
||||
public function reopen(User $user, Finding $finding): bool
|
||||
public function reopen(User $user, Finding $finding): Response|bool
|
||||
{
|
||||
return $this->triage($user, $finding);
|
||||
}
|
||||
|
||||
private function canMutateWithCapability(User $user, Finding $finding, string $capability): bool
|
||||
private function canMutateWithCapability(User $user, Finding $finding, string $capability): Response|bool
|
||||
{
|
||||
return $this->canMutateWithAnyCapability($user, $finding, [$capability]);
|
||||
}
|
||||
@ -93,20 +88,12 @@ private function canMutateWithCapability(User $user, Finding $finding, string $c
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
*/
|
||||
private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): bool
|
||||
private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): Response|bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = $this->authorizedTenantOrNull($user, $finding);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
@ -118,6 +105,42 @@ private function canMutateWithAnyCapability(User $user, Finding $finding, array
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return Response::deny();
|
||||
}
|
||||
|
||||
private function authorizedTenantOrNull(User $user, Finding $finding): ?Tenant
|
||||
{
|
||||
$tenant = $this->resolvedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function resolvedTenant(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
}
|
||||
|
||||
110
app/Policies/TenantReviewPolicy.php
Normal file
110
app/Policies/TenantReviewPolicy.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class TenantReviewPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE);
|
||||
}
|
||||
|
||||
public function refresh(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function publish(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function archive(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function export(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
public function createNextReview(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
return $this->authorizeManageAction($user, $review);
|
||||
}
|
||||
|
||||
private function authorizeManageAction(User $user, TenantReview $review): Response|bool
|
||||
{
|
||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE)
|
||||
? true
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
private function authorizedTenantOrNull(User $user, TenantReview $review): ?Tenant
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $review->workspace_id !== (int) $tenant->workspace_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceSetting;
|
||||
@ -17,6 +18,7 @@
|
||||
use App\Policies\AlertRulePolicy;
|
||||
use App\Policies\ProviderConnectionPolicy;
|
||||
use App\Policies\TenantOnboardingSessionPolicy;
|
||||
use App\Policies\TenantReviewPolicy;
|
||||
use App\Policies\WorkspaceSettingPolicy;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
@ -30,6 +32,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
protected $policies = [
|
||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||
TenantReview::class => TenantReviewPolicy::class,
|
||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||
AlertDestination::class => AlertDestinationPolicy::class,
|
||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||
|
||||
@ -6,7 +6,9 @@
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||
use App\Filament\Pages\NoAccess;
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Filament\Pages\WorkspaceOverview;
|
||||
@ -171,12 +173,15 @@ public function panel(Panel $panel): Panel
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
FindingExceptionsQueue::class,
|
||||
ReviewRegister::class,
|
||||
])
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->unsavedChangesAlerts()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@ -34,6 +34,7 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||
use Filament\Facades\Filament;
|
||||
@ -76,6 +77,9 @@ public function panel(Panel $panel): Panel
|
||||
: ''
|
||||
)
|
||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||
->resources([
|
||||
TenantReviewResource::class,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
@ -87,6 +91,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
||||
@ -36,19 +36,42 @@ public function record(
|
||||
): AuditLog {
|
||||
$actionValue = $action instanceof AuditActionId ? $action->value : trim($action);
|
||||
|
||||
return AuditLog::query()->create(
|
||||
$this->builder->buildRecordAttributes(
|
||||
action: $actionValue,
|
||||
context: $context,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
target: $target,
|
||||
outcome: $outcome,
|
||||
recordedAt: $recordedAt,
|
||||
summary: $summary,
|
||||
operationRunId: $operationRunId,
|
||||
),
|
||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
||||
$dedupeKey = is_string($metadata['_dedupe_key'] ?? null) ? trim((string) $metadata['_dedupe_key']) : null;
|
||||
|
||||
if ($dedupeKey !== '') {
|
||||
$metadata['_dedupe_key'] = $dedupeKey;
|
||||
$context['metadata'] = $metadata;
|
||||
}
|
||||
|
||||
$attributes = $this->builder->buildRecordAttributes(
|
||||
action: $actionValue,
|
||||
context: $context,
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
target: $target,
|
||||
outcome: $outcome,
|
||||
recordedAt: $recordedAt,
|
||||
summary: $summary,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
|
||||
if ($dedupeKey !== null && $dedupeKey !== '') {
|
||||
$existing = AuditLog::query()
|
||||
->where('tenant_id', $attributes['tenant_id'])
|
||||
->where('action', $attributes['action'])
|
||||
->where('resource_type', $attributes['resource_type'])
|
||||
->where('resource_id', $attributes['resource_id'])
|
||||
->whereRaw("metadata ->> '_dedupe_key' = ?", [$dedupeKey])
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof AuditLog) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return AuditLog::query()->create($attributes);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
@ -50,6 +52,10 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -64,6 +70,8 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
@ -84,6 +92,10 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::TENANT_REVIEW_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -93,6 +105,7 @@ class RoleCapabilityMap
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
@ -107,11 +120,14 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
Capabilities::TENANT_VIEW,
|
||||
Capabilities::TENANT_FINDINGS_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
||||
|
||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||
@ -123,6 +139,8 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::TENANT_REVIEW_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Manager->value => [
|
||||
@ -63,6 +64,7 @@ class WorkspaceRoleCapabilityMap
|
||||
Capabilities::WORKSPACE_BASELINES_VIEW,
|
||||
Capabilities::WORKSPACE_BASELINES_MANAGE,
|
||||
Capabilities::AUDIT_VIEW,
|
||||
Capabilities::FINDING_EXCEPTION_APPROVE,
|
||||
],
|
||||
|
||||
WorkspaceRole::Operator->value => [
|
||||
|
||||
@ -8,14 +8,17 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class BaselineAutoCloseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsResolver $settingsResolver,
|
||||
private readonly ?FindingWorkflowService $findingWorkflowService = null,
|
||||
) {}
|
||||
|
||||
public function shouldAutoClose(Tenant $tenant, OperationRun $run): bool
|
||||
@ -73,7 +76,7 @@ public function resolveStaleFindings(
|
||||
int $currentOperationRunId,
|
||||
): int {
|
||||
$scopeKey = 'baseline_profile:'.$baselineProfileId;
|
||||
$resolvedAt = now();
|
||||
$resolvedAt = CarbonImmutable::now();
|
||||
$resolvedCount = 0;
|
||||
|
||||
$query = Finding::query()
|
||||
@ -88,18 +91,22 @@ public function resolveStaleFindings(
|
||||
$query->whereNotIn('fingerprint', array_values(array_unique($seenFingerprints)));
|
||||
}
|
||||
|
||||
$query->chunkById(100, function ($findings) use (&$resolvedCount, $resolvedAt, $currentOperationRunId): void {
|
||||
$query->chunkById(100, function ($findings) use ($tenant, &$resolvedCount, $resolvedAt, $currentOperationRunId): void {
|
||||
foreach ($findings as $finding) {
|
||||
if (! $finding instanceof Finding) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $resolvedAt,
|
||||
'resolved_reason' => 'no_longer_drifting',
|
||||
'current_operation_run_id' => $currentOperationRunId,
|
||||
])->save();
|
||||
$this->findingWorkflowService()->resolveBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reason: 'no_longer_drifting',
|
||||
resolvedAt: $resolvedAt,
|
||||
operationRunId: $currentOperationRunId,
|
||||
mutate: function (Finding $record) use ($currentOperationRunId): void {
|
||||
$record->current_operation_run_id = $currentOperationRunId;
|
||||
},
|
||||
);
|
||||
|
||||
$resolvedCount++;
|
||||
}
|
||||
@ -118,4 +125,9 @@ private function resolveWorkspace(Tenant $tenant): ?Workspace
|
||||
|
||||
return Workspace::query()->whereKey($workspaceId)->first();
|
||||
}
|
||||
|
||||
private function findingWorkflowService(): FindingWorkflowService
|
||||
{
|
||||
return $this->findingWorkflowService ?? app(FindingWorkflowService::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
final class EntraAdminRolesFindingGenerator
|
||||
@ -21,6 +22,7 @@ final class EntraAdminRolesFindingGenerator
|
||||
public function __construct(
|
||||
private readonly HighPrivilegeRoleCatalog $catalog,
|
||||
private readonly ?FindingSlaPolicy $slaPolicy = null,
|
||||
private readonly ?FindingWorkflowService $findingWorkflowService = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -173,19 +175,14 @@ private function upsertFinding(
|
||||
$resolvedAt = $existing->resolved_at;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
||||
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||
$existing->save();
|
||||
|
||||
$existing->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),
|
||||
])->save();
|
||||
$this->findingWorkflowService()->reopenBySystem(
|
||||
finding: $existing,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: $operationRun?->getKey(),
|
||||
);
|
||||
|
||||
return 'reopened';
|
||||
}
|
||||
@ -268,22 +265,19 @@ private function handleGaAggregate(
|
||||
$resolvedAt = $existing->resolved_at;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
||||
$slaDays = $slaPolicy->daysForSeverity(Finding::SEVERITY_HIGH, $tenant);
|
||||
$existing->save();
|
||||
|
||||
$existing->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(Finding::SEVERITY_HIGH, $tenant, $observedAt),
|
||||
]);
|
||||
$this->findingWorkflowService()->reopenBySystem(
|
||||
finding: $existing,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: $operationRun?->getKey(),
|
||||
);
|
||||
|
||||
$reopened++;
|
||||
$this->produceAlertEvent($tenant, $gaFingerprint, $evidence);
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
@ -322,11 +316,12 @@ private function handleGaAggregate(
|
||||
->first();
|
||||
|
||||
if ($existing instanceof Finding) {
|
||||
$existing->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $observedAt,
|
||||
'resolved_reason' => 'ga_count_within_threshold',
|
||||
])->save();
|
||||
$this->findingWorkflowService()->resolveBySystem(
|
||||
finding: $existing,
|
||||
tenant: $tenant,
|
||||
reason: 'ga_count_within_threshold',
|
||||
resolvedAt: $observedAt,
|
||||
);
|
||||
$resolved++;
|
||||
}
|
||||
}
|
||||
@ -353,11 +348,12 @@ private function resolveStaleFindings(Tenant $tenant, array $currentFingerprints
|
||||
continue;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $observedAt,
|
||||
'resolved_reason' => 'role_assignment_removed',
|
||||
])->save();
|
||||
$this->findingWorkflowService()->resolveBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reason: 'role_assignment_removed',
|
||||
resolvedAt: $observedAt,
|
||||
);
|
||||
$resolved++;
|
||||
}
|
||||
|
||||
@ -463,4 +459,9 @@ private function resolvePrincipalType(array $principal): string
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private function findingWorkflowService(): FindingWorkflowService
|
||||
{
|
||||
return $this->findingWorkflowService ?? app(FindingWorkflowService::class);
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Services/Evidence/Contracts/EvidenceSourceProvider.php
Normal file
30
app/Services/Evidence/Contracts/EvidenceSourceProvider.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Contracts;
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
interface EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* dimension_key: string,
|
||||
* state: string,
|
||||
* required: bool,
|
||||
* source_kind: string,
|
||||
* source_record_type: ?string,
|
||||
* source_record_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* measured_at: ?\DateTimeInterface,
|
||||
* freshness_at: ?\DateTimeInterface,
|
||||
* summary_payload: array<string, mixed>,
|
||||
* fingerprint_payload: array<string, mixed>,
|
||||
* sort_order: int
|
||||
* }
|
||||
*/
|
||||
public function collect(Tenant $tenant): array;
|
||||
}
|
||||
42
app/Services/Evidence/EvidenceCompletenessEvaluator.php
Normal file
42
app/Services/Evidence/EvidenceCompletenessEvaluator.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessEvaluator
|
||||
{
|
||||
/**
|
||||
* @param list<array{state: string, required: bool}> $items
|
||||
*/
|
||||
public function evaluate(array $items): EvidenceCompletenessState
|
||||
{
|
||||
$requiredItems = array_values(array_filter($items, static fn (array $item): bool => $item['required'] === true));
|
||||
|
||||
if ($requiredItems === []) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Missing->value) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Stale->value) {
|
||||
return EvidenceCompletenessState::Stale;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Partial->value) {
|
||||
return EvidenceCompletenessState::Partial;
|
||||
}
|
||||
}
|
||||
|
||||
return EvidenceCompletenessState::Complete;
|
||||
}
|
||||
}
|
||||
19
app/Services/Evidence/EvidenceResolutionRequest.php
Normal file
19
app/Services/Evidence/EvidenceResolutionRequest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceResolutionRequest
|
||||
{
|
||||
/**
|
||||
* @param list<string> $requiredDimensions
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
public readonly ?int $snapshotId = null,
|
||||
public readonly array $requiredDimensions = [],
|
||||
public readonly bool $allowStale = false,
|
||||
) {}
|
||||
}
|
||||
47
app/Services/Evidence/EvidenceResolutionResult.php
Normal file
47
app/Services/Evidence/EvidenceResolutionResult.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
|
||||
final class EvidenceResolutionResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $eligibleDimensions
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $outcome,
|
||||
public readonly ?EvidenceSnapshot $snapshot,
|
||||
public readonly array $eligibleDimensions = [],
|
||||
public readonly array $reasons = [],
|
||||
) {}
|
||||
|
||||
public static function resolved(EvidenceSnapshot $snapshot, array $eligibleDimensions): self
|
||||
{
|
||||
return new self('resolved', $snapshot, $eligibleDimensions, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function missingSnapshot(array $reasons = []): self
|
||||
{
|
||||
return new self('missing_snapshot', null, [], $reasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function snapshotIneligible(EvidenceSnapshot $snapshot, array $reasons): self
|
||||
{
|
||||
return new self('snapshot_ineligible', $snapshot, [], $reasons);
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->outcome === 'resolved' && $this->snapshot instanceof EvidenceSnapshot;
|
||||
}
|
||||
}
|
||||
35
app/Services/Evidence/EvidenceSnapshotFingerprint.php
Normal file
35
app/Services/Evidence/EvidenceSnapshotFingerprint.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceSnapshotFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function hash(array $payload): string
|
||||
{
|
||||
return hash('sha256', json_encode(self::normalize($payload), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private static function normalize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
ksort($value);
|
||||
|
||||
return array_map(self::normalize(...), $value);
|
||||
}
|
||||
|
||||
if ($value instanceof \BackedEnum) {
|
||||
return $value->value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
62
app/Services/Evidence/EvidenceSnapshotResolver.php
Normal file
62
app/Services/Evidence/EvidenceSnapshotResolver.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceSnapshotResolver
|
||||
{
|
||||
public function resolve(EvidenceResolutionRequest $request): EvidenceResolutionResult
|
||||
{
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('items')
|
||||
->where('workspace_id', $request->workspaceId)
|
||||
->where('tenant_id', $request->tenantId)
|
||||
->where('status', 'active')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->latest('generated_at');
|
||||
|
||||
if ($request->snapshotId !== null) {
|
||||
$query->whereKey($request->snapshotId);
|
||||
}
|
||||
|
||||
$snapshot = $query->first();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return EvidenceResolutionResult::missingSnapshot(['No active snapshot found.']);
|
||||
}
|
||||
|
||||
$requiredDimensions = $request->requiredDimensions;
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$reasons = [];
|
||||
|
||||
foreach ($requiredDimensions as $dimension) {
|
||||
$item = $items->get($dimension);
|
||||
|
||||
if ($item === null) {
|
||||
$reasons[] = sprintf('Missing dimension: %s', $dimension);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $item->state === EvidenceCompletenessState::Missing->value) {
|
||||
$reasons[] = sprintf('Missing dimension: %s', $dimension);
|
||||
}
|
||||
|
||||
if (! $request->allowStale && (string) $item->state === EvidenceCompletenessState::Stale->value) {
|
||||
$reasons[] = sprintf('Stale dimension: %s', $dimension);
|
||||
}
|
||||
}
|
||||
|
||||
if ($reasons !== []) {
|
||||
return EvidenceResolutionResult::snapshotIneligible($snapshot, $reasons);
|
||||
}
|
||||
|
||||
return EvidenceResolutionResult::resolved($snapshot, $requiredDimensions === [] ? $items->keys()->all() : $requiredDimensions);
|
||||
}
|
||||
}
|
||||
269
app/Services/Evidence/EvidenceSnapshotService.php
Normal file
269
app/Services/Evidence/EvidenceSnapshotService.php
Normal file
@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
|
||||
use App\Services\Evidence\Sources\EntraAdminRolesSource;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\Evidence\Sources\OperationsSummarySource;
|
||||
use App\Services\Evidence\Sources\PermissionPostureSource;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EvidenceSnapshotService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $operationRuns,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly EvidenceCompletenessEvaluator $completenessEvaluator,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, User $user, bool $allowStale = false): EvidenceSnapshot
|
||||
{
|
||||
$fingerprint = $this->computeFingerprint($tenant);
|
||||
$existing = $this->findExistingSnapshot($tenant, $fingerprint);
|
||||
|
||||
if ($existing instanceof EvidenceSnapshot) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'allow_stale' => $allowStale,
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'status' => EvidenceSnapshotStatus::Queued->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Missing->value,
|
||||
'summary' => [
|
||||
'allow_stale' => $allowStale,
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRuns->dispatchOrFail($operationRun, function () use ($snapshot, $operationRun): void {
|
||||
GenerateEvidenceSnapshotJob::dispatch(
|
||||
snapshotId: (int) $snapshot->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotCreated,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'status' => EvidenceSnapshotStatus::Queued->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $snapshot->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function refresh(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
{
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Snapshot tenant could not be resolved.');
|
||||
}
|
||||
|
||||
$refreshed = $this->generate($tenant, $user);
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotRefreshed,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'previous_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'new_snapshot_id' => (int) $refreshed->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $refreshed->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $refreshed->getKey()),
|
||||
operationRunId: $refreshed->operation_run_id,
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
{
|
||||
$snapshot->forceFill([
|
||||
'status' => EvidenceSnapshotStatus::Expired->value,
|
||||
'expires_at' => now(),
|
||||
])->save();
|
||||
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotExpired,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'before_status' => EvidenceSnapshotStatus::Active->value,
|
||||
'after_status' => EvidenceSnapshotStatus::Expired->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $snapshot->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EvidenceSourceProvider>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
app(FindingsSummarySource::class),
|
||||
app(PermissionPostureSource::class),
|
||||
app(EntraAdminRolesSource::class),
|
||||
app(BaselineDriftPostureSource::class),
|
||||
app(OperationsSummarySource::class),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: list<array<string, mixed>>, fingerprint: string, completeness: string, summary: array<string, mixed>}
|
||||
*/
|
||||
public function buildSnapshotPayload(Tenant $tenant): array
|
||||
{
|
||||
$items = [];
|
||||
$fingerprintPayload = [];
|
||||
|
||||
foreach ($this->providers() as $provider) {
|
||||
$item = $provider->collect($tenant);
|
||||
$items[] = $item;
|
||||
$fingerprintPayload[$provider->key()] = $item['fingerprint_payload'];
|
||||
}
|
||||
|
||||
$completeness = $this->completenessEvaluator->evaluate(array_map(
|
||||
static fn (array $item): array => [
|
||||
'state' => (string) $item['state'],
|
||||
'required' => (bool) $item['required'],
|
||||
],
|
||||
$items,
|
||||
));
|
||||
$itemsByKey = collect($items)->keyBy('dimension_key');
|
||||
$findingsSummary = is_array($itemsByKey->get('findings_summary')['summary_payload'] ?? null)
|
||||
? $itemsByKey->get('findings_summary')['summary_payload']
|
||||
: [];
|
||||
$operationsSummary = is_array($itemsByKey->get('operations_summary')['summary_payload'] ?? null)
|
||||
? $itemsByKey->get('operations_summary')['summary_payload']
|
||||
: [];
|
||||
|
||||
$summary = [
|
||||
'dimension_count' => count($items),
|
||||
'finding_count' => (int) ($findingsSummary['count'] ?? 0),
|
||||
'report_count' => count(array_filter($items, static fn (array $item): bool => in_array($item['dimension_key'], ['permission_posture', 'entra_admin_roles'], true) && $item['source_record_id'] !== null)),
|
||||
'operation_count' => (int) ($operationsSummary['operation_count'] ?? 0),
|
||||
'missing_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Missing->value)),
|
||||
'stale_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Stale->value)),
|
||||
'dimensions' => array_map(static fn (array $item): array => [
|
||||
'key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
], $items),
|
||||
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
||||
? $findingsSummary['risk_acceptance']
|
||||
: [
|
||||
'status_marked_count' => 0,
|
||||
'valid_governed_count' => 0,
|
||||
'warning_count' => 0,
|
||||
'expired_count' => 0,
|
||||
'revoked_count' => 0,
|
||||
'missing_exception_count' => 0,
|
||||
],
|
||||
'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,
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'fingerprint' => EvidenceSnapshotFingerprint::hash($fingerprintPayload),
|
||||
'completeness' => $completeness->value,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
public function computeFingerprint(Tenant $tenant): string
|
||||
{
|
||||
return $this->buildSnapshotPayload($tenant)['fingerprint'];
|
||||
}
|
||||
|
||||
public function checkActiveRun(Tenant $tenant): bool
|
||||
{
|
||||
return $this->operationRuns->findCanonicalRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'fingerprint' => $this->computeFingerprint($tenant),
|
||||
],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
private function findExistingSnapshot(Tenant $tenant, string $fingerprint): ?EvidenceSnapshot
|
||||
{
|
||||
return EvidenceSnapshot::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
57
app/Services/Evidence/Sources/BaselineDriftPostureSource.php
Normal file
57
app/Services/Evidence/Sources/BaselineDriftPostureSource.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class BaselineDriftPostureSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'baseline_drift_posture';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
|
||||
$isStale = $latest !== null && $latest->lt(now()->subDays(30));
|
||||
|
||||
$state = match (true) {
|
||||
$findings->isEmpty() => EvidenceCompletenessState::Missing->value,
|
||||
$isStale => EvidenceCompletenessState::Stale->value,
|
||||
default => EvidenceCompletenessState::Complete->value,
|
||||
};
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $state,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => Finding::class,
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => $findings->max('fingerprint'),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => [
|
||||
'drift_count' => $findings->count(),
|
||||
'open_drift_count' => $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count(),
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'latest' => $latest?->format(DATE_ATOM),
|
||||
'count' => $findings->count(),
|
||||
],
|
||||
'sort_order' => 40,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Services/Evidence/Sources/EntraAdminRolesSource.php
Normal file
51
app/Services/Evidence/Sources/EntraAdminRolesSource.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EntraAdminRolesSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'entra_admin_roles';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$payload = is_array($report?->payload) ? $report->payload : [];
|
||||
$roles = is_array($payload['roles'] ?? null) ? $payload['roles'] : [];
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $report instanceof StoredReport ? EvidenceCompletenessState::Complete->value : EvidenceCompletenessState::Missing->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'stored_report',
|
||||
'source_record_type' => StoredReport::class,
|
||||
'source_record_id' => $report instanceof StoredReport ? (string) $report->getKey() : null,
|
||||
'source_fingerprint' => $report?->fingerprint,
|
||||
'measured_at' => $report?->updated_at,
|
||||
'freshness_at' => $report?->updated_at,
|
||||
'summary_payload' => [
|
||||
'role_count' => count($roles),
|
||||
'roles' => $roles,
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'fingerprint' => $report?->fingerprint,
|
||||
'role_count' => count($roles),
|
||||
],
|
||||
'sort_order' => 30,
|
||||
];
|
||||
}
|
||||
}
|
||||
99
app/Services/Evidence/Sources/FindingsSummarySource.php
Normal file
99
app/Services/Evidence/Sources/FindingsSummarySource.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'findings_summary';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->with('findingException.currentDecision')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
|
||||
$entries = $findings->map(function (Finding $finding): array {
|
||||
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
||||
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
||||
|
||||
return [
|
||||
'id' => (int) $finding->getKey(),
|
||||
'finding_type' => (string) $finding->finding_type,
|
||||
'severity' => (string) $finding->severity,
|
||||
'status' => (string) $finding->status,
|
||||
'title' => $finding->title,
|
||||
'description' => $finding->description,
|
||||
'created_at' => $finding->created_at?->toIso8601String(),
|
||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||
'governance_state' => $governanceState,
|
||||
'governance_warning' => $governanceWarning,
|
||||
];
|
||||
});
|
||||
$riskAcceptedEntries = $entries->filter(
|
||||
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
||||
);
|
||||
$warningStates = [
|
||||
'expired_exception',
|
||||
'revoked_exception',
|
||||
'rejected_exception',
|
||||
'risk_accepted_without_valid_exception',
|
||||
];
|
||||
|
||||
$summary = [
|
||||
'count' => $findings->count(),
|
||||
'open_count' => $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count(),
|
||||
'severity_counts' => [
|
||||
'critical' => $findings->where('severity', Finding::SEVERITY_CRITICAL)->count(),
|
||||
'high' => $findings->where('severity', Finding::SEVERITY_HIGH)->count(),
|
||||
'medium' => $findings->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
||||
'low' => $findings->where('severity', Finding::SEVERITY_LOW)->count(),
|
||||
],
|
||||
'risk_acceptance' => [
|
||||
'status_marked_count' => $riskAcceptedEntries->count(),
|
||||
'valid_governed_count' => $riskAcceptedEntries->filter(
|
||||
static fn (array $entry): bool => in_array($entry['governance_state'] ?? null, ['valid_exception', 'expiring_exception'], true),
|
||||
)->count(),
|
||||
'warning_count' => $riskAcceptedEntries->filter(
|
||||
static fn (array $entry): bool => in_array($entry['governance_state'] ?? null, $warningStates, true),
|
||||
)->count(),
|
||||
'expired_count' => $riskAcceptedEntries->where('governance_state', 'expired_exception')->count(),
|
||||
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
|
||||
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
|
||||
],
|
||||
'entries' => $entries->all(),
|
||||
];
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $findings->isEmpty() ? EvidenceCompletenessState::Missing->value : EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => 'finding',
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => $findings->max('fingerprint'),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => $summary,
|
||||
'fingerprint_payload' => $summary + ['latest' => $latest?->format(DATE_ATOM)],
|
||||
'sort_order' => 10,
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Services/Evidence/Sources/OperationsSummarySource.php
Normal file
62
app/Services/Evidence/Sources/OperationsSummarySource.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
|
||||
final class OperationsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'operations_summary';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->latest('created_at')
|
||||
->get();
|
||||
|
||||
$latest = $runs->max('created_at');
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $runs->isEmpty() ? EvidenceCompletenessState::Missing->value : EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'operation_rollup',
|
||||
'source_record_type' => OperationRun::class,
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => hash('sha256', implode('|', $runs->pluck('run_identity_hash')->all())),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => [
|
||||
'operation_count' => $runs->count(),
|
||||
'failed_count' => $runs->where('outcome', OperationRunOutcome::Failed->value)->count(),
|
||||
'partial_count' => $runs->where('outcome', OperationRunOutcome::PartiallySucceeded->value)->count(),
|
||||
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => $run->user?->name,
|
||||
'started_at' => $run->started_at?->toIso8601String(),
|
||||
'completed_at' => $run->completed_at?->toIso8601String(),
|
||||
])->all(),
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'count' => $runs->count(),
|
||||
'latest' => $latest?->format(DATE_ATOM),
|
||||
'hashes' => $runs->pluck('run_identity_hash')->values()->all(),
|
||||
],
|
||||
'sort_order' => 50,
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Services/Evidence/Sources/PermissionPostureSource.php
Normal file
61
app/Services/Evidence/Sources/PermissionPostureSource.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class PermissionPostureSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'permission_posture';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$payload = is_array($report?->payload) ? $report->payload : [];
|
||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||
|
||||
$state = match (true) {
|
||||
! $report instanceof StoredReport => EvidenceCompletenessState::Missing->value,
|
||||
$requiredCount > 0 && $grantedCount < $requiredCount => EvidenceCompletenessState::Partial->value,
|
||||
default => EvidenceCompletenessState::Complete->value,
|
||||
};
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $state,
|
||||
'required' => true,
|
||||
'source_kind' => 'stored_report',
|
||||
'source_record_type' => $report instanceof StoredReport ? StoredReport::class : StoredReport::class,
|
||||
'source_record_id' => $report instanceof StoredReport ? (string) $report->getKey() : null,
|
||||
'source_fingerprint' => $report?->fingerprint,
|
||||
'measured_at' => $report?->updated_at,
|
||||
'freshness_at' => $report?->updated_at,
|
||||
'summary_payload' => [
|
||||
'posture_score' => $payload['posture_score'] ?? null,
|
||||
'required_count' => $requiredCount,
|
||||
'granted_count' => $grantedCount,
|
||||
'payload' => $payload,
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'fingerprint' => $report?->fingerprint,
|
||||
'required_count' => $requiredCount,
|
||||
'granted_count' => $grantedCount,
|
||||
],
|
||||
'sort_order' => 20,
|
||||
];
|
||||
}
|
||||
}
|
||||
916
app/Services/Findings/FindingExceptionService.php
Normal file
916
app/Services/Findings/FindingExceptionService.php
Normal file
@ -0,0 +1,916 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final class FindingExceptionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private readonly FindingWorkflowService $findingWorkflowService,
|
||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* owner_user_id?: mixed,
|
||||
* request_reason?: mixed,
|
||||
* review_due_at?: mixed,
|
||||
* expires_at?: mixed,
|
||||
* evidence_references?: mixed
|
||||
* } $payload
|
||||
*/
|
||||
public function request(Finding $finding, Tenant $tenant, User $actor, array $payload): FindingException
|
||||
{
|
||||
$this->authorizeRequest($finding, $tenant, $actor);
|
||||
|
||||
$ownerUserId = $this->validatedTenantMemberId(
|
||||
tenant: $tenant,
|
||||
userId: $payload['owner_user_id'] ?? null,
|
||||
field: 'owner_user_id',
|
||||
required: true,
|
||||
);
|
||||
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
|
||||
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
|
||||
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
|
||||
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
|
||||
$requestedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $exception */
|
||||
$exception = DB::transaction(function () use ($finding, $tenant, $actor, $ownerUserId, $requestReason, $reviewDueAt, $expiresAt, $evidenceReferences, $requestedAt): FindingException {
|
||||
$exception = FindingException::query()
|
||||
->where('finding_id', (int) $finding->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($exception instanceof FindingException && $exception->isPending()) {
|
||||
throw new InvalidArgumentException('An exception request is already pending for this finding.');
|
||||
}
|
||||
|
||||
if ($exception instanceof FindingException && $exception->isActiveLike()) {
|
||||
throw new InvalidArgumentException('This finding already has an active exception.');
|
||||
}
|
||||
|
||||
$exception ??= new FindingException([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
]);
|
||||
|
||||
$before = $this->exceptionSnapshot($exception);
|
||||
|
||||
$exception->fill([
|
||||
'requested_by_user_id' => (int) $actor->getKey(),
|
||||
'owner_user_id' => $ownerUserId,
|
||||
'approved_by_user_id' => null,
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
'request_reason' => $requestReason,
|
||||
'approval_reason' => null,
|
||||
'rejection_reason' => null,
|
||||
'revocation_reason' => null,
|
||||
'requested_at' => $requestedAt,
|
||||
'approved_at' => null,
|
||||
'rejected_at' => null,
|
||||
'revoked_at' => null,
|
||||
'effective_from' => null,
|
||||
'expires_at' => $expiresAt,
|
||||
'review_due_at' => $reviewDueAt,
|
||||
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
|
||||
]);
|
||||
$exception->save();
|
||||
|
||||
$this->replaceEvidenceReferences($exception, $evidenceReferences);
|
||||
|
||||
$decision = $exception->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
|
||||
'reason' => $requestReason,
|
||||
'expires_at' => $expiresAt,
|
||||
'metadata' => [
|
||||
'review_due_at' => $reviewDueAt->toIso8601String(),
|
||||
'evidence_reference_count' => count($evidenceReferences),
|
||||
],
|
||||
'decided_at' => $requestedAt,
|
||||
]);
|
||||
|
||||
$exception->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
|
||||
$after = $this->exceptionSnapshot($exception->fresh($this->exceptionRelationships()) ?? $exception);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::FindingExceptionRequested,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding_exception',
|
||||
resourceId: (string) $exception->getKey(),
|
||||
targetLabel: 'Finding exception #'.$exception->getKey(),
|
||||
context: [
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $exception;
|
||||
});
|
||||
|
||||
return $this->governanceResolver->syncExceptionState(
|
||||
$exception->fresh($this->exceptionRelationships()) ?? $exception,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* effective_from?: mixed,
|
||||
* expires_at?: mixed,
|
||||
* approval_reason?: mixed
|
||||
* } $payload
|
||||
*/
|
||||
public function approve(FindingException $exception, User $actor, array $payload): FindingException
|
||||
{
|
||||
$tenant = $this->tenantForException($exception);
|
||||
$workspace = $this->workspaceForTenant($tenant);
|
||||
|
||||
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
|
||||
|
||||
$effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
|
||||
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
|
||||
$approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason');
|
||||
$approvedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $approvedException */
|
||||
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException {
|
||||
/** @var FindingException $lockedException */
|
||||
$lockedException = FindingException::query()
|
||||
->with(['finding', 'tenant', 'requester', 'currentDecision'])
|
||||
->whereKey((int) $exception->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! $lockedException->isPending()) {
|
||||
throw new InvalidArgumentException('Only pending exception requests can be approved.');
|
||||
}
|
||||
|
||||
if ((int) $lockedException->requested_by_user_id === (int) $actor->getKey()) {
|
||||
throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
|
||||
}
|
||||
|
||||
$isRenewalApproval = $lockedException->isPendingRenewal();
|
||||
$before = $this->exceptionSnapshot($lockedException);
|
||||
|
||||
$lockedException->fill([
|
||||
'status' => FindingException::STATUS_ACTIVE,
|
||||
'current_validity_state' => FindingException::VALIDITY_VALID,
|
||||
'approved_by_user_id' => (int) $actor->getKey(),
|
||||
'approval_reason' => $approvalReason,
|
||||
'approved_at' => $approvedAt,
|
||||
'effective_from' => $effectiveFrom,
|
||||
'expires_at' => $expiresAt,
|
||||
'rejection_reason' => null,
|
||||
'rejected_at' => null,
|
||||
'revocation_reason' => null,
|
||||
]);
|
||||
$lockedException->save();
|
||||
|
||||
$decision = $lockedException->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => $isRenewalApproval
|
||||
? FindingExceptionDecision::TYPE_RENEWED
|
||||
: FindingExceptionDecision::TYPE_APPROVED,
|
||||
'reason' => $approvalReason,
|
||||
'effective_from' => $effectiveFrom,
|
||||
'expires_at' => $expiresAt,
|
||||
'metadata' => [
|
||||
'request_type' => $isRenewalApproval ? 'renewal' : 'initial',
|
||||
],
|
||||
'decided_at' => $approvedAt,
|
||||
]);
|
||||
|
||||
$lockedException->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
|
||||
$finding = $lockedException->finding;
|
||||
|
||||
if (! $finding instanceof Finding) {
|
||||
throw new InvalidArgumentException('The linked finding could not be resolved.');
|
||||
}
|
||||
|
||||
if (! $isRenewalApproval) {
|
||||
$this->findingWorkflowService->riskAcceptFromException(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason),
|
||||
);
|
||||
}
|
||||
|
||||
$resolvedException = $this->governanceResolver->syncExceptionState(
|
||||
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
|
||||
);
|
||||
|
||||
$after = $this->exceptionSnapshot($resolvedException);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $isRenewalApproval
|
||||
? AuditActionId::FindingExceptionRenewed
|
||||
: AuditActionId::FindingExceptionApproved,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding_exception',
|
||||
resourceId: (string) $resolvedException->getKey(),
|
||||
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
|
||||
context: [
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'decision_type' => $isRenewalApproval
|
||||
? FindingExceptionDecision::TYPE_RENEWED
|
||||
: FindingExceptionDecision::TYPE_APPROVED,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $resolvedException;
|
||||
});
|
||||
|
||||
return $approvedException;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* rejection_reason?: mixed
|
||||
* } $payload
|
||||
*/
|
||||
public function reject(FindingException $exception, User $actor, array $payload): FindingException
|
||||
{
|
||||
$tenant = $this->tenantForException($exception);
|
||||
$workspace = $this->workspaceForTenant($tenant);
|
||||
|
||||
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
|
||||
|
||||
$rejectionReason = $this->validatedReason($payload['rejection_reason'] ?? null, 'rejection_reason');
|
||||
$rejectedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $rejectedException */
|
||||
$rejectedException = DB::transaction(function () use ($exception, $tenant, $actor, $rejectionReason, $rejectedAt): FindingException {
|
||||
/** @var FindingException $lockedException */
|
||||
$lockedException = FindingException::query()
|
||||
->with(['finding', 'currentDecision'])
|
||||
->whereKey((int) $exception->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! $lockedException->isPending()) {
|
||||
throw new InvalidArgumentException('Only pending exception requests can be rejected.');
|
||||
}
|
||||
|
||||
$isRenewalRejection = $lockedException->isPendingRenewal();
|
||||
$before = $this->exceptionSnapshot($lockedException);
|
||||
|
||||
if ($isRenewalRejection) {
|
||||
$lockedException->fill([
|
||||
'status' => FindingException::STATUS_ACTIVE,
|
||||
'rejection_reason' => $rejectionReason,
|
||||
'rejected_at' => $rejectedAt,
|
||||
'review_due_at' => $this->metadataDate($lockedException, 'previous_review_due_at') ?? $lockedException->review_due_at,
|
||||
]);
|
||||
} else {
|
||||
$lockedException->fill([
|
||||
'status' => FindingException::STATUS_REJECTED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REJECTED,
|
||||
'rejection_reason' => $rejectionReason,
|
||||
'rejected_at' => $rejectedAt,
|
||||
'approved_by_user_id' => null,
|
||||
'approved_at' => null,
|
||||
'approval_reason' => null,
|
||||
'effective_from' => null,
|
||||
]);
|
||||
}
|
||||
$lockedException->save();
|
||||
|
||||
$decision = $lockedException->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
|
||||
'reason' => $rejectionReason,
|
||||
'metadata' => [
|
||||
'request_type' => $isRenewalRejection ? 'renewal' : 'initial',
|
||||
],
|
||||
'decided_at' => $rejectedAt,
|
||||
]);
|
||||
|
||||
$lockedException->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
|
||||
$resolvedException = $this->governanceResolver->syncExceptionState(
|
||||
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
|
||||
);
|
||||
|
||||
$after = $this->exceptionSnapshot($resolvedException);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::FindingExceptionRejected,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding_exception',
|
||||
resourceId: (string) $resolvedException->getKey(),
|
||||
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
|
||||
context: [
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $resolvedException->finding_id,
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $resolvedException;
|
||||
});
|
||||
|
||||
return $rejectedException;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* owner_user_id?: mixed,
|
||||
* request_reason?: mixed,
|
||||
* review_due_at?: mixed,
|
||||
* expires_at?: mixed,
|
||||
* evidence_references?: mixed
|
||||
* } $payload
|
||||
*/
|
||||
public function renew(FindingException $exception, User $actor, array $payload): FindingException
|
||||
{
|
||||
$tenant = $this->tenantForException($exception);
|
||||
|
||||
$this->authorizeManagement($exception, $tenant, $actor);
|
||||
|
||||
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
|
||||
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
|
||||
$requestedExpiry = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
|
||||
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
|
||||
$requestedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $renewedException */
|
||||
$renewedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $requestReason, $reviewDueAt, $requestedExpiry, $evidenceReferences, $requestedAt): FindingException {
|
||||
/** @var FindingException $lockedException */
|
||||
$lockedException = FindingException::query()
|
||||
->with(['currentDecision', 'finding'])
|
||||
->whereKey((int) $exception->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! $lockedException->canBeRenewed()) {
|
||||
throw new InvalidArgumentException('Only active, expiring, or expired exceptions can be renewed.');
|
||||
}
|
||||
|
||||
$ownerUserId = array_key_exists('owner_user_id', $payload)
|
||||
? $this->validatedTenantMemberId($tenant, $payload['owner_user_id'], 'owner_user_id')
|
||||
: (is_numeric($lockedException->owner_user_id) ? (int) $lockedException->owner_user_id : null);
|
||||
|
||||
$before = $this->exceptionSnapshot($lockedException);
|
||||
|
||||
$lockedException->fill([
|
||||
'requested_by_user_id' => (int) $actor->getKey(),
|
||||
'owner_user_id' => $ownerUserId,
|
||||
'status' => FindingException::STATUS_PENDING,
|
||||
'request_reason' => $requestReason,
|
||||
'requested_at' => $requestedAt,
|
||||
'review_due_at' => $reviewDueAt,
|
||||
'rejection_reason' => null,
|
||||
'rejected_at' => null,
|
||||
'revocation_reason' => null,
|
||||
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
|
||||
]);
|
||||
$lockedException->save();
|
||||
|
||||
$this->replaceEvidenceReferences($lockedException, $evidenceReferences);
|
||||
|
||||
$decision = $lockedException->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
|
||||
'reason' => $requestReason,
|
||||
'expires_at' => $requestedExpiry,
|
||||
'metadata' => [
|
||||
'review_due_at' => $reviewDueAt->toIso8601String(),
|
||||
'requested_expires_at' => $requestedExpiry?->toIso8601String(),
|
||||
'previous_review_due_at' => $lockedException->getOriginal('review_due_at'),
|
||||
'previous_expires_at' => $lockedException->getOriginal('expires_at'),
|
||||
'evidence_reference_count' => count($evidenceReferences),
|
||||
],
|
||||
'decided_at' => $requestedAt,
|
||||
]);
|
||||
|
||||
$lockedException->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
|
||||
$resolvedException = $this->governanceResolver->syncExceptionState(
|
||||
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
|
||||
);
|
||||
|
||||
$after = $this->exceptionSnapshot($resolvedException);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::FindingExceptionRenewalRequested,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding_exception',
|
||||
resourceId: (string) $resolvedException->getKey(),
|
||||
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
|
||||
context: [
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $resolvedException->finding_id,
|
||||
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $resolvedException;
|
||||
});
|
||||
|
||||
return $renewedException;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* revocation_reason?: mixed
|
||||
* } $payload
|
||||
*/
|
||||
public function revoke(FindingException $exception, User $actor, array $payload): FindingException
|
||||
{
|
||||
$tenant = $this->tenantForException($exception);
|
||||
|
||||
$this->authorizeManagement($exception, $tenant, $actor);
|
||||
|
||||
$revocationReason = $this->validatedReason($payload['revocation_reason'] ?? null, 'revocation_reason');
|
||||
$revokedAt = CarbonImmutable::now();
|
||||
|
||||
/** @var FindingException $revokedException */
|
||||
$revokedException = DB::transaction(function () use ($exception, $tenant, $actor, $revocationReason, $revokedAt): FindingException {
|
||||
/** @var FindingException $lockedException */
|
||||
$lockedException = FindingException::query()
|
||||
->with(['currentDecision', 'finding'])
|
||||
->whereKey((int) $exception->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if (! $lockedException->canBeRevoked()) {
|
||||
throw new InvalidArgumentException('Only active or pending-renewal exceptions can be revoked.');
|
||||
}
|
||||
|
||||
$before = $this->exceptionSnapshot($lockedException);
|
||||
|
||||
$lockedException->fill([
|
||||
'status' => FindingException::STATUS_REVOKED,
|
||||
'current_validity_state' => FindingException::VALIDITY_REVOKED,
|
||||
'revocation_reason' => $revocationReason,
|
||||
'revoked_at' => $revokedAt,
|
||||
]);
|
||||
$lockedException->save();
|
||||
|
||||
$decision = $lockedException->decisions()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
|
||||
'reason' => $revocationReason,
|
||||
'metadata' => [],
|
||||
'decided_at' => $revokedAt,
|
||||
]);
|
||||
|
||||
$lockedException->forceFill([
|
||||
'current_decision_id' => (int) $decision->getKey(),
|
||||
])->save();
|
||||
|
||||
$resolvedException = $this->governanceResolver->syncExceptionState(
|
||||
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
|
||||
);
|
||||
|
||||
$after = $this->exceptionSnapshot($resolvedException);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: AuditActionId::FindingExceptionRevoked,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding_exception',
|
||||
resourceId: (string) $resolvedException->getKey(),
|
||||
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
|
||||
context: [
|
||||
'metadata' => [
|
||||
'finding_id' => (int) $resolvedException->finding_id,
|
||||
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return $resolvedException;
|
||||
});
|
||||
|
||||
return $revokedException;
|
||||
}
|
||||
|
||||
private function authorizeRequest(Finding $finding, Tenant $tenant, User $actor): void
|
||||
{
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthorizationException('Missing capability for exception request.');
|
||||
}
|
||||
|
||||
private function authorizeApproval(FindingException $exception, Tenant $tenant, Workspace $workspace, User $actor): void
|
||||
{
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $exception->workspace_id !== (int) $workspace->getKey() || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthorizationException('Missing capability for exception approval.');
|
||||
}
|
||||
|
||||
private function authorizeManagement(FindingException $exception, Tenant $tenant, User $actor): void
|
||||
{
|
||||
if (! $actor->canAccessTenant($tenant)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthorizationException('Missing capability for exception management.');
|
||||
}
|
||||
|
||||
private function tenantForException(FindingException $exception): Tenant
|
||||
{
|
||||
$tenant = $exception->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenant = Tenant::query()->findOrFail((int) $exception->tenant_id);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function workspaceForTenant(Tenant $tenant): Workspace
|
||||
{
|
||||
$workspace = $tenant->workspace;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
$workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
return $workspace;
|
||||
}
|
||||
|
||||
private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): void
|
||||
{
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int
|
||||
{
|
||||
if ($userId === null || $userId === '') {
|
||||
if ($required) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_numeric($userId) || (int) $userId <= 0) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field));
|
||||
}
|
||||
|
||||
$resolvedUserId = (int) $userId;
|
||||
|
||||
$isMember = TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('user_id', $resolvedUserId)
|
||||
->exists();
|
||||
|
||||
if (! $isMember) {
|
||||
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field));
|
||||
}
|
||||
|
||||
return $resolvedUserId;
|
||||
}
|
||||
|
||||
private function validatedReason(mixed $reason, string $field): string
|
||||
{
|
||||
if (! is_string($reason)) {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
$resolved = trim($reason);
|
||||
|
||||
if ($resolved === '') {
|
||||
throw new InvalidArgumentException(sprintf('%s is required.', $field));
|
||||
}
|
||||
|
||||
if (mb_strlen($resolved) > 2000) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
private function validatedOptionalReason(mixed $reason, string $field): ?string
|
||||
{
|
||||
if ($reason === null || $reason === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->validatedReason($reason, $field);
|
||||
}
|
||||
|
||||
private function validatedDate(mixed $value, string $field): CarbonImmutable
|
||||
{
|
||||
try {
|
||||
return CarbonImmutable::parse((string) $value);
|
||||
} catch (\Throwable) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field));
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedFutureDate(mixed $value, string $field): CarbonImmutable
|
||||
{
|
||||
$date = $this->validatedDate($value, $field);
|
||||
|
||||
if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
|
||||
throw new InvalidArgumentException(sprintf('%s must be in the future.', $field));
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, bool $required = false): ?CarbonImmutable
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
if ($required) {
|
||||
throw new InvalidArgumentException('expires_at is required.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiresAt = $this->validatedDate($value, 'expires_at');
|
||||
|
||||
if ($expiresAt->lessThanOrEqualTo($minimum)) {
|
||||
throw new InvalidArgumentException('expires_at must be after the related review or effective date.');
|
||||
}
|
||||
|
||||
return $expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* source_type: string,
|
||||
* source_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* label: string,
|
||||
* measured_at: ?CarbonImmutable,
|
||||
* summary_payload: array<string, mixed>
|
||||
* }>
|
||||
*/
|
||||
private function validatedEvidenceReferences(mixed $references): array
|
||||
{
|
||||
if (! is_array($references)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
|
||||
foreach ($references as $reference) {
|
||||
if (! is_array($reference)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceType = trim((string) ($reference['source_type'] ?? ''));
|
||||
$label = trim((string) ($reference['label'] ?? ''));
|
||||
|
||||
if ($sourceType === '' || $label === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$measuredAt = null;
|
||||
|
||||
if (($reference['measured_at'] ?? null) !== null && (string) $reference['measured_at'] !== '') {
|
||||
$measuredAt = $this->validatedDate($reference['measured_at'], 'measured_at');
|
||||
}
|
||||
|
||||
$resolved[] = [
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => filled($reference['source_id'] ?? null) ? trim((string) $reference['source_id']) : null,
|
||||
'source_fingerprint' => filled($reference['source_fingerprint'] ?? null) ? trim((string) $reference['source_fingerprint']) : null,
|
||||
'label' => mb_substr($label, 0, 255),
|
||||
'measured_at' => $measuredAt,
|
||||
'summary_payload' => is_array($reference['summary_payload'] ?? null) ? $reference['summary_payload'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{
|
||||
* source_type: string,
|
||||
* source_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* label: string,
|
||||
* measured_at: ?CarbonImmutable,
|
||||
* summary_payload: array<string, mixed>
|
||||
* }> $references
|
||||
*/
|
||||
private function replaceEvidenceReferences(FindingException $exception, array $references): void
|
||||
{
|
||||
$exception->evidenceReferences()->delete();
|
||||
|
||||
foreach ($references as $reference) {
|
||||
$exception->evidenceReferences()->create([
|
||||
'workspace_id' => (int) $exception->workspace_id,
|
||||
'tenant_id' => (int) $exception->tenant_id,
|
||||
'source_type' => $reference['source_type'],
|
||||
'source_id' => $reference['source_id'],
|
||||
'source_fingerprint' => $reference['source_fingerprint'],
|
||||
'label' => $reference['label'],
|
||||
'measured_at' => $reference['measured_at'],
|
||||
'summary_payload' => $reference['summary_payload'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{
|
||||
* source_type: string,
|
||||
* source_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* label: string,
|
||||
* measured_at: ?CarbonImmutable,
|
||||
* summary_payload: array<string, mixed>
|
||||
* }> $references
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function evidenceSummary(array $references): array
|
||||
{
|
||||
return [
|
||||
'reference_count' => count($references),
|
||||
'labels' => array_values(array_map(
|
||||
static fn (array $reference): string => $reference['label'],
|
||||
array_slice($references, 0, 5),
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string
|
||||
{
|
||||
if (is_string($approvalReason) && $approvalReason !== '') {
|
||||
return mb_substr($approvalReason, 0, 255);
|
||||
}
|
||||
|
||||
return 'Governed by approved exception #'.$exception->getKey();
|
||||
}
|
||||
|
||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||
{
|
||||
$currentDecision = $exception->relationLoaded('currentDecision')
|
||||
? $exception->currentDecision
|
||||
: $exception->currentDecision()->first();
|
||||
|
||||
if (! $currentDecision instanceof FindingExceptionDecision) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $currentDecision->metadata[$key] ?? null;
|
||||
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CarbonImmutable::parse($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function exceptionSnapshot(FindingException $exception): array
|
||||
{
|
||||
return [
|
||||
'status' => $exception->status,
|
||||
'current_validity_state' => $exception->current_validity_state,
|
||||
'current_decision_type' => $exception->currentDecisionType(),
|
||||
'finding_id' => $exception->finding_id,
|
||||
'requested_by_user_id' => $exception->requested_by_user_id,
|
||||
'owner_user_id' => $exception->owner_user_id,
|
||||
'approved_by_user_id' => $exception->approved_by_user_id,
|
||||
'requested_at' => $exception->requested_at?->toIso8601String(),
|
||||
'approved_at' => $exception->approved_at?->toIso8601String(),
|
||||
'rejected_at' => $exception->rejected_at?->toIso8601String(),
|
||||
'revoked_at' => $exception->revoked_at?->toIso8601String(),
|
||||
'effective_from' => $exception->effective_from?->toIso8601String(),
|
||||
'expires_at' => $exception->expires_at?->toIso8601String(),
|
||||
'review_due_at' => $exception->review_due_at?->toIso8601String(),
|
||||
'request_reason' => $exception->request_reason,
|
||||
'approval_reason' => $exception->approval_reason,
|
||||
'rejection_reason' => $exception->rejection_reason,
|
||||
'revocation_reason' => $exception->revocation_reason,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string|array<int|string, mixed>>
|
||||
*/
|
||||
private function exceptionRelationships(): array
|
||||
{
|
||||
return [
|
||||
'finding',
|
||||
'tenant',
|
||||
'requester',
|
||||
'owner',
|
||||
'approver',
|
||||
'currentDecision',
|
||||
'decisions.actor',
|
||||
'evidenceReferences',
|
||||
];
|
||||
}
|
||||
}
|
||||
230
app/Services/Findings/FindingRiskGovernanceResolver.php
Normal file
230
app/Services/Findings/FindingRiskGovernanceResolver.php
Normal file
@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class FindingRiskGovernanceResolver
|
||||
{
|
||||
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
$now ??= CarbonImmutable::instance(now());
|
||||
|
||||
$status = (string) $exception->status;
|
||||
|
||||
if (in_array($status, [
|
||||
FindingException::STATUS_REJECTED,
|
||||
FindingException::STATUS_REVOKED,
|
||||
FindingException::STATUS_SUPERSEDED,
|
||||
], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
if ($status === FindingException::STATUS_PENDING) {
|
||||
return FindingException::STATUS_PENDING;
|
||||
}
|
||||
|
||||
$expiresAt = $exception->expires_at instanceof Carbon
|
||||
? CarbonImmutable::instance($exception->expires_at)
|
||||
: null;
|
||||
|
||||
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
|
||||
return FindingException::STATUS_EXPIRED;
|
||||
}
|
||||
|
||||
if ($this->isExpiring($exception, $now)) {
|
||||
return FindingException::STATUS_EXPIRING;
|
||||
}
|
||||
|
||||
return FindingException::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function resolveValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
if ($exception->isPendingRenewal()) {
|
||||
return $this->resolveApprovedValidityState($exception, $now);
|
||||
}
|
||||
|
||||
return match ($this->resolveExceptionStatus($exception, $now)) {
|
||||
FindingException::STATUS_ACTIVE => FindingException::VALIDITY_VALID,
|
||||
FindingException::STATUS_EXPIRING => FindingException::VALIDITY_EXPIRING,
|
||||
FindingException::STATUS_EXPIRED => FindingException::VALIDITY_EXPIRED,
|
||||
FindingException::STATUS_REVOKED => FindingException::VALIDITY_REVOKED,
|
||||
FindingException::STATUS_REJECTED => FindingException::VALIDITY_REJECTED,
|
||||
default => FindingException::VALIDITY_MISSING_SUPPORT,
|
||||
};
|
||||
}
|
||||
|
||||
public function resolveFindingState(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
$exception ??= $finding->relationLoaded('findingException')
|
||||
? $finding->findingException
|
||||
: $finding->findingException()->first();
|
||||
|
||||
$findingIsRiskAccepted = $finding->isRiskAccepted();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return $findingIsRiskAccepted
|
||||
? 'risk_accepted_without_valid_exception'
|
||||
: 'ungoverned';
|
||||
}
|
||||
|
||||
if (! $findingIsRiskAccepted) {
|
||||
return $exception->isPending()
|
||||
? 'pending_exception'
|
||||
: 'ungoverned';
|
||||
}
|
||||
|
||||
if ($exception->isPendingRenewal()) {
|
||||
return match ($this->resolveApprovedValidityState($exception, $now)) {
|
||||
FindingException::VALIDITY_VALID => 'valid_exception',
|
||||
FindingException::VALIDITY_EXPIRING => 'expiring_exception',
|
||||
FindingException::VALIDITY_EXPIRED => 'expired_exception',
|
||||
default => 'pending_exception',
|
||||
};
|
||||
}
|
||||
|
||||
return match ($this->resolveExceptionStatus($exception, $now)) {
|
||||
FindingException::STATUS_PENDING => 'pending_exception',
|
||||
FindingException::STATUS_ACTIVE => 'valid_exception',
|
||||
FindingException::STATUS_EXPIRING => 'expiring_exception',
|
||||
FindingException::STATUS_EXPIRED => 'expired_exception',
|
||||
FindingException::STATUS_REVOKED => 'revoked_exception',
|
||||
FindingException::STATUS_REJECTED => 'rejected_exception',
|
||||
default => $findingIsRiskAccepted
|
||||
? 'risk_accepted_without_valid_exception'
|
||||
: 'ungoverned',
|
||||
};
|
||||
}
|
||||
|
||||
public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
|
||||
{
|
||||
return in_array($this->resolveFindingState($finding, $exception, $now), [
|
||||
'valid_exception',
|
||||
'expiring_exception',
|
||||
], true);
|
||||
}
|
||||
|
||||
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
|
||||
{
|
||||
$exception ??= $finding->relationLoaded('findingException')
|
||||
? $finding->findingException
|
||||
: $finding->findingException()->first();
|
||||
|
||||
if (! $exception instanceof FindingException) {
|
||||
return $finding->isRiskAccepted()
|
||||
? 'This finding is marked as accepted risk without a valid exception record.'
|
||||
: null;
|
||||
}
|
||||
|
||||
$exceptionStatus = $exception->isPendingRenewal()
|
||||
? match ($this->resolveApprovedValidityState($exception, $now)) {
|
||||
FindingException::VALIDITY_EXPIRED => FindingException::STATUS_EXPIRED,
|
||||
FindingException::VALIDITY_EXPIRING => FindingException::STATUS_EXPIRING,
|
||||
FindingException::VALIDITY_VALID => FindingException::STATUS_ACTIVE,
|
||||
default => FindingException::STATUS_PENDING,
|
||||
}
|
||||
: $this->resolveExceptionStatus($exception, $now);
|
||||
|
||||
if ($finding->isRiskAccepted()) {
|
||||
return match ($this->resolveFindingState($finding, $exception, $now)) {
|
||||
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
|
||||
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
|
||||
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($exception->requiresFreshDecisionForFinding($finding)) {
|
||||
return 'This finding changed after the earlier exception decision; a fresh decision is required.';
|
||||
}
|
||||
|
||||
return match ($exceptionStatus) {
|
||||
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
|
||||
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
|
||||
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
|
||||
{
|
||||
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
|
||||
$resolvedValidityState = $this->resolveValidityState($exception, $now);
|
||||
|
||||
if ((string) $exception->status === $resolvedStatus && (string) $exception->current_validity_state === $resolvedValidityState) {
|
||||
return $exception;
|
||||
}
|
||||
|
||||
$exception->forceFill([
|
||||
'status' => $resolvedStatus,
|
||||
'current_validity_state' => $resolvedValidityState,
|
||||
])->save();
|
||||
|
||||
return $exception->refresh();
|
||||
}
|
||||
|
||||
private function resolveApprovedValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
|
||||
{
|
||||
$now ??= CarbonImmutable::instance(now());
|
||||
|
||||
$expiresAt = $this->renewalAwareDate(
|
||||
$exception,
|
||||
'previous_expires_at',
|
||||
$exception->expires_at,
|
||||
);
|
||||
|
||||
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
|
||||
return FindingException::VALIDITY_EXPIRED;
|
||||
}
|
||||
|
||||
if ($this->isExpiring($exception, $now, renewalAware: true)) {
|
||||
return FindingException::VALIDITY_EXPIRING;
|
||||
}
|
||||
|
||||
return FindingException::VALIDITY_VALID;
|
||||
}
|
||||
|
||||
private function isExpiring(FindingException $exception, CarbonImmutable $now, bool $renewalAware = false): bool
|
||||
{
|
||||
$reviewDueAt = $renewalAware
|
||||
? $this->renewalAwareDate($exception, 'previous_review_due_at', $exception->review_due_at)
|
||||
: ($exception->review_due_at instanceof Carbon ? CarbonImmutable::instance($exception->review_due_at) : null);
|
||||
|
||||
if ($reviewDueAt instanceof CarbonImmutable && $reviewDueAt->lessThanOrEqualTo($now)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$expiresAt = $renewalAware
|
||||
? $this->renewalAwareDate($exception, 'previous_expires_at', $exception->expires_at)
|
||||
: ($exception->expires_at instanceof Carbon ? CarbonImmutable::instance($exception->expires_at) : null);
|
||||
|
||||
if (! $expiresAt instanceof CarbonImmutable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $expiresAt->lessThanOrEqualTo($now->addDays(7));
|
||||
}
|
||||
|
||||
private function renewalAwareDate(FindingException $exception, string $metadataKey, mixed $fallback): ?CarbonImmutable
|
||||
{
|
||||
$currentDecision = $exception->relationLoaded('currentDecision')
|
||||
? $exception->currentDecision
|
||||
: $exception->currentDecision()->first();
|
||||
|
||||
if ($currentDecision instanceof FindingExceptionDecision && is_string($currentDecision->metadata[$metadataKey] ?? null)) {
|
||||
return CarbonImmutable::parse((string) $currentDecision->metadata[$metadataKey]);
|
||||
}
|
||||
|
||||
return $fallback instanceof Carbon
|
||||
? CarbonImmutable::instance($fallback)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@ -48,7 +50,7 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.triaged',
|
||||
action: AuditActionId::FindingTriaged,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'triaged_at' => $now->toIso8601String(),
|
||||
@ -78,7 +80,7 @@ public function startProgress(Finding $finding, Tenant $tenant, User $actor): Fi
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.in_progress',
|
||||
action: AuditActionId::FindingInProgress,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'in_progress_at' => $now->toIso8601String(),
|
||||
@ -112,7 +114,7 @@ public function assign(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.assigned',
|
||||
action: AuditActionId::FindingAssigned,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'assignee_user_id' => $assigneeUserId,
|
||||
@ -141,7 +143,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.resolved',
|
||||
action: AuditActionId::FindingResolved,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'resolved_reason' => $reason,
|
||||
@ -167,7 +169,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.closed',
|
||||
action: AuditActionId::FindingClosed,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'closed_reason' => $reason,
|
||||
@ -187,6 +189,22 @@ public function riskAccept(Finding $finding, Tenant $tenant, User $actor, string
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RISK_ACCEPT]);
|
||||
|
||||
return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
|
||||
}
|
||||
|
||||
public function riskAcceptFromException(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||
{
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason);
|
||||
}
|
||||
|
||||
private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
|
||||
{
|
||||
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RISK_ACCEPTED) {
|
||||
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
@ -194,7 +212,7 @@ public function riskAccept(Finding $finding, Tenant $tenant, User $actor, string
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.risk_accepted',
|
||||
action: AuditActionId::FindingRiskAccepted,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'closed_reason' => $reason,
|
||||
@ -229,7 +247,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
action: 'finding.reopened',
|
||||
action: AuditActionId::FindingReopened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $now->toIso8601String(),
|
||||
@ -251,6 +269,97 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||
);
|
||||
}
|
||||
|
||||
public function resolveBySystem(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
string $reason,
|
||||
CarbonImmutable $resolvedAt,
|
||||
?int $operationRunId = null,
|
||||
?callable $mutate = null,
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if (! $finding->hasOpenStatus()) {
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: null,
|
||||
action: AuditActionId::FindingResolved,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'resolved_reason' => $reason,
|
||||
'resolved_at' => $resolvedAt->toIso8601String(),
|
||||
'system_origin' => true,
|
||||
],
|
||||
],
|
||||
mutate: function (Finding $record) use ($mutate, $reason, $resolvedAt): void {
|
||||
if ($mutate !== null) {
|
||||
$mutate($record);
|
||||
}
|
||||
|
||||
$record->status = Finding::STATUS_RESOLVED;
|
||||
$record->resolved_reason = $reason;
|
||||
$record->resolved_at = $resolvedAt;
|
||||
},
|
||||
actorType: AuditActorType::System,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
public function reopenBySystem(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
CarbonImmutable $reopenedAt,
|
||||
?int $operationRunId = null,
|
||||
?callable $mutate = null,
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: null,
|
||||
action: AuditActionId::FindingReopened,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $reopenedAt->toIso8601String(),
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $dueAt->toIso8601String(),
|
||||
'system_origin' => true,
|
||||
],
|
||||
],
|
||||
mutate: function (Finding $record) use ($mutate, $reopenedAt, $slaDays, $dueAt): void {
|
||||
if ($mutate !== null) {
|
||||
$mutate($record);
|
||||
}
|
||||
|
||||
$record->status = Finding::STATUS_REOPENED;
|
||||
$record->reopened_at = $reopenedAt;
|
||||
$record->resolved_at = null;
|
||||
$record->resolved_reason = null;
|
||||
$record->closed_at = null;
|
||||
$record->closed_reason = null;
|
||||
$record->closed_by_user_id = null;
|
||||
$record->sla_days = $slaDays;
|
||||
$record->due_at = $dueAt;
|
||||
},
|
||||
actorType: AuditActorType::System,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
*/
|
||||
@ -260,13 +369,7 @@ private function authorize(Finding $finding, Tenant $tenant, User $actor, array
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
foreach ($capabilities as $capability) {
|
||||
if ($this->capabilityResolver->can($actor, $tenant, $capability)) {
|
||||
@ -277,6 +380,17 @@ private function authorize(Finding $finding, Tenant $tenant, User $actor, array
|
||||
throw new AuthorizationException('Missing capability for finding workflow action.');
|
||||
}
|
||||
|
||||
private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): void
|
||||
{
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $field): void
|
||||
{
|
||||
if ($userId === null) {
|
||||
@ -318,41 +432,53 @@ private function validatedReason(string $reason, string $field): string
|
||||
private function mutateAndAudit(
|
||||
Finding $finding,
|
||||
Tenant $tenant,
|
||||
User $actor,
|
||||
string $action,
|
||||
?User $actor,
|
||||
string|AuditActionId $action,
|
||||
array $context,
|
||||
callable $mutate,
|
||||
?AuditActorType $actorType = null,
|
||||
?int $operationRunId = null,
|
||||
): Finding {
|
||||
$before = $this->auditSnapshot($finding);
|
||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
||||
$resolvedFinding = DB::transaction(function () use ($finding, $tenant, $actor, $action, $metadata, $mutate, $actorType, $operationRunId): Finding {
|
||||
/** @var Finding $record */
|
||||
$record = Finding::query()
|
||||
->whereKey($finding->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($finding, $mutate): void {
|
||||
$mutate($finding);
|
||||
$finding->save();
|
||||
$before = $this->auditSnapshot($record);
|
||||
|
||||
$mutate($record);
|
||||
$record->save();
|
||||
|
||||
$after = $this->auditSnapshot($record);
|
||||
$auditMetadata = array_merge($metadata, [
|
||||
'finding_id' => (int) $record->getKey(),
|
||||
'before_status' => $before['status'] ?? null,
|
||||
'after_status' => $after['status'] ?? null,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
actorId: $actor?->getKey() !== null ? (int) $actor->getKey() : null,
|
||||
actorEmail: $actor?->email,
|
||||
actorName: $actor?->name,
|
||||
resourceType: 'finding',
|
||||
resourceId: (string) $record->getKey(),
|
||||
context: ['metadata' => $auditMetadata],
|
||||
actorType: $actorType,
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
|
||||
return $record;
|
||||
});
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
||||
$metadata = array_merge($metadata, [
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'before_status' => $before['status'] ?? null,
|
||||
'after_status' => $finding->status,
|
||||
'before' => $before,
|
||||
'after' => $this->auditSnapshot($finding),
|
||||
]);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
actorId: (int) $actor->getKey(),
|
||||
actorEmail: $actor->email,
|
||||
actorName: $actor->name,
|
||||
resourceType: 'finding',
|
||||
resourceId: (string) $finding->getKey(),
|
||||
context: ['metadata' => $metadata],
|
||||
);
|
||||
|
||||
return $finding;
|
||||
return $resolvedFinding->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -377,4 +503,36 @@ private function auditSnapshot(Finding $finding): array
|
||||
'closed_by_user_id' => $finding->closed_by_user_id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $before
|
||||
* @param array<string, mixed> $after
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
private function dedupeKey(
|
||||
string|AuditActionId $action,
|
||||
Finding $finding,
|
||||
array $before,
|
||||
array $after,
|
||||
array $metadata,
|
||||
?User $actor,
|
||||
?AuditActorType $actorType = null,
|
||||
): string {
|
||||
$payload = [
|
||||
'action' => $action instanceof AuditActionId ? $action->value : $action,
|
||||
'finding_id' => (int) $finding->getKey(),
|
||||
'actor_id' => $actor?->getKey() !== null ? (int) $actor->getKey() : null,
|
||||
'actor_type' => $actorType?->value,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'assignee_user_id' => $metadata['assignee_user_id'] ?? null,
|
||||
'owner_user_id' => $metadata['owner_user_id'] ?? null,
|
||||
'resolved_reason' => $metadata['resolved_reason'] ?? null,
|
||||
'closed_reason' => $metadata['closed_reason'] ?? null,
|
||||
];
|
||||
|
||||
$encoded = json_encode($payload);
|
||||
|
||||
return hash('sha256', is_string($encoded) ? $encoded : serialize($payload));
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,14 @@ public function log(
|
||||
): \App\Models\AuditLog {
|
||||
$workspaceId = is_numeric($tenant->workspace_id) ? (int) $tenant->workspace_id : null;
|
||||
|
||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
||||
|
||||
if ($actorType instanceof AuditActorType) {
|
||||
$metadata['_actor_type'] = $actorType->value;
|
||||
}
|
||||
|
||||
$context['metadata'] = $metadata;
|
||||
|
||||
if ($workspaceId === null) {
|
||||
throw new InvalidArgumentException('Tenant-scoped audit events require tenant workspace_id.');
|
||||
}
|
||||
@ -58,7 +66,12 @@ public function log(
|
||||
),
|
||||
outcome: $status,
|
||||
recordedAt: CarbonImmutable::now(),
|
||||
summary: $summary,
|
||||
summary: $summary ?? AuditActionId::summaryFor(
|
||||
action: $action,
|
||||
targetLabel: $targetLabel,
|
||||
targetType: $resourceType,
|
||||
context: $metadata,
|
||||
),
|
||||
operationRunId: $operationRunId,
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class OnboardingDraftMutationService
|
||||
{
|
||||
@ -100,6 +101,25 @@ public function mutate(
|
||||
});
|
||||
}
|
||||
|
||||
public function lockForTrustedMutation(TenantOnboardingSession|int|string $draft, Workspace $workspace): TenantOnboardingSession
|
||||
{
|
||||
$draftId = $draft instanceof TenantOnboardingSession
|
||||
? (int) $draft->getKey()
|
||||
: (int) $draft;
|
||||
|
||||
$lockedDraft = TenantOnboardingSession::query()
|
||||
->whereKey($draftId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $lockedDraft instanceof TenantOnboardingSession) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $lockedDraft;
|
||||
}
|
||||
|
||||
private function resolveDraftForIdentity(
|
||||
Workspace $workspace,
|
||||
string $entraTenantId,
|
||||
|
||||
@ -71,6 +71,15 @@ public function resolve(TenantOnboardingSession|int|string $draft, User $user, W
|
||||
return $resolvedDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
* @throws NotFoundHttpException
|
||||
*/
|
||||
public function resolveForTrustedAction(TenantOnboardingSession|int|string $draft, User $user, Workspace $workspace): TenantOnboardingSession
|
||||
{
|
||||
return $this->resolve($draft, $user, $workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, TenantOnboardingSession>
|
||||
*/
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
@ -21,6 +22,7 @@ final class PermissionPostureFindingGenerator implements FindingGeneratorContrac
|
||||
public function __construct(
|
||||
private readonly PostureScoreCalculator $scoreCalculator,
|
||||
private readonly FindingSlaPolicy $slaPolicy,
|
||||
private readonly ?FindingWorkflowService $findingWorkflowService = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -146,22 +148,15 @@ private function handleMissingPermission(
|
||||
$resolvedAt = $finding->resolved_at;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
||||
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
||||
|
||||
$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' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
|
||||
$finding->save();
|
||||
|
||||
$this->findingWorkflowService()->reopenBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: $operationRun?->getKey(),
|
||||
);
|
||||
|
||||
return 'reopened';
|
||||
}
|
||||
}
|
||||
@ -229,19 +224,16 @@ private function handleErrorPermission(
|
||||
$resolvedAt = $existing->resolved_at;
|
||||
|
||||
if ($resolvedAt === null || $observedAt->greaterThan(CarbonImmutable::instance($resolvedAt))) {
|
||||
$slaDays = $this->slaPolicy->daysForSeverity($severity, $tenant);
|
||||
$existing->save();
|
||||
|
||||
$existing->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' => $this->slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||
]);
|
||||
$this->findingWorkflowService()->reopenBySystem(
|
||||
finding: $existing,
|
||||
tenant: $tenant,
|
||||
reopenedAt: $observedAt,
|
||||
operationRunId: $operationRun?->getKey(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,11 +279,12 @@ private function resolveExistingFinding(Tenant $tenant, string $key, string $rea
|
||||
return false;
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $observedAt,
|
||||
'resolved_reason' => $reason,
|
||||
])->save();
|
||||
$this->findingWorkflowService()->resolveBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reason: $reason,
|
||||
resolvedAt: $observedAt,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -320,11 +313,12 @@ private function resolveStaleFindings(Tenant $tenant, array $processedPermission
|
||||
}
|
||||
|
||||
if ($permissionKey !== null && ! in_array($permissionKey, $processedPermissionKeys, true)) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $observedAt,
|
||||
'resolved_reason' => 'permission_removed_from_registry',
|
||||
])->save();
|
||||
$this->findingWorkflowService()->resolveBySystem(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
reason: 'permission_removed_from_registry',
|
||||
resolvedAt: $observedAt,
|
||||
);
|
||||
$resolved++;
|
||||
}
|
||||
}
|
||||
@ -473,4 +467,9 @@ private function errorFingerprint(Tenant $tenant, string $permissionKey): string
|
||||
{
|
||||
return substr(hash('sha256', 'permission_posture:'.$tenant->getKey().':'.$permissionKey.':error'), 0, 64);
|
||||
}
|
||||
|
||||
private function findingWorkflowService(): FindingWorkflowService
|
||||
{
|
||||
return $this->findingWorkflowService ?? app(FindingWorkflowService::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,13 +4,18 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantReview;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
@ -19,8 +24,21 @@ class ReviewPackService
|
||||
{
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
private EvidenceSnapshotResolver $snapshotResolver,
|
||||
private WorkspaceAuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const REQUIRED_EVIDENCE_DIMENSIONS = [
|
||||
'findings_summary',
|
||||
'permission_posture',
|
||||
'entra_admin_roles',
|
||||
'baseline_drift_posture',
|
||||
'operations_summary',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
||||
*
|
||||
@ -29,7 +47,8 @@ public function __construct(
|
||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprint($tenant, $options);
|
||||
$snapshot = $this->resolveSnapshot($tenant);
|
||||
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
||||
|
||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
||||
if ($existing instanceof ReviewPack) {
|
||||
@ -42,18 +61,39 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
inputs: [
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated) {
|
||||
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
||||
|
||||
if ($queuedPack instanceof ReviewPack) {
|
||||
return $queuedPack;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [],
|
||||
'summary' => [
|
||||
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
||||
? $snapshot->summary['risk_acceptance']
|
||||
: [],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
@ -66,6 +106,90 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a review-derived executive pack.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function generateFromReview(TenantReview $review, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$review->loadMissing(['tenant', 'evidenceSnapshot', 'sections']);
|
||||
|
||||
$tenant = $review->tenant;
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $snapshot instanceof EvidenceSnapshot) {
|
||||
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
||||
}
|
||||
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
||||
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
||||
|
||||
if ($existing instanceof ReviewPack) {
|
||||
$this->logReviewExport($review, $user, $existing, 'reused');
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::ReviewPackGenerate->value,
|
||||
inputs: [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
if (! $operationRun->wasRecentlyCreated) {
|
||||
$queuedPack = $this->findPackForRun($tenant, $operationRun);
|
||||
|
||||
if ($queuedPack instanceof ReviewPack) {
|
||||
$this->logReviewExport($review, $user, $queuedPack, 'reused_active_run');
|
||||
|
||||
return $queuedPack;
|
||||
}
|
||||
}
|
||||
|
||||
$reviewPack = ReviewPack::create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
GenerateReviewPackJob::dispatch(
|
||||
reviewPackId: (int) $reviewPack->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->logReviewExport($review, $user, $reviewPack, 'queued');
|
||||
|
||||
return $reviewPack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic fingerprint for deduplication.
|
||||
*
|
||||
@ -73,31 +197,7 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
*/
|
||||
public function computeFingerprint(Tenant $tenant, array $options): string
|
||||
{
|
||||
$reportFingerprints = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->orderBy('report_type')
|
||||
->pluck('fingerprint')
|
||||
->toArray();
|
||||
|
||||
$maxFindingDate = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->max('updated_at');
|
||||
|
||||
$data = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
'report_fingerprints' => $reportFingerprints,
|
||||
'max_finding_date' => $maxFindingDate,
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
return $this->computeFingerprintForSnapshot($this->resolveSnapshot($tenant), $this->normalizeOptions($options));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,6 +227,16 @@ public function findExistingPack(Tenant $tenant, string $fingerprint): ?ReviewPa
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findExistingPackForReview(TenantReview $review, string $fingerprint): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->where('tenant_review_id', (int) $review->getKey())
|
||||
->ready()
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('expires_at', '>', now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a generation run is currently active for this tenant.
|
||||
*/
|
||||
@ -139,6 +249,16 @@ public function checkActiveRun(Tenant $tenant): bool
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function checkActiveRunForReview(TenantReview $review): bool
|
||||
{
|
||||
return OperationRun::query()
|
||||
->where('tenant_id', (int) $review->tenant_id)
|
||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
||||
->whereJsonContains('context->tenant_review_id', (int) $review->getKey())
|
||||
->active()
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{include_pii: bool, include_operations: bool}
|
||||
@ -150,4 +270,82 @@ private function normalizeOptions(array $options): array
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
];
|
||||
}
|
||||
|
||||
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
||||
{
|
||||
$data = [
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'evidence_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public function computeFingerprintForReview(TenantReview $review, array $options): string
|
||||
{
|
||||
$data = [
|
||||
'tenant_review_id' => (int) $review->getKey(),
|
||||
'review_fingerprint' => (string) $review->fingerprint,
|
||||
'review_status' => (string) $review->status,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
$result = $this->snapshotResolver->resolve(new EvidenceResolutionRequest(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
requiredDimensions: self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
));
|
||||
|
||||
if (! $result->isResolved()) {
|
||||
throw new ReviewPackEvidenceResolutionException($result);
|
||||
}
|
||||
|
||||
return $result->snapshot;
|
||||
}
|
||||
|
||||
private function findPackForRun(Tenant $tenant, OperationRun $operationRun): ?ReviewPack
|
||||
{
|
||||
return ReviewPack::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('operation_run_id', (int) $operationRun->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function logReviewExport(TenantReview $review, User $user, ReviewPack $reviewPack, string $mode): void
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::TenantReviewExported,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'review_id' => (int) $review->getKey(),
|
||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'mode' => $mode,
|
||||
'status' => (string) $reviewPack->status,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'tenant_review',
|
||||
resourceId: (string) $review->getKey(),
|
||||
targetLabel: sprintf('Tenant review #%d', (int) $review->getKey()),
|
||||
operationRunId: $reviewPack->operation_run_id,
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,4 +33,42 @@ public function ensureAllowed(Tenant $tenant): void
|
||||
'tenant_id' => 'This tenant is not eligible for System runbooks.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveAllowed(int|string|null $tenantId): ?Tenant
|
||||
{
|
||||
if (! is_numeric($tenantId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->ensureAllowed($tenant);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function resolveAllowedOrFail(int|string|null $tenantId): Tenant
|
||||
{
|
||||
if (! is_numeric($tenantId) || (int) $tenantId <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_id' => 'Select a tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->whereKey((int) $tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_id' => 'Select a valid tenant.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->ensureAllowed($tenant);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ final class OperationRunTriageService
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
];
|
||||
|
||||
private const CANCELABLE_TYPES = [
|
||||
@ -33,6 +34,7 @@ final class OperationRunTriageService
|
||||
'rbac.health_check',
|
||||
'entra.admin_roles.scan',
|
||||
'tenant.review_pack.generate',
|
||||
'tenant.review.compose',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
71
app/Services/TenantReviews/TenantReviewComposer.php
Normal file
71
app/Services/TenantReviews/TenantReviewComposer.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\TenantReview;
|
||||
use App\Support\TenantReviewStatus;
|
||||
|
||||
final class TenantReviewComposer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantReviewFingerprint $fingerprint,
|
||||
private readonly TenantReviewSectionFactory $sectionFactory,
|
||||
private readonly TenantReviewReadinessGate $readinessGate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* fingerprint: string,
|
||||
* completeness_state: string,
|
||||
* status: string,
|
||||
* summary: array<string, mixed>,
|
||||
* sections: list<array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null): array
|
||||
{
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if ($tenant === null) {
|
||||
throw new \RuntimeException('Evidence snapshot tenant is required for review composition.');
|
||||
}
|
||||
|
||||
$sections = $this->sectionFactory->make($snapshot);
|
||||
$blockers = $this->readinessGate->blockersForSections($sections);
|
||||
$sectionStateCounts = $this->readinessGate->sectionStateCounts($sections);
|
||||
$completeness = $this->readinessGate->completenessForSections($sections);
|
||||
$status = $this->readinessGate->statusForSections($sections);
|
||||
|
||||
if ($review instanceof TenantReview && $review->isPublished()) {
|
||||
$status = TenantReviewStatus::Published;
|
||||
}
|
||||
|
||||
return [
|
||||
'fingerprint' => $this->fingerprint->forSnapshot($tenant, $snapshot),
|
||||
'completeness_state' => $completeness->value,
|
||||
'status' => $status->value,
|
||||
'summary' => [
|
||||
'evidence_basis' => [
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'snapshot_completeness_state' => (string) $snapshot->completeness_state,
|
||||
'snapshot_generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'section_count' => count($sections),
|
||||
'section_state_counts' => $sectionStateCounts,
|
||||
'publish_blockers' => $blockers,
|
||||
'has_ready_export' => false,
|
||||
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
||||
'report_count' => 2,
|
||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||
'recommended_next_actions' => data_get($sections, '0.render_payload.next_actions', []),
|
||||
'last_composed_at' => now()->toIso8601String(),
|
||||
],
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Services/TenantReviews/TenantReviewFingerprint.php
Normal file
41
app/Services/TenantReviews/TenantReviewFingerprint.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\TenantReviews;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class TenantReviewFingerprint
|
||||
{
|
||||
public function forSnapshot(Tenant $tenant, EvidenceSnapshot $snapshot): string
|
||||
{
|
||||
$summary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||
|
||||
$payload = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'snapshot_completeness' => (string) $snapshot->completeness_state,
|
||||
'dimension_states' => collect(Arr::wrap($summary['dimensions'] ?? []))
|
||||
->map(static fn (mixed $dimension): array => [
|
||||
'key' => (string) data_get($dimension, 'key'),
|
||||
'state' => (string) data_get($dimension, 'state'),
|
||||
'required' => (bool) data_get($dimension, 'required', false),
|
||||
])
|
||||
->sortBy('key')
|
||||
->values()
|
||||
->all(),
|
||||
'counts' => [
|
||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
||||
],
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user