Compare commits

...

6 Commits

Author SHA1 Message Date
a4f2629493 feat: add tenant review layer (#185)
## Summary
- add the tenant review domain with tenant-scoped review library, canonical workspace review register, lifecycle actions, and review-derived executive pack export
- extend review pack, operations, audit, capability, and badge infrastructure to support review composition, publication, export, and recurring review cycles
- add product backlog and audit documentation updates for tenant review and semantic-clarity follow-up candidates

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact --filter="TenantReview"`
- `CI=1 vendor/bin/sail artisan test --compact`

## Notes
- Livewire v4+ compliant via existing Filament v5 stack
- panel providers remain in `bootstrap/providers.php` via existing Laravel 12 structure; no provider registration moved to `bootstrap/app.php`
- `TenantReviewResource` is not globally searchable, so the Filament edit/view global-search constraint does not apply
- destructive review actions use action handlers with confirmation and policy enforcement

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #185
2026-03-21 22:03:01 +00:00
b1e1e06861 feat: implement finding risk acceptance lifecycle (#184)
## Summary
- add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support
- add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament
- add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- CI=1 vendor/bin/sail artisan test --compact
- manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility

## Notes
- Filament implementation remains on v5 with Livewire v4-compatible surfaces
- canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php
- finding exceptions stay out of global search in this rollout

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #184
2026-03-20 01:07:55 +00:00
a74ab12f04 feat: implement evidence domain foundation (#183)
## Summary
- add the Evidence Snapshot domain with immutable tenant-scoped snapshots, per-dimension items, queued generation, audit actions, badge mappings, and Filament list/detail surfaces
- add the workspace evidence overview, capability and policy wiring, Livewire update-path hardening, and review-pack integration through explicit evidence snapshot resolution
- add spec 153 artifacts, migrations, factories, and focused Pest coverage for evidence, review-pack reuse, authorization, action-surface regressions, and audit behavior

## Testing
- `vendor/bin/sail artisan test --compact --stop-on-failure`
- `CI=1 vendor/bin/sail artisan test --compact`
- `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- branch: `153-evidence-domain-foundation`
- commit: `b7dfa279`
- spec: `specs/153-evidence-domain-foundation/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #183
2026-03-19 13:32:52 +00:00
5ec62cd117 feat: harden livewire trusted state boundaries (#182)
## Summary
- add the shared trusted-state model and resolver helpers for first-slice Livewire and Filament surfaces
- harden managed tenant onboarding, tenant required permissions, and system runbooks against forged or stale public state
- add focused Pest guard and regression coverage plus the complete spec 152 artifact set

## Validation
- `vendor/bin/sail artisan test --compact`
- manual smoke validated on `/admin/onboarding/{onboardingDraft}`
- manual smoke validated on `/admin/tenants/{tenant}/required-permissions`
- manual smoke validated on `/system/ops/runbooks`

## Notes
- Livewire v4.0+ / Filament v5 stack unchanged
- no new panels, routes, assets, or global-search changes
- provider registration remains in `bootstrap/providers.php`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #182
2026-03-18 23:01:14 +00:00
ec71c2d4e7 feat: harden findings workflow and audit backstop (#181)
## Summary
- harden finding lifecycle changes behind the canonical `FindingWorkflowService` gateway
- route automated resolve and reopen flows through the same audited workflow path
- tighten tenant and workspace scope checks on finding actions and audit visibility
- add focused spec artifacts, workflow regression coverage, automation coverage, and audit visibility tests
- update legacy finding model tests to use the workflow service after direct lifecycle mutators were removed

## Testing
- `vendor/bin/sail bin pint --dirty --format agent`
- focused findings and audit slices passed during implementation
- `vendor/bin/sail artisan test --compact tests/Feature/Models/FindingResolvedTest.php`
- full repository suite passed: `2757 passed`, `8 skipped`, `14448 assertions`

## Notes
- Livewire v4.0+ compliance preserved
- no new Filament assets or panel providers introduced; provider registration remains in `bootstrap/providers.php`
- findings stay on existing Filament action surfaces, with destructive actions still confirmation-gated
- no global search behavior was changed for findings resources

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #181
2026-03-18 12:57:23 +00:00
1f3619bd16 feat: tenant-owned query canon and wrong-tenant guards (#180)
## Summary
- introduce a shared tenant-owned query and record-resolution canon for first-slice Filament resources
- harden direct views, row actions, bulk actions, relation managers, and workspace-admin canonical viewers against wrong-tenant access
- add registry-backed rollout metadata, search posture handling, architectural guards, and focused Pest coverage for scope parity and 404/403 semantics

## Included
- Spec 150 package under `specs/150-tenant-owned-query-canon-and-wrong-tenant-guards/`
- shared support classes: `TenantOwnedModelFamilies`, `TenantOwnedQueryScope`, `TenantOwnedRecordResolver`
- shared Filament concern: `InteractsWithTenantOwnedRecords`
- resource/page/policy hardening across findings, policies, policy versions, backup schedules, backup sets, restore runs, inventory items, and Entra groups
- additional regression coverage for canonical tenant state, wrong-tenant record resolution, relation-manager congruence, and action-surface guardrails

## Validation
- `vendor/bin/sail artisan test --compact` passed
- full suite result: `2733 passed, 8 skipped`
- formatting applied with `vendor/bin/sail bin pint --dirty --format agent`

## Notes
- Livewire v4.0+ compliant via existing Filament v5 stack
- provider registration remains in `bootstrap/providers.php`
- globally searchable first-slice posture: Entra groups scoped; policies and policy versions explicitly disabled
- destructive actions continue to use confirmation and policy authorization
- no new Filament assets added; existing deployment flow remains unchanged, including `php artisan filament:assets` when registered assets are used

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #180
2026-03-18 08:33:13 +00:00
317 changed files with 29567 additions and 809 deletions

View File

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

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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' => [],
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

View File

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

View File

@ -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
View 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)) : [];
}
}

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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,
) {}
}

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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