Compare commits
1 Commits
dev
...
147-tenant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0679cd8b |
19
.github/agents/copilot-instructions.md
vendored
19
.github/agents/copilot-instructions.md
vendored
@ -83,19 +83,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
- PostgreSQL for tenants, onboarding sessions, audit logs, operation runs, and workspace membership data (145-tenant-action-taxonomy-lifecycle-safe-visibility)
|
||||||
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
- PostgreSQL (existing tenant and operation records only; no schema changes planned) (146-central-tenant-status-presentation)
|
||||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned (147-tenant-selector-remembered-context-enforcement)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing support-layer helpers such as `UiEnforcement`, `CapabilityResolver`, `WorkspaceContext`, `OperateHubShell`, `TenantOperabilityService`, and `TenantActionPolicySurface` (148-central-tenant-operability-policy)
|
|
||||||
- PostgreSQL plus existing session-backed workspace and remembered-tenant context; no schema change planned for the first implementation slice (148-central-tenant-operability-policy)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Pest 4, existing `OperationRunService`, `TrackOperationRun`, `ProviderOperationStartGate`, `TenantOperabilityService`, `CapabilityResolver`, and `WriteGateInterface` seams (149-queued-execution-reauthorization)
|
|
||||||
- PostgreSQL-backed application data plus queue-serialized `OperationRun` context; no schema migration planned for the first implementation slice (149-queued-execution-reauthorization)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4 (150-tenant-owned-query-canon-and-wrong-tenant-guards)
|
|
||||||
- PostgreSQL with existing `findings` and `audit_logs` tables; no new storage engine or external log store (151-findings-workflow-backstop)
|
|
||||||
- PostgreSQL with existing workspace-, tenant-, onboarding-, and audit-related tables; no new persistent storage planned for the first slice (152-livewire-context-locking)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure (153-evidence-domain-foundation)
|
|
||||||
- PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store (153-evidence-domain-foundation)
|
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing Finding, AuditLog, EvidenceSnapshot, CapabilityResolver, WorkspaceCapabilityResolver, and UiEnforcement patterns (001-finding-risk-acceptance)
|
|
||||||
- PostgreSQL with new tenant-owned exception tables and JSONB-backed supporting metadata (001-finding-risk-acceptance)
|
|
||||||
- PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService` (155-tenant-review-layer)
|
|
||||||
- PostgreSQL with JSONB-backed summary payloads and tenant/workspace ownership columns (155-tenant-review-layer)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -115,8 +102,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 155-tenant-review-layer: Added PHP 8.4, Laravel 12, Livewire 4, Filament 5 + Filament resources/pages/actions, Eloquent models, queued Laravel jobs, existing `EvidenceSnapshotService`, existing `ReviewPackService`, capability registry, `OperationRunService`
|
- 147-tenant-selector-remembered-context-enforcement: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||||
- 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
|
- 146-central-tenant-status-presentation: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4
|
||||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
- 145-tenant-action-taxonomy-lifecycle-safe-visibility: Added PHP 8.4.15 with Laravel 12, Filament v5, Livewire v4.0+ + Filament Actions/Tables/Infolists, Laravel Gates/Policies, `UiEnforcement`, `WorkspaceUiEnforcement`, `ActionSurfaceDeclaration`, `BadgeCatalog`, `TenantOperabilityService`, `OnboardingLifecycleService`
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions;
|
|
||||||
|
|
||||||
use App\Services\Evidence\EvidenceResolutionResult;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class ReviewPackEvidenceResolutionException extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly EvidenceResolutionResult $result,
|
|
||||||
?string $message = null,
|
|
||||||
) {
|
|
||||||
parent::__construct($message ?? self::defaultMessage($result));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function defaultMessage(EvidenceResolutionResult $result): string
|
|
||||||
{
|
|
||||||
return match ($result->outcome) {
|
|
||||||
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
|
||||||
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
|
||||||
default => 'Evidence snapshot resolution failed.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Concerns;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedQueryScope;
|
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedRecordResolver;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
trait InteractsWithTenantOwnedRecords
|
|
||||||
{
|
|
||||||
protected static function tenantOwnedRelationshipName(): string
|
|
||||||
{
|
|
||||||
$relationshipName = property_exists(static::class, 'tenantOwnershipRelationshipName')
|
|
||||||
? static::$tenantOwnershipRelationshipName
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return is_string($relationshipName) && $relationshipName !== ''
|
|
||||||
? $relationshipName
|
|
||||||
: 'tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveTenantContextForTenantOwnedRecords(): ?Tenant
|
|
||||||
{
|
|
||||||
if (method_exists(static::class, 'resolveTenantContextForCurrentPanel')) {
|
|
||||||
return static::resolveTenantContextForCurrentPanel();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method_exists(static::class, 'panelTenantContext')) {
|
|
||||||
return static::panelTenantContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getTenantOwnedEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function scopeTenantOwnedQuery(Builder $query, ?Tenant $tenant = null): Builder
|
|
||||||
{
|
|
||||||
return app(TenantOwnedQueryScope::class)->apply(
|
|
||||||
$query,
|
|
||||||
$tenant ?? static::resolveTenantContextForTenantOwnedRecords(),
|
|
||||||
static::tenantOwnedRelationshipName(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveTenantOwnedRecord(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): ?Model
|
|
||||||
{
|
|
||||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
|
||||||
$query ?? parent::getEloquentQuery(),
|
|
||||||
$tenant,
|
|
||||||
);
|
|
||||||
|
|
||||||
return app(TenantOwnedRecordResolver::class)->resolve($scopedQuery, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function resolveTenantOwnedRecordOrFail(Model|int|string|null $record, ?Builder $query = null, ?Tenant $tenant = null): Model
|
|
||||||
{
|
|
||||||
$scopedQuery = static::scopeTenantOwnedQuery(
|
|
||||||
$query ?? parent::getEloquentQuery(),
|
|
||||||
$tenant,
|
|
||||||
);
|
|
||||||
|
|
||||||
return app(TenantOwnedRecordResolver::class)->resolveOrFail($scopedQuery, $record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,7 +14,7 @@ trait ResolvesPanelTenantContext
|
|||||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
}
|
}
|
||||||
@ -24,16 +24,6 @@ protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
|||||||
return $tenant instanceof Tenant ? $tenant : null;
|
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
|
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
@ -44,9 +34,4 @@ protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
|||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
|
||||||
{
|
|
||||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -22,10 +21,6 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
{
|
{
|
||||||
$query = static::getModel()::query();
|
$query = static::getModel()::query();
|
||||||
|
|
||||||
if (! TenantOwnedModelFamilies::supportsScopedGlobalSearch(static::getModel())) {
|
|
||||||
return $query->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! static::isScopedToTenant()) {
|
if (! static::isScopedToTenant()) {
|
||||||
$panel = Filament::getCurrentOrDefaultPanel();
|
$panel = Filament::getCurrentOrDefaultPanel();
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -70,22 +68,7 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$workspaceContext = app(WorkspaceContext::class);
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||||||
$workspaceId = $workspaceContext->currentWorkspaceId(request());
|
|
||||||
$tenant = null;
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
|
||||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$workspace = $tenant->workspace;
|
|
||||||
|
|
||||||
if ($workspace !== null && $user->canAccessTenant($tenant)) {
|
|
||||||
$workspaceContext->setCurrentWorkspace($workspace, $user, request());
|
|
||||||
$workspaceId = (int) $workspace->getKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($workspaceId === null) {
|
if ($workspaceId === null) {
|
||||||
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
$this->redirect(route('filament.admin.pages.choose-workspace'));
|
||||||
@ -93,12 +76,10 @@ public function selectTenant(int $tenantId): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || (int) $tenant->workspace_id !== $workspaceId) {
|
$tenant = Tenant::query()
|
||||||
$tenant = Tenant::query()
|
->where('workspace_id', $workspaceId)
|
||||||
->where('workspace_id', $workspaceId)
|
->whereKey($tenantId)
|
||||||
->whereKey($tenantId)
|
->first();
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -108,21 +89,13 @@ public function selectTenant(int $tenantId): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: $workspaceId,
|
|
||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $outcome->allowed) {
|
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistLastTenant($user, $tenant);
|
$this->persistLastTenant($user, $tenant);
|
||||||
|
|
||||||
if (! $workspaceContext->rememberTenantContext($tenant, request())) {
|
if (! app(WorkspaceContext::class)->rememberTenantContext($tenant, request())) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Auth\AuthenticationException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EvidenceOverview extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Evidence Overview';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public array $rows = [];
|
|
||||||
|
|
||||||
public ?int $tenantFilter = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
throw new AuthenticationException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceContext = app(WorkspaceContext::class);
|
|
||||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
|
||||||
$workspaceId = (int) $workspace->getKey();
|
|
||||||
|
|
||||||
$accessibleTenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get()
|
|
||||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
|
||||||
|
|
||||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
|
||||||
|
|
||||||
$query = EvidenceSnapshot::query()
|
|
||||||
->with('tenant')
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->whereIn('tenant_id', $tenantIds)
|
|
||||||
->where('status', 'active')
|
|
||||||
->latest('generated_at');
|
|
||||||
|
|
||||||
if ($this->tenantFilter !== null) {
|
|
||||||
$query->where('tenant_id', $this->tenantFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
|
||||||
|
|
||||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
|
||||||
return [
|
|
||||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
|
||||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
|
||||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
|
||||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
|
||||||
];
|
|
||||||
})->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
|
||||||
->url(route('admin.evidence.overview')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,503 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Monitoring;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingExceptionsQueue extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
public ?int $selectedFindingExceptionId = null;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Finding exceptions';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'finding-exceptions/queue';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Finding Exceptions Queue';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.monitoring.finding-exceptions-queue';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
|
||||||
->withDefaults(new ActionSurfaceDefaults(
|
|
||||||
moreGroupLabel: 'More',
|
|
||||||
exportIsDefaultBulkActionForReadOnly: false,
|
|
||||||
))
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep workspace approval scope visible and expose selected exception review actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions are reviewed one record at a time in v1 and do not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains when the approval queue is empty and keeps navigation back to tenant findings available.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Selected exception detail exposes approve, reject, and related-record navigation actions in the page header.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
return $resolver->isMember($user, $workspace)
|
|
||||||
&& $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->selectedFindingExceptionId = is_numeric(request()->query('exception')) ? (int) request()->query('exception') : null;
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
|
|
||||||
if ($this->selectedFindingExceptionId !== null) {
|
|
||||||
$this->selectedFindingException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
$actions = app(OperateHubShell::class)->headerActions(
|
|
||||||
scopeActionName: 'operate_hub_scope_finding_exceptions',
|
|
||||||
returnActionName: 'operate_hub_return_finding_exceptions',
|
|
||||||
);
|
|
||||||
|
|
||||||
$actions[] = Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveQueueFilters())
|
|
||||||
->action(function (): void {
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->removeTableFilter('status');
|
|
||||||
$this->removeTableFilter('current_validity_state');
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
$this->resetTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('view_tenant_register')
|
|
||||||
->label('View tenant register')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->filteredTenant() instanceof Tenant)
|
|
||||||
->url(function (): ?string {
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('clear_selected_exception')
|
|
||||||
->label('Close details')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->action(function (): void {
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_exception')
|
|
||||||
->label('Open tenant detail')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->url(fn (): ?string => $this->selectedExceptionUrl());
|
|
||||||
|
|
||||||
$actions[] = Action::make('open_selected_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingExceptionId !== null)
|
|
||||||
->url(fn (): ?string => $this->selectedFindingUrl());
|
|
||||||
|
|
||||||
$actions[] = Action::make('approve_selected_exception')
|
|
||||||
->label('Approve exception')
|
|
||||||
->color('success')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
DateTimePicker::make('effective_from')
|
|
||||||
->label('Effective from')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Expires at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
Textarea::make('approval_reason')
|
|
||||||
->label('Approval reason')
|
|
||||||
->rows(3)
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasRenewalRequest = $record->isPendingRenewal();
|
|
||||||
$updated = $service->approve($record, $user, $data);
|
|
||||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
|
||||||
$this->resetTable();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title($wasRenewalRequest ? 'Exception renewed' : 'Exception approved')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
|
|
||||||
$actions[] = Action::make('reject_selected_exception')
|
|
||||||
->label('Reject exception')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (): bool => $this->selectedFindingException()?->isPending() ?? false)
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('rejection_reason')
|
|
||||||
->label('Rejection reason')
|
|
||||||
->rows(3)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$wasRenewalRequest = $record->isPendingRenewal();
|
|
||||||
$updated = $service->reject($record, $user, $data);
|
|
||||||
$this->selectedFindingExceptionId = (int) $updated->getKey();
|
|
||||||
$this->resetTable();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title($wasRenewalRequest ? 'Renewal rejected' : 'Exception rejected')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
});
|
|
||||||
|
|
||||||
return $actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->queueBaseQuery())
|
|
||||||
->defaultSort('requested_at', 'asc')
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
|
||||||
TextColumn::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant')
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('finding_summary')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => $record->finding?->resolvedSubjectDisplayName() ?: 'Finding #'.$record->finding_id)
|
|
||||||
->searchable(),
|
|
||||||
TextColumn::make('requester.name')
|
|
||||||
->label('Requested by')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('owner.name')
|
|
||||||
->label('Owner')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('review_due_at')
|
|
||||||
->label('Review due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('expires_at')
|
|
||||||
->label('Expires')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('requested_at')
|
|
||||||
->label('Requested')
|
|
||||||
->dateTime()
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->searchable(),
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
|
||||||
SelectFilter::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('inspect_exception')
|
|
||||||
->label('Inspect exception')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (FindingException $record): void {
|
|
||||||
$this->selectedFindingExceptionId = (int) $record->getKey();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No exceptions match this queue')
|
|
||||||
->emptyStateDescription('Adjust the current tenant or lifecycle filters to review governed exceptions in this workspace.')
|
|
||||||
->emptyStateIcon('heroicon-o-shield-check')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->action(function (): void {
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->removeTableFilter('status');
|
|
||||||
$this->removeTableFilter('current_validity_state');
|
|
||||||
$this->selectedFindingExceptionId = null;
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedFindingException(): ?FindingException
|
|
||||||
{
|
|
||||||
if (! is_int($this->selectedFindingExceptionId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $this->queueBaseQuery()
|
|
||||||
->whereKey($this->selectedFindingExceptionId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedExceptionUrl(): ?string
|
|
||||||
{
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingExceptionResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectedFindingUrl(): ?string
|
|
||||||
{
|
|
||||||
$record = $this->selectedFindingException();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', $workspaceId)
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->authorizedTenants = $tenants
|
|
||||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueBaseQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
$tenantIds = array_values(array_map(
|
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
||||||
$this->authorizedTenants(),
|
|
||||||
));
|
|
||||||
|
|
||||||
return FindingException::query()
|
|
||||||
->with([
|
|
||||||
'tenant',
|
|
||||||
'requester',
|
|
||||||
'owner',
|
|
||||||
'approver',
|
|
||||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
|
||||||
'decisions.actor',
|
|
||||||
'evidenceReferences',
|
|
||||||
])
|
|
||||||
->where('workspace_id', (int) $workspaceId)
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return Collection::make($this->authorizedTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$tenantId = $this->currentTenantFilterId();
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
|
||||||
{
|
|
||||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasActiveQueueFilters(): bool
|
|
||||||
{
|
|
||||||
return $this->currentTenantFilterId() !== null
|
|
||||||
|| is_string(data_get($this->tableFilters, 'status.value'))
|
|
||||||
|| is_string(data_get($this->tableFilters, 'current_validity_state.value'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
@ -21,8 +20,6 @@
|
|||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -106,7 +103,14 @@ protected function getHeaderActions(): array
|
|||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
$related = OperationRunLinks::related($this->run, $this->relatedLinksTenant());
|
$user = auth()->user();
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant && (! $user instanceof User || ! app(CapabilityResolver::class)->isMember($user, $tenant))) {
|
||||||
|
$tenant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$related = OperationRunLinks::related($this->run, $tenant);
|
||||||
|
|
||||||
$relatedActions = [];
|
$relatedActions = [];
|
||||||
|
|
||||||
@ -160,33 +164,6 @@ public function redactionIntegrityNote(): ?string
|
|||||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
|
||||||
*/
|
|
||||||
public function blockedExecutionBanner(): ?array
|
|
||||||
{
|
|
||||||
if (! isset($this->run) || (string) $this->run->outcome !== 'blocked') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
|
||||||
$reasonCode = data_get($context, 'reason_code');
|
|
||||||
|
|
||||||
if (! is_string($reasonCode) || trim($reasonCode) === '') {
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code');
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonCode = is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : 'unknown_error';
|
|
||||||
$message = $this->run->failure_summary[0]['message'] ?? null;
|
|
||||||
$message = is_string($message) && trim($message) !== '' ? trim($message) : 'The queued run was refused before side effects could begin.';
|
|
||||||
|
|
||||||
return [
|
|
||||||
'tone' => 'amber',
|
|
||||||
'title' => 'Execution blocked',
|
|
||||||
'body' => sprintf('Reason code: %s. %s', $reasonCode, $message),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{tone: string, title: string, body: string}|null
|
* @return array{tone: string, title: string, body: string}|null
|
||||||
*/
|
*/
|
||||||
@ -395,30 +372,4 @@ private function canResumeCapture(): bool
|
|||||||
return $resolver->isMember($user, $workspace)
|
return $resolver->isMember($user, $workspace)
|
||||||
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function relatedLinksTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
if (! isset($this->run)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $this->run->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! app(CapabilityResolver::class)->isMember($user, $tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(TenantOperabilityService::class)->outcomeFor(
|
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: (int) ($this->run->workspace_id ?? 0),
|
|
||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
|
||||||
)->allowed ? $tenant : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,307 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Reviews;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use App\Support\Filament\FilterPresets;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class ReviewRegister extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Reviews';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Review Register';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.reviews.review-register';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the canonical review register.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The review register does not expose bulk actions in the first slice.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the tenant-scoped review detail rather than opening an inline canonical detail panel.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorizePageAccess();
|
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
['status', 'published_state', 'completeness_state'],
|
|
||||||
request(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
|
||||||
->action(function (): void {
|
|
||||||
$this->resetTable();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->registerQuery())
|
|
||||||
->defaultSort('generated_at', 'desc')
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant'))
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('tenant.name')->label('Tenant')->searchable(),
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
||||||
TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
TextColumn::make('summary.publish_blockers')
|
|
||||||
->label('Publish blockers')
|
|
||||||
->formatStateUsing(static function (mixed $state): string {
|
|
||||||
if (! is_array($state) || $state === []) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) count($state);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
|
||||||
->searchable(),
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
'draft' => 'Draft',
|
|
||||||
'ready' => 'Ready',
|
|
||||||
'published' => 'Published',
|
|
||||||
'archived' => 'Archived',
|
|
||||||
'superseded' => 'Superseded',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->options([
|
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('published_state')
|
|
||||||
->label('Published state')
|
|
||||||
->options([
|
|
||||||
'published' => 'Published',
|
|
||||||
'unpublished' => 'Not published',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
return match ($data['value'] ?? null) {
|
|
||||||
'published' => $query->whereNotNull('published_at'),
|
|
||||||
'unpublished' => $query->whereNull('published_at'),
|
|
||||||
default => $query,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => TenantReviewResource::tenantScopedUrl('view', ['record' => $record], $record->tenant, 'tenant')),
|
|
||||||
Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No review records match this view')
|
|
||||||
->emptyStateDescription('Clear the current filters to return to the full review register for your entitled tenants.')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('clear_filters_empty')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->action(fn (): mixed => $this->resetTable()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->authorizedTenants() === []) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function registerQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return TenantReview::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(TenantReviewRegisterService::class)->query($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->authorizedTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultTenantFilter(): ?string
|
|
||||||
{
|
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
||||||
|
|
||||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
||||||
? (string) $tenantId
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasActiveFilters(): bool
|
|
||||||
{
|
|
||||||
$filters = array_filter((array) $this->tableFilters);
|
|
||||||
|
|
||||||
return $filters !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
return is_numeric($workspaceId)
|
|
||||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,8 +12,6 @@
|
|||||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Livewire\Attributes\Locked;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class TenantRequiredPermissions extends Page
|
class TenantRequiredPermissions extends Page
|
||||||
{
|
{
|
||||||
@ -43,8 +41,7 @@ class TenantRequiredPermissions extends Page
|
|||||||
*/
|
*/
|
||||||
public array $viewModel = [];
|
public array $viewModel = [];
|
||||||
|
|
||||||
#[Locked]
|
public ?Tenant $scopedTenant = null;
|
||||||
public ?int $scopedTenantId = null;
|
|
||||||
|
|
||||||
public static function canAccess(): bool
|
public static function canAccess(): bool
|
||||||
{
|
{
|
||||||
@ -53,7 +50,7 @@ public static function canAccess(): bool
|
|||||||
|
|
||||||
public function currentTenant(): ?Tenant
|
public function currentTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
return $this->trustedScopedTenant();
|
return $this->scopedTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@ -64,7 +61,7 @@ public function mount(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->scopedTenantId = (int) $tenant->getKey();
|
$this->scopedTenant = $tenant;
|
||||||
$this->heading = $tenant->getFilamentName();
|
$this->heading = $tenant->getFilamentName();
|
||||||
$this->subheading = 'Required permissions';
|
$this->subheading = 'Required permissions';
|
||||||
|
|
||||||
@ -146,7 +143,7 @@ public function resetFilters(): void
|
|||||||
|
|
||||||
private function refreshViewModel(): void
|
private function refreshViewModel(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->viewModel = [];
|
$this->viewModel = [];
|
||||||
@ -175,7 +172,7 @@ private function refreshViewModel(): void
|
|||||||
|
|
||||||
public function reRunVerificationUrl(): string
|
public function reRunVerificationUrl(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
return TenantResource::getUrl('view', ['record' => $tenant]);
|
return TenantResource::getUrl('view', ['record' => $tenant]);
|
||||||
@ -186,7 +183,7 @@ public function reRunVerificationUrl(): string
|
|||||||
|
|
||||||
public function manageProviderConnectionUrl(): ?string
|
public function manageProviderConnectionUrl(): ?string
|
||||||
{
|
{
|
||||||
$tenant = $this->trustedScopedTenant();
|
$tenant = $this->scopedTenant;
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return null;
|
return null;
|
||||||
@ -237,47 +234,4 @@ private static function hasScopedTenantAccess(?Tenant $tenant): bool
|
|||||||
|
|
||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function trustedScopedTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceContext = app(WorkspaceContext::class);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$routeTenant = static::resolveScopedTenant();
|
|
||||||
|
|
||||||
if ($routeTenant instanceof Tenant) {
|
|
||||||
try {
|
|
||||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($routeTenant, $user, request());
|
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->scopedTenantId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->withTrashed()->whereKey($this->scopedTenantId)->first();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return $workspaceContext->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
|
||||||
} catch (NotFoundHttpException) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,6 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
|
||||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||||
use App\Support\Onboarding\OnboardingDraftStage;
|
use App\Support\Onboarding\OnboardingDraftStage;
|
||||||
use App\Support\Onboarding\OnboardingLifecycleState;
|
use App\Support\Onboarding\OnboardingLifecycleState;
|
||||||
@ -52,9 +51,7 @@
|
|||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderVerificationStatus;
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Verification\VerificationAssistViewModelBuilder;
|
use App\Support\Verification\VerificationAssistViewModelBuilder;
|
||||||
use App\Support\Verification\VerificationCheckStatus;
|
use App\Support\Verification\VerificationCheckStatus;
|
||||||
use App\Support\Verification\VerificationReportOverall;
|
use App\Support\Verification\VerificationReportOverall;
|
||||||
@ -89,7 +86,6 @@
|
|||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Livewire\Attributes\Locked;
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
@ -125,14 +121,8 @@ protected function getLayoutData(): array
|
|||||||
|
|
||||||
public ?Tenant $managedTenant = null;
|
public ?Tenant $managedTenant = null;
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public ?int $managedTenantId = null;
|
|
||||||
|
|
||||||
public ?TenantOnboardingSession $onboardingSession = null;
|
public ?TenantOnboardingSession $onboardingSession = null;
|
||||||
|
|
||||||
#[Locked]
|
|
||||||
public ?int $onboardingSessionId = null;
|
|
||||||
|
|
||||||
public ?int $onboardingSessionVersion = null;
|
public ?int $onboardingSessionVersion = null;
|
||||||
|
|
||||||
public ?int $selectedProviderConnectionId = null;
|
public ?int $selectedProviderConnectionId = null;
|
||||||
@ -159,8 +149,6 @@ protected function getLayoutData(): array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [];
|
$actions = [];
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
|
||||||
|
|
||||||
if (isset($this->workspace)) {
|
if (isset($this->workspace)) {
|
||||||
$actions[] = Action::make('back_to_workspace')
|
$actions[] = Action::make('back_to_workspace')
|
||||||
@ -180,10 +168,10 @@ protected function getHeaderActions(): array
|
|||||||
$actions[] = Action::make('view_linked_tenant')
|
$actions[] = Action::make('view_linked_tenant')
|
||||||
->label($this->linkedTenantActionLabel())
|
->label($this->linkedTenantActionLabel())
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->url($tenant instanceof Tenant ? TenantResource::getUrl('view', ['record' => $tenant]) : null);
|
->url(TenantResource::getUrl('view', ['record' => $this->managedTenant]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->canResumeDraft($draft)) {
|
if ($this->canResumeDraft($this->onboardingSession)) {
|
||||||
$actions[] = Action::make('cancel_onboarding_draft')
|
$actions[] = Action::make('cancel_onboarding_draft')
|
||||||
->label('Cancel draft')
|
->label('Cancel draft')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@ -194,7 +182,7 @@ protected function getHeaderActions(): array
|
|||||||
->action(fn () => $this->cancelOnboardingDraft());
|
->action(fn () => $this->cancelOnboardingDraft());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->canDeleteDraft($draft)) {
|
if ($this->canDeleteDraft($this->onboardingSession)) {
|
||||||
$actions[] = Action::make('delete_onboarding_draft_header')
|
$actions[] = Action::make('delete_onboarding_draft_header')
|
||||||
->label('Delete draft')
|
->label('Delete draft')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
@ -212,36 +200,27 @@ protected function getHeaderActions(): array
|
|||||||
private function canViewLinkedTenant(): bool
|
private function canViewLinkedTenant(): bool
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
if (! $user instanceof User || ! $this->managedTenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
if (! $user->canAccessTenant($this->managedTenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(TenantOperabilityService::class)->outcomeFor(
|
return app(TenantOperabilityService::class)->canViewTenantSurface($this->managedTenant);
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: (int) $this->workspace->getKey(),
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
)->allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function linkedTenantActionLabel(): string
|
private function linkedTenantActionLabel(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return 'View tenant';
|
return 'View tenant';
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'View tenant (%s)',
|
'View tenant (%s)',
|
||||||
TenantLifecyclePresentation::fromTenant($tenant)->label,
|
TenantLifecyclePresentation::fromTenant($this->managedTenant)->label,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -725,7 +704,7 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
|
|||||||
$tenant = $draft->tenant;
|
$tenant = $draft->tenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
||||||
$this->setManagedTenant($tenant);
|
$this->managedTenant = $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
|
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
|
||||||
@ -814,9 +793,7 @@ private function draftPickerSchema(): array
|
|||||||
*/
|
*/
|
||||||
private function resumeContextSchema(): array
|
private function resumeContextSchema(): array
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -829,19 +806,19 @@ private function resumeContextSchema(): array
|
|||||||
->schema([
|
->schema([
|
||||||
Text::make('Tenant')
|
Text::make('Tenant')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => $this->draftTitle($this->currentOnboardingSessionRecord() ?? $draft))
|
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
|
||||||
->weight(FontWeight::SemiBold),
|
->weight(FontWeight::SemiBold),
|
||||||
Text::make('Current stage')
|
Text::make('Current stage')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => $this->draftStageLabel($this->currentOnboardingSessionRecord() ?? $draft))
|
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn () => $this->draftStageColor($this->currentOnboardingSessionRecord() ?? $draft)),
|
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
|
||||||
Text::make('Started by')
|
Text::make('Started by')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->startedByUser?->name ?? 'Unknown'),
|
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
|
||||||
Text::make('Last updated by')
|
Text::make('Last updated by')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => ($this->currentOnboardingSessionRecord() ?? $draft)?->updatedByUser?->name ?? 'Unknown'),
|
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -851,13 +828,11 @@ private function resumeContextSchema(): array
|
|||||||
*/
|
*/
|
||||||
private function nonResumableSummarySchema(): array
|
private function nonResumableSummarySchema(): array
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$statusLabel = $draft->status()->label();
|
$statusLabel = $this->onboardingSession->status()->label();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Callout::make("This onboarding draft is {$statusLabel}.")
|
Callout::make("This onboarding draft is {$statusLabel}.")
|
||||||
@ -872,16 +847,16 @@ private function nonResumableSummarySchema(): array
|
|||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn (): string => $statusLabel)
|
Text::make(fn (): string => $statusLabel)
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn () => $this->draftStatusColor($this->currentOnboardingSessionRecord() ?? $draft)),
|
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
|
||||||
Text::make('Primary domain')
|
Text::make('Primary domain')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['primary_domain'] ?? null) ?: '—')),
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
|
||||||
Text::make('Environment')
|
Text::make('Environment')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['environment'] ?? null) ?: '—')),
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
|
||||||
Text::make('Notes')
|
Text::make('Notes')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn () => (string) ((($this->currentOnboardingSessionRecord() ?? $draft)?->state['notes'] ?? null) ?: '—')),
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
|
||||||
]),
|
]),
|
||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('back_to_workspace_summary')
|
Action::make('back_to_workspace_summary')
|
||||||
@ -899,7 +874,7 @@ private function nonResumableSummarySchema(): array
|
|||||||
->modalHeading('Delete onboarding draft')
|
->modalHeading('Delete onboarding draft')
|
||||||
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
|
->modalDescription('This permanently deletes the onboarding draft record. The linked tenant record, if any, is not deleted.')
|
||||||
->modalSubmitActionLabel('Delete draft')
|
->modalSubmitActionLabel('Delete draft')
|
||||||
->visible(fn (): bool => $this->canDeleteDraft($this->currentOnboardingSessionRecord() ?? $draft))
|
->visible(fn (): bool => $this->canDeleteDraft($this->onboardingSession))
|
||||||
->action(fn () => $this->deleteOnboardingDraft()),
|
->action(fn () => $this->deleteOnboardingDraft()),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
@ -909,7 +884,7 @@ private function startNewOnboardingDraft(): void
|
|||||||
{
|
{
|
||||||
$this->showDraftPicker = false;
|
$this->showDraftPicker = false;
|
||||||
$this->showStartState = true;
|
$this->showStartState = true;
|
||||||
$this->setManagedTenant(null);
|
$this->managedTenant = null;
|
||||||
$this->setOnboardingSession(null);
|
$this->setOnboardingSession(null);
|
||||||
$this->selectedProviderConnectionId = null;
|
$this->selectedProviderConnectionId = null;
|
||||||
$this->selectedBootstrapOperationTypes = [];
|
$this->selectedBootstrapOperationTypes = [];
|
||||||
@ -961,20 +936,9 @@ private function cancelOnboardingDraft(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->authorizeWorkspaceMember($user);
|
$this->authorize('cancel', $this->onboardingSession);
|
||||||
|
|
||||||
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
|
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||||
$this->onboardingSessionId ?? $this->onboardingSession,
|
|
||||||
$user,
|
|
||||||
$this->workspace,
|
|
||||||
app(OnboardingDraftResolver::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->setOnboardingSession($draft);
|
|
||||||
|
|
||||||
$this->authorize('cancel', $draft);
|
|
||||||
|
|
||||||
if (! $this->canResumeDraft($draft)) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Draft is not resumable')
|
->title('Draft is not resumable')
|
||||||
->warning()
|
->warning()
|
||||||
@ -1035,7 +999,8 @@ private function cancelOnboardingDraft(): void
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->setManagedTenant($normalizedTenant);
|
$this->managedTenant = $normalizedTenant;
|
||||||
|
$this->onboardingSession->setRelation('tenant', $normalizedTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -1058,20 +1023,9 @@ private function deleteOnboardingDraft(): void
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->authorizeWorkspaceMember($user);
|
$this->authorize('cancel', $this->onboardingSession);
|
||||||
|
|
||||||
$draft = app(TrustedStateResolver::class)->resolveOnboardingDraft(
|
if (! $this->canDeleteDraft($this->onboardingSession)) {
|
||||||
$this->onboardingSessionId ?? $this->onboardingSession,
|
|
||||||
$user,
|
|
||||||
$this->workspace,
|
|
||||||
app(OnboardingDraftResolver::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->setOnboardingSession($draft);
|
|
||||||
|
|
||||||
$this->authorize('cancel', $draft);
|
|
||||||
|
|
||||||
if (! $this->canDeleteDraft($draft)) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Draft cannot be deleted')
|
->title('Draft cannot be deleted')
|
||||||
->warning()
|
->warning()
|
||||||
@ -1080,6 +1034,7 @@ private function deleteOnboardingDraft(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$draft = $this->onboardingSession;
|
||||||
$draftId = (int) $draft->getKey();
|
$draftId = (int) $draft->getKey();
|
||||||
$draftTitle = $this->draftTitle($draft);
|
$draftTitle = $this->draftTitle($draft);
|
||||||
$draftStatus = $draft->status()->value;
|
$draftStatus = $draft->status()->value;
|
||||||
@ -1107,7 +1062,7 @@ private function deleteOnboardingDraft(): void
|
|||||||
targetLabel: $draftTitle,
|
targetLabel: $draftTitle,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->setManagedTenant(null);
|
$this->managedTenant = null;
|
||||||
$this->setOnboardingSession(null);
|
$this->setOnboardingSession(null);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -1120,10 +1075,8 @@ private function deleteOnboardingDraft(): void
|
|||||||
|
|
||||||
private function showsNonResumableSummary(): bool
|
private function showsNonResumableSummary(): bool
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||||
|
&& ! $this->canResumeDraft($this->onboardingSession);
|
||||||
return $draft instanceof TenantOnboardingSession
|
|
||||||
&& ! $this->canResumeDraft($draft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
|
private function canDeleteDraft(?TenantOnboardingSession $draft): bool
|
||||||
@ -1156,13 +1109,11 @@ private function onboardingEntryActionDescriptor(int $resumableDraftCount): \App
|
|||||||
|
|
||||||
private function shouldShowDraftLandingAction(): bool
|
private function shouldShowDraftLandingAction(): bool
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->canResumeDraft($draft)) {
|
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1260,95 +1211,14 @@ private function expectedDraftVersion(): ?int
|
|||||||
private function setOnboardingSession(?TenantOnboardingSession $draft): void
|
private function setOnboardingSession(?TenantOnboardingSession $draft): void
|
||||||
{
|
{
|
||||||
$this->onboardingSession = $draft;
|
$this->onboardingSession = $draft;
|
||||||
$this->onboardingSessionId = $draft instanceof TenantOnboardingSession
|
|
||||||
? (int) $draft->getKey()
|
|
||||||
: null;
|
|
||||||
$this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession
|
$this->onboardingSessionVersion = $draft instanceof TenantOnboardingSession
|
||||||
? $draft->expectedVersion()
|
? $draft->expectedVersion()
|
||||||
: null;
|
: 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
|
private function refreshOnboardingDraftFromBackend(): void
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1359,11 +1229,15 @@ private function refreshOnboardingDraftFromBackend(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve(
|
$this->setOnboardingSession(app(OnboardingDraftResolver::class)->resolve(
|
||||||
$draft,
|
$this->onboardingSession,
|
||||||
$user,
|
$user,
|
||||||
$this->workspace,
|
$this->workspace,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if ($this->onboardingSession->tenant instanceof Tenant) {
|
||||||
|
$this->managedTenant = $this->onboardingSession->tenant;
|
||||||
|
}
|
||||||
|
|
||||||
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
||||||
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
|
$this->selectedProviderConnectionId = $this->resolvePersistedProviderConnectionId($providerConnectionId);
|
||||||
$this->initializeWizardData();
|
$this->initializeWizardData();
|
||||||
@ -1393,13 +1267,11 @@ private function handleImmutableDraft(string $title = 'This onboarding draft is
|
|||||||
|
|
||||||
private function lifecycleState(): OnboardingLifecycleState
|
private function lifecycleState(): OnboardingLifecycleState
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return OnboardingLifecycleState::Draft;
|
return OnboardingLifecycleState::Draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->lifecycleService()->snapshot($draft)['lifecycle_state'];
|
return $this->lifecycleService()->snapshot($this->onboardingSession)['lifecycle_state'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function lifecycleStateLabel(): string
|
private function lifecycleStateLabel(): string
|
||||||
@ -1422,38 +1294,30 @@ private function lifecycleStateColor(): string
|
|||||||
|
|
||||||
private function currentCheckpointLabel(): string
|
private function currentCheckpointLabel(): string
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return OnboardingCheckpoint::Identify->label();
|
return OnboardingCheckpoint::Identify->label();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ($this->lifecycleService()->snapshot($draft)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
|
return ($this->lifecycleService()->snapshot($this->onboardingSession)['current_checkpoint'] ?? OnboardingCheckpoint::Identify)?->label()
|
||||||
?? OnboardingCheckpoint::Identify->label();
|
?? OnboardingCheckpoint::Identify->label();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shouldPollCheckpointLifecycle(): bool
|
public function shouldPollCheckpointLifecycle(): bool
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
return $this->onboardingSession instanceof TenantOnboardingSession
|
||||||
|
&& $this->lifecycleService()->hasActiveCheckpoint($this->onboardingSession);
|
||||||
return $draft instanceof TenantOnboardingSession
|
|
||||||
&& $this->lifecycleService()->hasActiveCheckpoint($draft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshCheckpointLifecycle(): void
|
public function refreshCheckpointLifecycle(): void
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($draft));
|
$this->setOnboardingSession($this->lifecycleService()->syncPersistedLifecycle($this->onboardingSession));
|
||||||
|
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if ($this->managedTenant instanceof Tenant) {
|
||||||
|
$this->managedTenant->refresh();
|
||||||
if ($tenant instanceof Tenant) {
|
|
||||||
$this->setManagedTenant($tenant->fresh());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->initializeWizardData();
|
$this->initializeWizardData();
|
||||||
@ -1479,24 +1343,20 @@ private function initializeWizardData(): void
|
|||||||
$this->data['new_connection']['is_default'] ??= true;
|
$this->data['new_connection']['is_default'] ??= true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if ($this->managedTenant instanceof Tenant) {
|
||||||
|
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
|
||||||
|
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
|
||||||
|
$this->data['name'] ??= (string) $this->managedTenant->name;
|
||||||
|
$this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? '');
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
$notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null;
|
||||||
$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) !== '') {
|
if (is_string($notes) && trim($notes) !== '') {
|
||||||
$this->data['notes'] ??= trim($notes);
|
$this->data['notes'] ??= trim($notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
|
||||||
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']) !== '') {
|
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']);
|
$this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']);
|
||||||
@ -1526,13 +1386,13 @@ private function initializeWizardData(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$providerConnectionId = $this->resolvePersistedProviderConnectionId($draft->state['provider_connection_id'] ?? null);
|
$providerConnectionId = $this->resolvePersistedProviderConnectionId($this->onboardingSession->state['provider_connection_id'] ?? null);
|
||||||
if ($providerConnectionId !== null) {
|
if ($providerConnectionId !== null) {
|
||||||
$this->data['provider_connection_id'] = $providerConnectionId;
|
$this->data['provider_connection_id'] = $providerConnectionId;
|
||||||
$this->selectedProviderConnectionId = $providerConnectionId;
|
$this->selectedProviderConnectionId = $providerConnectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$types = $draft->state['bootstrap_operation_types'] ?? null;
|
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
||||||
if (is_array($types)) {
|
if (is_array($types)) {
|
||||||
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
||||||
}
|
}
|
||||||
@ -1550,7 +1410,7 @@ private function initializeWizardData(): void
|
|||||||
private function computeWizardStartStep(): int
|
private function computeWizardStartStep(): int
|
||||||
{
|
{
|
||||||
return app(OnboardingDraftStageResolver::class)
|
return app(OnboardingDraftStageResolver::class)
|
||||||
->resolve($this->currentOnboardingSessionRecord())
|
->resolve($this->onboardingSession)
|
||||||
->wizardStep();
|
->wizardStep();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1559,15 +1419,13 @@ private function computeWizardStartStep(): int
|
|||||||
*/
|
*/
|
||||||
private function providerConnectionOptions(): array
|
private function providerConnectionOptions(): array
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderConnection::query()
|
return ProviderConnection::query()
|
||||||
->where('workspace_id', (int) $this->workspace->getKey())
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $this->managedTenant->getKey())
|
||||||
->orderByDesc('is_default')
|
->orderByDesc('is_default')
|
||||||
->orderBy('display_name')
|
->orderBy('display_name')
|
||||||
->pluck('display_name', 'id')
|
->pluck('display_name', 'id')
|
||||||
@ -1584,13 +1442,11 @@ private function verificationStatusLabel(): string
|
|||||||
|
|
||||||
private function verificationStatus(): string
|
private function verificationStatus(): string
|
||||||
{
|
{
|
||||||
$draft = $this->currentOnboardingSessionRecord();
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
|
||||||
return 'not_started';
|
return 'not_started';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->lifecycleService()->verificationStatus($draft, $this->selectedProviderConnectionId);
|
return $this->lifecycleService()->verificationStatus($this->onboardingSession, $this->selectedProviderConnectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function verificationStatusFromRunOutcome(OperationRun $run): string
|
private function verificationStatusFromRunOutcome(OperationRun $run): string
|
||||||
@ -2087,19 +1943,6 @@ private function authorizeEditableDraft(User $user): void
|
|||||||
return;
|
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);
|
$this->authorize('update', $this->onboardingSession);
|
||||||
|
|
||||||
if (! $this->canResumeDraft($this->onboardingSession)) {
|
if (! $this->canResumeDraft($this->onboardingSession)) {
|
||||||
@ -2107,56 +1950,17 @@ private function authorizeEditableDraft(User $user): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function trustedManagedTenantForUser(User $user): Tenant
|
|
||||||
{
|
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $tenant->fresh();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = app(WorkspaceContext::class)->ensureTenantAccessibleInCurrentWorkspace($tenant, $user, request());
|
|
||||||
|
|
||||||
$this->setManagedTenant($tenant);
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
private function canResumeDraft(?TenantOnboardingSession $draft): bool
|
||||||
{
|
{
|
||||||
if (! $draft instanceof TenantOnboardingSession) {
|
return $draft instanceof TenantOnboardingSession
|
||||||
return false;
|
&& $this->lifecycleService()->canResumeDraft($draft);
|
||||||
}
|
|
||||||
|
|
||||||
if (! $draft->tenant instanceof Tenant) {
|
|
||||||
return $this->lifecycleService()->canResumeDraft($draft);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->currentUser();
|
|
||||||
|
|
||||||
return app(TenantOperabilityService::class)->outcomeFor(
|
|
||||||
tenant: $draft->tenant,
|
|
||||||
question: TenantOperabilityQuestion::ResumeOnboardingEligibility,
|
|
||||||
actor: $user instanceof User ? $user : null,
|
|
||||||
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
|
|
||||||
lane: TenantInteractionLane::OnboardingWorkflow,
|
|
||||||
onboardingDraft: $draft,
|
|
||||||
)->allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeWorkspaceMember(User $user): void
|
private function authorizeWorkspaceMember(User $user): void
|
||||||
{
|
{
|
||||||
$this->workspace = app(TrustedStateResolver::class)->currentWorkspaceForMember(
|
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
||||||
$user,
|
abort(404);
|
||||||
app(WorkspaceContext::class),
|
}
|
||||||
request(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
||||||
@ -2353,7 +2157,7 @@ public function identifyManagedTenant(array $data): void
|
|||||||
resourceId: (string) $tenant->getKey(),
|
resourceId: (string) $tenant->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->setManagedTenant($tenant);
|
$this->managedTenant = $tenant;
|
||||||
$this->setOnboardingSession($session);
|
$this->setOnboardingSession($session);
|
||||||
});
|
});
|
||||||
} catch (OnboardingDraftConflictException) {
|
} catch (OnboardingDraftConflictException) {
|
||||||
@ -2392,11 +2196,13 @@ public function selectProviderConnection(int $providerConnectionId): void
|
|||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
|
||||||
$this->authorizeEditableDraft($user);
|
$this->authorizeEditableDraft($user);
|
||||||
|
|
||||||
$tenant = $this->trustedManagedTenantForUser($user);
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$connection = ProviderConnection::query()
|
$connection = ProviderConnection::query()
|
||||||
->where('workspace_id', (int) $this->workspace->getKey())
|
->where('workspace_id', (int) $this->workspace->getKey())
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', $this->managedTenant->getKey())
|
||||||
->whereKey($providerConnectionId)
|
->whereKey($providerConnectionId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@ -2442,7 +2248,7 @@ public function selectProviderConnection(int $providerConnectionId): void
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'tenant_db_id' => (int) $tenant->getKey(),
|
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||||
],
|
],
|
||||||
@ -2475,7 +2281,11 @@ public function createProviderConnection(array $data): void
|
|||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
||||||
$this->authorizeEditableDraft($user);
|
$this->authorizeEditableDraft($user);
|
||||||
|
|
||||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->managedTenant->fresh();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -2664,7 +2474,7 @@ public function createProviderConnection(array $data): void
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'workspace_id' => (int) $this->workspace->getKey(),
|
'workspace_id' => (int) $this->workspace->getKey(),
|
||||||
'tenant_db_id' => (int) $tenant->getKey(),
|
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
||||||
],
|
],
|
||||||
@ -2706,9 +2516,7 @@ public function startVerification(): void
|
|||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
|
||||||
$this->authorizeEditableDraft($user);
|
$this->authorizeEditableDraft($user);
|
||||||
|
|
||||||
try {
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
|
||||||
} catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Identify a managed tenant first')
|
->title('Identify a managed tenant first')
|
||||||
->warning()
|
->warning()
|
||||||
@ -2717,6 +2525,8 @@ public function startVerification(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $this->managedTenant->fresh();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
@ -2917,15 +2727,6 @@ public function startVerification(): void
|
|||||||
|
|
||||||
public function refreshVerificationStatus(): void
|
public function refreshVerificationStatus(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorizeWorkspaceMember($user);
|
|
||||||
$this->authorizeEditableDraft($user);
|
|
||||||
|
|
||||||
$this->refreshCheckpointLifecycle();
|
$this->refreshCheckpointLifecycle();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -2948,7 +2749,11 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
$this->authorizeWorkspaceMember($user);
|
$this->authorizeWorkspaceMember($user);
|
||||||
$this->authorizeEditableDraft($user);
|
$this->authorizeEditableDraft($user);
|
||||||
|
|
||||||
$tenant = $this->trustedManagedTenantForUser($user)->fresh();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $this->managedTenant->fresh();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@ -3249,19 +3054,6 @@ private function canCompleteOnboarding(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->currentUser();
|
|
||||||
|
|
||||||
if (! app(TenantOperabilityService::class)->outcomeFor(
|
|
||||||
tenant: $this->managedTenant,
|
|
||||||
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
|
||||||
actor: $user instanceof User ? $user : null,
|
|
||||||
workspaceId: isset($this->workspace) ? (int) $this->workspace->getKey() : null,
|
|
||||||
lane: TenantInteractionLane::OnboardingWorkflow,
|
|
||||||
onboardingDraft: $this->onboardingSession,
|
|
||||||
)->allowed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -3283,27 +3075,23 @@ private function canCompleteOnboarding(): bool
|
|||||||
|
|
||||||
private function completionSummaryTenantLine(): string
|
private function completionSummaryTenantLine(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = $tenant->name ?? '—';
|
$name = $this->managedTenant->name ?? '—';
|
||||||
$tenantId = $tenant->graphTenantId();
|
$tenantId = $this->managedTenant->graphTenantId();
|
||||||
|
|
||||||
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
|
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function completionSummaryConnectionLabel(): string
|
private function completionSummaryConnectionLabel(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
$connection = $this->resolveSelectedProviderConnection($tenant);
|
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
return 'Not configured';
|
return 'Not configured';
|
||||||
@ -3431,29 +3219,12 @@ public function completeOnboarding(): void
|
|||||||
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
|
||||||
$this->authorizeEditableDraft($user);
|
$this->authorizeEditableDraft($user);
|
||||||
|
|
||||||
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
if (! $this->managedTenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $this->trustedManagedTenantForUser($user);
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
abort(404);
|
||||||
$completionOutcome = app(TenantOperabilityService::class)->outcomeFor(
|
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::OnboardingCompletionEligibility,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: (int) $this->workspace->getKey(),
|
|
||||||
lane: TenantInteractionLane::OnboardingWorkflow,
|
|
||||||
onboardingDraft: $this->onboardingSession,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $completionOutcome->allowed) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Onboarding unavailable')
|
|
||||||
->body('This tenant can no longer be completed from the current onboarding workflow state.')
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$run = $this->verificationRun();
|
$run = $this->verificationRun();
|
||||||
@ -3489,7 +3260,7 @@ public function completeOnboarding(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $tenant->fresh();
|
$tenant = $this->managedTenant->fresh();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
abort(404);
|
abort(404);
|
||||||
|
|||||||
@ -10,9 +10,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
@ -67,15 +64,7 @@ public function getTenants(): Collection
|
|||||||
->where('workspace_id', $this->workspace->getKey())
|
->where('workspace_id', $this->workspace->getKey())
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->filter(function (Tenant $tenant) use ($user): bool {
|
->filter(fn (Tenant $tenant): bool => app(TenantOperabilityService::class)->canViewTenantSurface($tenant))
|
||||||
return app(TenantOperabilityService::class)->outcomeFor(
|
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
)->allowed;
|
|
||||||
})
|
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Exceptions\InvalidPolicyTypeException;
|
use App\Exceptions\InvalidPolicyTypeException;
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||||
@ -65,7 +64,6 @@
|
|||||||
|
|
||||||
class BackupScheduleResource extends Resource
|
class BackupScheduleResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = BackupSchedule::class;
|
protected static ?string $model = BackupSchedule::class;
|
||||||
@ -583,8 +581,6 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
|
||||||
|
|
||||||
Gate::authorize('delete', $record);
|
Gate::authorize('delete', $record);
|
||||||
|
|
||||||
if ($record->trashed()) {
|
if ($record->trashed()) {
|
||||||
@ -626,8 +622,6 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
|
||||||
|
|
||||||
Gate::authorize('restore', $record);
|
Gate::authorize('restore', $record);
|
||||||
|
|
||||||
if (! $record->trashed()) {
|
if (! $record->trashed()) {
|
||||||
@ -668,8 +662,6 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
->visible(fn (BackupSchedule $record): bool => $record->trashed())
|
||||||
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
->action(function (BackupSchedule $record, AuditLogger $auditLogger): void {
|
||||||
$record = static::resolveProtectedScheduleRecordOrFail($record);
|
|
||||||
|
|
||||||
Gate::authorize('forceDelete', $record);
|
Gate::authorize('forceDelete', $record);
|
||||||
|
|
||||||
if (! $record->trashed()) {
|
if (! $record->trashed()) {
|
||||||
@ -927,32 +919,17 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
->orderByDesc('is_enabled')
|
->orderByDesc('is_enabled')
|
||||||
->orderBy('next_run_at');
|
->orderBy('next_run_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getRecordRouteBindingEloquentQuery(): Builder
|
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery()->withTrashed())
|
return static::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
|
public static function getRelations(): array
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
|
||||||
class EditBackupSchedule extends EditRecord
|
class EditBackupSchedule extends EditRecord
|
||||||
{
|
{
|
||||||
@ -12,7 +13,15 @@ class EditBackupSchedule extends EditRecord
|
|||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return BackupScheduleResource::resolveScopedRecordOrFail($key);
|
$record = BackupScheduleResource::getEloquentQuery()
|
||||||
|
->withTrashed()
|
||||||
|
->find($key);
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
throw (new ModelNotFoundException)->setModel(BackupScheduleResource::getModel(), [$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mutateFormDataBeforeSave(array $data): array
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
|||||||
@ -5,32 +5,18 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource;
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
|
|
||||||
class ListBackupSchedules extends ListRecords
|
class ListBackupSchedules extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = BackupScheduleResource::class;
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->syncCanonicalAdminTenantFilterState();
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
parent::mount();
|
parent::mount();
|
||||||
}
|
}
|
||||||
@ -54,14 +40,4 @@ private function tableHasRecords(): bool
|
|||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncCanonicalAdminTenantFilterState(): void
|
|
||||||
{
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
tenantSensitiveFilters: [],
|
|
||||||
request: request(),
|
|
||||||
tenantFilterName: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Closure;
|
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
@ -25,19 +24,6 @@ class BackupScheduleOperationRunsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static ?string $title = 'Executions';
|
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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
@ -62,7 +48,7 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->formatStateUsing(Closure::fromCallable([self::class, 'formatOperationType'])),
|
->formatStateUsing([OperationCatalog::class, 'label']),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
Tables\Columns\TextColumn::make('status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -101,7 +87,6 @@ public function table(Table $table): Table
|
|||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->url(function (OperationRun $record): string {
|
->url(function (OperationRun $record): string {
|
||||||
$record = $this->resolveOwnerScopedOperationRun($record);
|
|
||||||
$tenant = Tenant::currentOrFail();
|
$tenant = Tenant::currentOrFail();
|
||||||
|
|
||||||
return OperationRunLinks::view($record, $tenant);
|
return OperationRunLinks::view($record, $tenant);
|
||||||
@ -112,32 +97,4 @@ public function table(Table $table): Table
|
|||||||
->emptyStateHeading('No schedule runs yet')
|
->emptyStateHeading('No schedule runs yet')
|
||||||
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedOperationRun(mixed $record): OperationRun
|
|
||||||
{
|
|
||||||
$recordId = $record instanceof OperationRun
|
|
||||||
? (int) $record->getKey()
|
|
||||||
: (is_numeric($record) ? (int) $record : 0);
|
|
||||||
|
|
||||||
if ($recordId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedRecord = $this->getOwnerRecord()
|
|
||||||
->operationRuns()
|
|
||||||
->where('tenant_id', Tenant::currentOrFail()->getKey())
|
|
||||||
->whereKey($recordId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $resolvedRecord instanceof OperationRun) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolvedRecord;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function formatOperationType(?string $state): string
|
|
||||||
{
|
|
||||||
return OperationCatalog::label($state);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\BackupSetResource\Pages;
|
use App\Filament\Resources\BackupSetResource\Pages;
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
@ -57,7 +56,6 @@
|
|||||||
|
|
||||||
class BackupSetResource extends Resource
|
class BackupSetResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = BackupSet::class;
|
protected static ?string $model = BackupSet::class;
|
||||||
@ -122,12 +120,13 @@ public static function canCreate(): bool
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): \Illuminate\Database\Eloquent\Model
|
if (! $tenant instanceof Tenant) {
|
||||||
{
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->withTrashed());
|
}
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewBackupSet extends ViewRecord
|
class ViewBackupSet extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -25,11 +24,6 @@ class ViewBackupSet extends ViewRecord
|
|||||||
|
|
||||||
protected static string $resource = BackupSetResource::class;
|
protected static string $resource = BackupSetResource::class;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return BackupSetResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$actions = [
|
$actions = [
|
||||||
|
|||||||
@ -43,27 +43,6 @@ public function closeAddPoliciesModal(): void
|
|||||||
$this->unmountAction();
|
$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
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$refreshTable = Actions\Action::make('refreshTable')
|
$refreshTable = Actions\Action::make('refreshTable')
|
||||||
@ -98,7 +77,7 @@ public function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (mixed $record): void {
|
->action(function (BackupItem $record): void {
|
||||||
$backupSet = $this->getOwnerRecord();
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -115,7 +94,7 @@ public function table(Table $table): Table
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupItemIds = [$this->resolveOwnerScopedBackupItemId($backupSet, $record)];
|
$backupItemIds = [(int) $record->getKey()];
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -194,7 +173,14 @@ public function table(Table $table): Table
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupItemIds = $this->resolveOwnerScopedBackupItemIdsFromKeys($backupSet, $this->selectedTableRecords);
|
$backupItemIds = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
if ($backupItemIds === []) {
|
if ($backupItemIds === []) {
|
||||||
return;
|
return;
|
||||||
@ -448,68 +434,4 @@ private static function applyRestoreModeFilter(Builder $query, mixed $value): Bu
|
|||||||
|
|
||||||
return $query->whereIn('policy_type', $types);
|
return $query->whereIn('policy_type', $types);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedBackupItemId(\App\Models\BackupSet $backupSet, mixed $record): int
|
|
||||||
{
|
|
||||||
$recordId = $this->normalizeBackupItemKey($record);
|
|
||||||
|
|
||||||
if ($recordId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedId = $backupSet->items()
|
|
||||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
|
||||||
->whereKey($recordId)
|
|
||||||
->value('id');
|
|
||||||
|
|
||||||
if (! is_numeric($resolvedId) || (int) $resolvedId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $resolvedId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function resolveOwnerScopedBackupItemIdsFromKeys(\App\Models\BackupSet $backupSet, array $recordKeys): array
|
|
||||||
{
|
|
||||||
$requestedIds = collect($recordKeys)
|
|
||||||
->map(fn (mixed $record): int => $this->normalizeBackupItemKey($record))
|
|
||||||
->filter(fn (int $value): bool => $value > 0)
|
|
||||||
->unique()
|
|
||||||
->sort()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($requestedIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedIds = $backupSet->items()
|
|
||||||
->where('tenant_id', (int) $backupSet->tenant_id)
|
|
||||||
->whereIn('id', $requestedIds)
|
|
||||||
->pluck('id')
|
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
|
||||||
->filter(fn (int $value): bool => $value > 0)
|
|
||||||
->unique()
|
|
||||||
->sort()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if (count($resolvedIds) !== count($requestedIds)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolvedIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeBackupItemKey(mixed $record): int
|
|
||||||
{
|
|
||||||
if ($record instanceof BackupItem) {
|
|
||||||
return (int) $record->getKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_numeric($record) ? (int) $record : 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
@ -11,6 +9,7 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -34,16 +33,12 @@
|
|||||||
|
|
||||||
class EntraGroupResource extends Resource
|
class EntraGroupResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
use ScopesGlobalSearchToTenant;
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $model = EntraGroup::class;
|
protected static ?string $model = EntraGroup::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static ?string $recordTitleAttribute = 'display_name';
|
protected static ?string $recordTitleAttribute = 'display_name';
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||||
@ -193,13 +188,15 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
$tenant = static::panelTenantContext();
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
return parent::getEloquentQuery()
|
||||||
{
|
->when(
|
||||||
return static::resolveTenantOwnedRecordOrFail($key);
|
$tenant instanceof Tenant,
|
||||||
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenant->getKey()),
|
||||||
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
|
)
|
||||||
|
->latest('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getGlobalSearchResultUrl(Model $record): string
|
public static function getGlobalSearchResultUrl(Model $record): string
|
||||||
@ -219,6 +216,19 @@ public static function getPages(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function panelTenantContext(): ?Tenant
|
||||||
|
{
|
||||||
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $parameters
|
* @param array<string, mixed> $parameters
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -8,17 +8,11 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewEntraGroup extends ViewRecord
|
class ViewEntraGroup extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EntraGroupResource::class;
|
protected static string $resource = EntraGroupResource::class;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return EntraGroupResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
protected function authorizeAccess(): void
|
||||||
{
|
{
|
||||||
$tenant = EntraGroupResource::panelTenantContext();
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
|||||||
@ -1,637 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\EvidenceSnapshotItem;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationCatalog;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Panel;
|
|
||||||
use Filament\Resources\Pages\PageRegistration;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Routing\Route;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class EvidenceSnapshotResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static ?string $model = EvidenceSnapshot::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'evidence';
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Evidence';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 55;
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $record instanceof EvidenceSnapshot
|
|
||||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Snapshot')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('operationRun.id')
|
|
||||||
->label('Operation run')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
|
||||||
->openUrlInNewTab(),
|
|
||||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
||||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Summary')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
|
||||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Evidence dimensions')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('items')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('dimension_key')->label('Dimension')
|
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
||||||
TextEntry::make('state')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
|
||||||
TextEntry::make('source_kind')->label('Source')
|
|
||||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
|
||||||
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
|
||||||
ViewEntry::make('summary_payload_highlights')
|
|
||||||
->label('Summary')
|
|
||||||
->view('filament.infolists.entries.evidence-dimension-summary')
|
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
ViewEntry::make('summary_payload_raw')
|
|
||||||
->label('Raw summary JSON')
|
|
||||||
->view('filament.infolists.entries.snapshot-json')
|
|
||||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(4),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
'queued' => 'Queued',
|
|
||||||
'generating' => 'Generating',
|
|
||||||
'active' => 'Active',
|
|
||||||
'superseded' => 'Superseded',
|
|
||||||
'expired' => 'Expired',
|
|
||||||
'failed' => 'Failed',
|
|
||||||
]),
|
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
|
||||||
->options([
|
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_snapshot')
|
|
||||||
->label('View snapshot')
|
|
||||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('expire')
|
|
||||||
->label('Expire snapshot')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (EvidenceSnapshot $record): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
|
||||||
}),
|
|
||||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No evidence snapshots yet')
|
|
||||||
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
|
||||||
->emptyStateActions([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_first_snapshot')
|
|
||||||
->label('Create first snapshot')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(fn (): mixed => static::executeGeneration([])),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
|
||||||
'view' => new PageRegistration(
|
|
||||||
page: Pages\ViewEvidenceSnapshot::class,
|
|
||||||
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
|
||||||
->whereNumber('record')
|
|
||||||
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
|
||||||
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
|
||||||
{
|
|
||||||
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
|
||||||
|
|
||||||
return match ($item->dimension_key) {
|
|
||||||
'findings_summary' => static::findingsSummaryPresentation($payload),
|
|
||||||
'permission_posture' => static::permissionPosturePresentation($payload),
|
|
||||||
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
|
||||||
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
|
||||||
'operations_summary' => static::operationsSummaryPresentation($payload),
|
|
||||||
default => static::genericSummaryPresentation($payload),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function findingsSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$count = (int) ($payload['count'] ?? 0);
|
|
||||||
$openCount = (int) ($payload['open_count'] ?? 0);
|
|
||||||
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
|
||||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Findings', 'value' => (string) $count],
|
|
||||||
['label' => 'Open findings', 'value' => (string) $openCount],
|
|
||||||
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
|
||||||
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
|
||||||
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
|
||||||
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
|
||||||
],
|
|
||||||
'items' => collect($entries)
|
|
||||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function permissionPosturePresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
|
||||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
|
||||||
$postureScore = $payload['posture_score'] ?? null;
|
|
||||||
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
|
||||||
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
|
||||||
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
|
||||||
],
|
|
||||||
'items' => static::namedItemsFromArray(
|
|
||||||
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
|
||||||
'No missing permission details captured.'
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function entraAdminRolesPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$roleCount = (int) ($payload['role_count'] ?? 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Role count', 'value' => (string) $roleCount],
|
|
||||||
],
|
|
||||||
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function baselineDriftPosturePresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
|
||||||
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
|
||||||
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
|
||||||
],
|
|
||||||
'items' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function operationsSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
|
||||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
|
||||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
|
||||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
|
|
||||||
'highlights' => [
|
|
||||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
|
||||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
|
||||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
|
||||||
],
|
|
||||||
'items' => collect($entries)
|
|
||||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
|
||||||
*/
|
|
||||||
private static function genericSummaryPresentation(array $payload): array
|
|
||||||
{
|
|
||||||
$highlights = collect($payload)
|
|
||||||
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
|
||||||
->take(6)
|
|
||||||
->map(fn (mixed $value, string|int $key): array => [
|
|
||||||
'label' => Str::headline((string) $key),
|
|
||||||
'value' => static::stringifySummaryValue($value),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
|
||||||
'highlights' => $highlights,
|
|
||||||
'items' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
|
||||||
{
|
|
||||||
if (! is_array($items) || $items === []) {
|
|
||||||
return [$emptyFallback];
|
|
||||||
}
|
|
||||||
|
|
||||||
$labels = collect($items)
|
|
||||||
->map(function (mixed $item): ?string {
|
|
||||||
if (is_string($item)) {
|
|
||||||
return trim($item) !== '' ? $item : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($item)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
|
||||||
$value = $item[$key] ?? null;
|
|
||||||
|
|
||||||
if (is_string($value) && trim($value) !== '') {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
->filter()
|
|
||||||
->take(5)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $labels === [] ? [$emptyFallback] : $labels;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function findingEntryLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$title = $entry['title'] ?? null;
|
|
||||||
$severity = $entry['severity'] ?? null;
|
|
||||||
$status = $entry['status'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($title) || trim($title) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [trim($title)];
|
|
||||||
|
|
||||||
if (is_string($severity) && trim($severity) !== '') {
|
|
||||||
$parts[] = Str::headline($severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($status) && trim($status) !== '') {
|
|
||||||
$parts[] = Str::headline($status);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' · ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function operationEntryLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$type = $entry['type'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($type) || trim($type) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [static::operationTypeLabel($type)];
|
|
||||||
|
|
||||||
$stateLabel = static::operationEntryStateLabel($entry);
|
|
||||||
|
|
||||||
if ($stateLabel !== null) {
|
|
||||||
$parts[] = $stateLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(' · ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
|
||||||
{
|
|
||||||
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function operationTypeLabel(string $type): string
|
|
||||||
{
|
|
||||||
$label = OperationCatalog::label($type);
|
|
||||||
|
|
||||||
return $label === 'Unknown operation' ? 'Operation' : $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*/
|
|
||||||
private static function operationEntryStateLabel(array $entry): ?string
|
|
||||||
{
|
|
||||||
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
|
||||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
|
||||||
|
|
||||||
return match ($status) {
|
|
||||||
OperationRunStatus::Queued->value => 'Queued',
|
|
||||||
OperationRunStatus::Running->value => 'Running',
|
|
||||||
OperationRunStatus::Completed->value => match ($outcome) {
|
|
||||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
|
||||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
|
||||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
|
||||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
|
||||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
|
||||||
default => 'Completed',
|
|
||||||
},
|
|
||||||
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function stringifySummaryValue(mixed $value): string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$value === null => '—',
|
|
||||||
is_bool($value) => $value ? 'Yes' : 'No',
|
|
||||||
is_scalar($value) => (string) $value,
|
|
||||||
default => '—',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function executeGeneration(array $data): void
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
|
||||||
tenant: $tenant,
|
|
||||||
user: $user,
|
|
||||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $snapshot->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Snapshot already available')
|
|
||||||
->body('A matching active snapshot already exists. No new run was started.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_snapshot')
|
|
||||||
->label('View snapshot')
|
|
||||||
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Create snapshot queued')
|
|
||||||
->body('The snapshot is being generated in the background.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
|
|
||||||
class ListEvidenceSnapshots extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_snapshot')
|
|
||||||
->label('Create snapshot')
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
|
|
||||||
->form([
|
|
||||||
Section::make('Snapshot options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('allow_stale')
|
|
||||||
->label('Allow stale dimensions')
|
|
||||||
->default(false),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewEvidenceSnapshot extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = EvidenceSnapshotResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
|
||||||
Actions\Action::make('view_review_pack')
|
|
||||||
->label('View review pack')
|
|
||||||
->icon('heroicon-o-document-text')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$pack = $this->latestReviewPack();
|
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('refresh_snapshot')
|
|
||||||
->label('Refresh evidence')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Refresh evidence queued')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('expire_snapshot')
|
|
||||||
->label('Expire snapshot')
|
|
||||||
->icon('heroicon-o-x-circle')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
|
||||||
$this->refreshFormData(['status', 'expires_at']);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Snapshot expired')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewPack(): ?ReviewPack
|
|
||||||
{
|
|
||||||
return $this->record->reviewPacks()
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,488 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\FindingExceptionEvidenceReference;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingExceptionResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static ?string $model = FindingException::class;
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-exclamation';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Risk exceptions';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 60;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::shouldRegisterNavigation();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::FINDING_EXCEPTION_VIEW, $tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ! $record instanceof FindingException
|
|
||||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'List header links back to findings where exception requests originate.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()
|
|
||||||
->with(static::relationshipsForView());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record, parent::getEloquentQuery()->with(static::relationshipsForView()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Exception')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus)),
|
|
||||||
TextEntry::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity)),
|
|
||||||
TextEntry::make('governance_warning')
|
|
||||||
->label('Governance warning')
|
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
|
||||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
|
||||||
->columnSpanFull()
|
|
||||||
->visible(fn (FindingException $record): bool => static::governanceWarning($record) !== null),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('finding_summary')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record)),
|
|
||||||
TextEntry::make('requester.name')->label('Requested by')->placeholder('—'),
|
|
||||||
TextEntry::make('owner.name')->label('Owner')->placeholder('—'),
|
|
||||||
TextEntry::make('approver.name')->label('Approved by')->placeholder('—'),
|
|
||||||
TextEntry::make('requested_at')->label('Requested')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('approved_at')->label('Approved')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('review_due_at')->label('Review due')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('effective_from')->label('Effective from')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('expires_at')->label('Expires')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('request_reason')->label('Request reason')->columnSpanFull(),
|
|
||||||
TextEntry::make('approval_reason')->label('Approval reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
TextEntry::make('rejection_reason')->label('Rejection reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Decision history')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('decisions')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('decision_type')->label('Decision'),
|
|
||||||
TextEntry::make('actor.name')->label('Actor')->placeholder('—'),
|
|
||||||
TextEntry::make('decided_at')->label('Decided')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('reason')->label('Reason')->placeholder('—')->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
]),
|
|
||||||
Section::make('Evidence references')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('evidenceReferences')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('label')->label('Label'),
|
|
||||||
TextEntry::make('source_type')->label('Source'),
|
|
||||||
TextEntry::make('source_id')->label('Source ID')->placeholder('—'),
|
|
||||||
TextEntry::make('source_fingerprint')->label('Fingerprint')->placeholder('—'),
|
|
||||||
TextEntry::make('measured_at')->label('Measured')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('summary_payload')
|
|
||||||
->label('Summary')
|
|
||||||
->state(function (FindingExceptionEvidenceReference $record): ?string {
|
|
||||||
if ($record->summary_payload === [] || $record->summary_payload === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_encode($record->summary_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: null;
|
|
||||||
})
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
])
|
|
||||||
->visible(fn (FindingException $record): bool => $record->evidenceReferences->isNotEmpty()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('requested_at', 'desc')
|
|
||||||
->paginated(TablePaginationProfiles::resource())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (FindingException $record): string => static::getUrl('view', ['record' => $record]))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingExceptionStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingRiskGovernanceValidity))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('finding_summary')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (FindingException $record): string => static::findingSummary($record))
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('governance_warning')
|
|
||||||
->label('Governance warning')
|
|
||||||
->state(fn (FindingException $record): ?string => static::governanceWarning($record))
|
|
||||||
->color(fn (FindingException $record): string => static::governanceWarningColor($record))
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('requester.name')
|
|
||||||
->label('Requested by')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('owner.name')
|
|
||||||
->label('Owner')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('review_due_at')
|
|
||||||
->label('Review due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('requested_at')
|
|
||||||
->label('Requested')
|
|
||||||
->dateTime()
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionStatuses()),
|
|
||||||
SelectFilter::make('current_validity_state')
|
|
||||||
->label('Validity')
|
|
||||||
->options(FilterOptionCatalog::findingExceptionValidityStates()),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('renew_exception')
|
|
||||||
->label('Renew exception')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRenewed())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Select::make('owner_user_id')
|
|
||||||
->label('Owner')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => static::tenantMemberOptions())
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Renewal reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Requested expiry')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->defaultItems(0)
|
|
||||||
->collapsed(),
|
|
||||||
])
|
|
||||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $record->tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->renew($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request submitted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Action::make('revoke_exception')
|
|
||||||
->label('Revoke exception')
|
|
||||||
->icon('heroicon-o-no-symbol')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (FindingException $record): bool => static::canManageRecord($record) && $record->canBeRevoked())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('revocation_reason')
|
|
||||||
->label('Revocation reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (FindingException $record, array $data, FindingExceptionService $service): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->revoke($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revocation failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revoked')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No exceptions match this view')
|
|
||||||
->emptyStateDescription('Exception requests are created from finding detail when a governed risk acceptance review is needed.')
|
|
||||||
->emptyStateIcon('heroicon-o-shield-exclamation')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('open_findings')
|
|
||||||
->label('Open findings')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => FindingResource::getUrl('index')),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListFindingExceptions::route('/'),
|
|
||||||
'view' => Pages\ViewFindingException::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string|array<int|string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function relationshipsForView(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'tenant',
|
|
||||||
'requester',
|
|
||||||
'owner',
|
|
||||||
'approver',
|
|
||||||
'currentDecision',
|
|
||||||
'decisions.actor',
|
|
||||||
'evidenceReferences',
|
|
||||||
'finding' => fn ($query) => $query->withSubjectDisplayName(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private static function tenantMemberOptions(): array
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
|
||||||
->pluck('users.name', 'users.id')
|
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function findingSummary(FindingException $record): string
|
|
||||||
{
|
|
||||||
$summary = $record->finding?->resolvedSubjectDisplayName();
|
|
||||||
|
|
||||||
if (is_string($summary) && trim($summary) !== '') {
|
|
||||||
return trim($summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Finding #'.$record->finding_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function canManageRecord(FindingException $record): bool
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User
|
|
||||||
&& $record->tenant instanceof Tenant
|
|
||||||
&& $user->canAccessTenant($record->tenant)
|
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function governanceWarning(FindingException $record): ?string
|
|
||||||
{
|
|
||||||
$finding = $record->relationLoaded('finding')
|
|
||||||
? $record->finding
|
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
|
||||||
|
|
||||||
if (! $finding instanceof \App\Models\Finding) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(FindingRiskGovernanceResolver::class)->resolveWarningMessage($finding, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function governanceWarningColor(FindingException $record): string
|
|
||||||
{
|
|
||||||
$finding = $record->relationLoaded('finding')
|
|
||||||
? $record->finding
|
|
||||||
: $record->finding()->withSubjectDisplayName()->first();
|
|
||||||
|
|
||||||
if ($finding instanceof \App\Models\Finding && $record->requiresFreshDecisionForFinding($finding)) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'danger';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListFindingExceptions extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('open_findings')
|
|
||||||
->label('Open findings')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(FindingResource::getUrl('index')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
class ViewFindingException extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = FindingExceptionResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return FindingExceptionResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('open_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}),
|
|
||||||
Action::make('renew_exception')
|
|
||||||
->label('Renew exception')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
|
||||||
->fillForm(fn (): array => [
|
|
||||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
|
||||||
])
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Select::make('owner_user_id')
|
|
||||||
->label('Owner')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => FindingExceptionResource::canViewAny() ? $this->tenantMemberOptions() : [])
|
|
||||||
->searchable(),
|
|
||||||
Textarea::make('request_reason')
|
|
||||||
->label('Renewal reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
DateTimePicker::make('review_due_at')
|
|
||||||
->label('Review due at')
|
|
||||||
->required()
|
|
||||||
->seconds(false),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Requested expiry')
|
|
||||||
->seconds(false),
|
|
||||||
Repeater::make('evidence_references')
|
|
||||||
->label('Evidence references')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('label')
|
|
||||||
->label('Label')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_type')
|
|
||||||
->label('Source type')
|
|
||||||
->required()
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_id')
|
|
||||||
->label('Source ID')
|
|
||||||
->maxLength(255),
|
|
||||||
TextInput::make('source_fingerprint')
|
|
||||||
->label('Fingerprint')
|
|
||||||
->maxLength(255),
|
|
||||||
DateTimePicker::make('measured_at')
|
|
||||||
->label('Measured at')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->defaultItems(0)
|
|
||||||
->collapsed(),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->renew($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Renewal request submitted')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'review_due_at']);
|
|
||||||
}),
|
|
||||||
Action::make('revoke_exception')
|
|
||||||
->label('Revoke exception')
|
|
||||||
->icon('heroicon-o-no-symbol')
|
|
||||||
->color('danger')
|
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRevoked())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->form([
|
|
||||||
Textarea::make('revocation_reason')
|
|
||||||
->label('Revocation reason')
|
|
||||||
->rows(4)
|
|
||||||
->required()
|
|
||||||
->maxLength(2000),
|
|
||||||
])
|
|
||||||
->action(function (array $data, FindingExceptionService $service): void {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $user instanceof User) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$service->revoke($record, $user, $data);
|
|
||||||
} catch (InvalidArgumentException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revocation failed')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Exception revoked')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'current_validity_state', 'revocation_reason', 'revoked_at']);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function tenantMemberOptions(): array
|
|
||||||
{
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return \App\Models\TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->join('users', 'users.id', '=', 'tenant_memberships.user_id')
|
|
||||||
->orderBy('users.name')
|
|
||||||
->pluck('users.name', 'users.id')
|
|
||||||
->mapWithKeys(fn (string $name, int|string $id): array => [(int) $id => $name])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageRecord(): bool
|
|
||||||
{
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
&& $record->tenant instanceof Tenant
|
|
||||||
&& $user instanceof User
|
|
||||||
&& $user->canAccessTenant($record->tenant)
|
|
||||||
&& $user->can(Capabilities::FINDING_EXCEPTION_MANAGE, $record->tenant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingResource\Pages;
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
use App\Services\Findings\FindingExceptionService;
|
|
||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
use App\Services\Findings\FindingWorkflowService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -38,8 +34,6 @@
|
|||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Repeater;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@ -61,7 +55,6 @@
|
|||||||
|
|
||||||
class FindingResource extends Resource
|
class FindingResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = Finding::class;
|
protected static ?string $model = Finding::class;
|
||||||
@ -119,8 +112,7 @@ public static function canView(Model $record): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($record instanceof Finding) {
|
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;
|
return true;
|
||||||
@ -228,62 +220,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->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')
|
Section::make('Evidence')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('redaction_integrity_note')
|
TextEntry::make('redaction_integrity_note')
|
||||||
@ -816,7 +752,6 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$workflow->triage($record, $tenant, $user);
|
$workflow->triage($record, $tenant, $user);
|
||||||
$triagedCount++;
|
$triagedCount++;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@ -897,7 +832,6 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
$workflow->assign($record, $tenant, $user, $assigneeUserId, $ownerUserId);
|
||||||
$assignedCount++;
|
$assignedCount++;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@ -972,7 +906,6 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$workflow->resolve($record, $tenant, $user, $reason);
|
$workflow->resolve($record, $tenant, $user, $reason);
|
||||||
$resolvedCount++;
|
$resolvedCount++;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@ -1047,7 +980,6 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$workflow->close($record, $tenant, $user, $reason);
|
$workflow->close($record, $tenant, $user, $reason);
|
||||||
$closedCount++;
|
$closedCount++;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@ -1075,6 +1007,79 @@ public static function table(Table $table): Table
|
|||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply(),
|
->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'),
|
])->label('More'),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('No findings match this view')
|
->emptyStateHeading('No findings match this view')
|
||||||
@ -1084,19 +1089,12 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
|
||||||
->withSubjectDisplayName();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
return parent::getEloquentQuery()
|
||||||
{
|
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||||
return static::resolveTenantOwnedRecordOrFail(
|
->withSubjectDisplayName()
|
||||||
$key,
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||||
parent::getEloquentQuery()
|
|
||||||
->with(['assigneeUser', 'ownerUser', 'closedByUser', 'findingException.owner', 'findingException.approver', 'findingException.currentDecision'])
|
|
||||||
->withSubjectDisplayName(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1172,9 +1170,7 @@ public static function workflowActions(): array
|
|||||||
static::assignAction(),
|
static::assignAction(),
|
||||||
static::resolveAction(),
|
static::resolveAction(),
|
||||||
static::closeAction(),
|
static::closeAction(),
|
||||||
static::requestExceptionAction(),
|
static::riskAcceptAction(),
|
||||||
static::renewExceptionAction(),
|
|
||||||
static::revokeExceptionAction(),
|
|
||||||
static::reopenAction(),
|
static::reopenAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -1186,7 +1182,7 @@ public static function triageAction(): Actions\Action
|
|||||||
->label('Triage')
|
->label('Triage')
|
||||||
->icon('heroicon-o-check')
|
->icon('heroicon-o-check')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_NEW,
|
Finding::STATUS_NEW,
|
||||||
Finding::STATUS_REOPENED,
|
Finding::STATUS_REOPENED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
@ -1212,7 +1208,7 @@ public static function startProgressAction(): Actions\Action
|
|||||||
->label('Start progress')
|
->label('Start progress')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (Finding $record): bool => in_array(static::freshWorkflowStatus($record), [
|
->visible(fn (Finding $record): bool => in_array((string) $record->status, [
|
||||||
Finding::STATUS_TRIAGED,
|
Finding::STATUS_TRIAGED,
|
||||||
Finding::STATUS_ACKNOWLEDGED,
|
Finding::STATUS_ACKNOWLEDGED,
|
||||||
], true))
|
], true))
|
||||||
@ -1237,7 +1233,7 @@ public static function assignAction(): Actions\Action
|
|||||||
->label('Assign')
|
->label('Assign')
|
||||||
->icon('heroicon-o-user-plus')
|
->icon('heroicon-o-user-plus')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->fillForm(fn (Finding $record): array => [
|
->fillForm(fn (Finding $record): array => [
|
||||||
'assignee_user_id' => $record->assignee_user_id,
|
'assignee_user_id' => $record->assignee_user_id,
|
||||||
'owner_user_id' => $record->owner_user_id,
|
'owner_user_id' => $record->owner_user_id,
|
||||||
@ -1281,7 +1277,7 @@ public static function resolveAction(): Actions\Action
|
|||||||
->label('Resolve')
|
->label('Resolve')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('resolved_reason')
|
Textarea::make('resolved_reason')
|
||||||
@ -1316,7 +1312,6 @@ public static function closeAction(): Actions\Action
|
|||||||
->label('Close')
|
->label('Close')
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Textarea::make('closed_reason')
|
Textarea::make('closed_reason')
|
||||||
@ -1344,153 +1339,36 @@ public static function closeAction(): Actions\Action
|
|||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function requestExceptionAction(): Actions\Action
|
public static function riskAcceptAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('request_exception')
|
Actions\Action::make('risk_accept')
|
||||||
->label('Request exception')
|
->label('Risk accept')
|
||||||
->icon('heroicon-o-shield-exclamation')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (Finding $record): bool => static::freshWorkflowRecord($record)->hasOpenStatus())
|
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->form([
|
->form([
|
||||||
Select::make('owner_user_id')
|
Textarea::make('closed_reason')
|
||||||
->label('Owner')
|
->label('Risk acceptance reason')
|
||||||
|
->rows(3)
|
||||||
->required()
|
->required()
|
||||||
->options(fn (): array => static::tenantMemberOptions())
|
->maxLength(255),
|
||||||
->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, FindingExceptionService $service): void {
|
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||||
static::runExceptionRequestMutation($record, $data, $service);
|
static::runWorkflowMutation(
|
||||||
|
record: $record,
|
||||||
|
successTitle: 'Finding marked as risk accepted',
|
||||||
|
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->riskAccept(
|
||||||
|
$finding,
|
||||||
|
$tenant,
|
||||||
|
$user,
|
||||||
|
(string) ($data['closed_reason'] ?? ''),
|
||||||
|
),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::FINDING_EXCEPTION_MANAGE)
|
->requireCapability(Capabilities::TENANT_FINDINGS_RISK_ACCEPT)
|
||||||
->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)
|
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
@ -1503,7 +1381,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus(static::freshWorkflowStatus($record)))
|
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||||
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
->action(function (Finding $record, FindingWorkflowService $workflow): void {
|
||||||
static::runWorkflowMutation(
|
static::runWorkflowMutation(
|
||||||
record: $record,
|
record: $record,
|
||||||
@ -1523,7 +1401,6 @@ public static function reopenAction(): Actions\Action
|
|||||||
*/
|
*/
|
||||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||||
{
|
{
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -1540,15 +1417,6 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $record->workspace_id !== (int) $tenant->workspace_id) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Finding belongs to a different workspace')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$callback($record, $tenant, $user);
|
$callback($record, $tenant, $user);
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
@ -1567,194 +1435,6 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
->send();
|
->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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -23,7 +23,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@ -33,26 +32,14 @@ class ListFindings extends ListRecords
|
|||||||
|
|
||||||
protected static string $resource = FindingResource::class;
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->syncCanonicalAdminTenantFilterState();
|
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||||
|
$this->getTableFiltersSessionKey(),
|
||||||
|
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||||
|
request: request(),
|
||||||
|
tenantFilterName: null,
|
||||||
|
);
|
||||||
|
|
||||||
parent::mount();
|
parent::mount();
|
||||||
}
|
}
|
||||||
@ -259,7 +246,15 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
protected function buildAllMatchingQuery(): Builder
|
protected function buildAllMatchingQuery(): Builder
|
||||||
{
|
{
|
||||||
$query = FindingResource::getEloquentQuery();
|
$query = Finding::query();
|
||||||
|
|
||||||
|
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||||
|
|
||||||
|
if (! is_numeric($tenantId)) {
|
||||||
|
return $query->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->where('tenant_id', (int) $tenantId);
|
||||||
|
|
||||||
$query->where('status', Finding::STATUS_NEW);
|
$query->where('status', Finding::STATUS_NEW);
|
||||||
|
|
||||||
@ -309,16 +304,6 @@ protected function buildAllMatchingQuery(): Builder
|
|||||||
return $query;
|
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
|
private function filterIsActive(string $filterName): bool
|
||||||
{
|
{
|
||||||
$state = $this->getTableFilterState($filterName);
|
$state = $this->getTableFilterState($filterName);
|
||||||
|
|||||||
@ -8,17 +8,11 @@
|
|||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewFinding extends ViewRecord
|
class ViewFinding extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = FindingResource::class;
|
protected static string $resource = FindingResource::class;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return FindingResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
@ -39,7 +38,6 @@
|
|||||||
|
|
||||||
class InventoryItemResource extends Resource
|
class InventoryItemResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = InventoryItem::class;
|
protected static ?string $model = InventoryItem::class;
|
||||||
@ -336,13 +334,11 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||||
->with('lastSeenRun');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
return parent::getEloquentQuery()
|
||||||
{
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
->with('lastSeenRun');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
|
|||||||
@ -174,8 +174,6 @@ protected function getHeaderActions(): array
|
|||||||
],
|
],
|
||||||
context: array_merge($computed['selection'], [
|
context: array_merge($computed['selection'], [
|
||||||
'selection_hash' => $computed['selection_hash'],
|
'selection_hash' => $computed['selection_hash'],
|
||||||
'execution_authority_mode' => 'actor_bound',
|
|
||||||
'required_capability' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
|
||||||
'target_scope' => [
|
'target_scope' => [
|
||||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -4,14 +4,8 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewInventoryItem extends ViewRecord
|
class ViewInventoryItem extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -323,15 +323,6 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
? $factory->keyFact('Viewer context', $referencedTenantLifecycle->contextNote)
|
||||||
: null,
|
: null,
|
||||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||||
static::blockedExecutionReasonCode($record) !== null
|
|
||||||
? $factory->keyFact('Blocked reason', static::blockedExecutionReasonCode($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionDetail($record) !== null
|
|
||||||
? $factory->keyFact('Blocked detail', static::blockedExecutionDetail($record))
|
|
||||||
: null,
|
|
||||||
static::blockedExecutionSource($record) !== null
|
|
||||||
? $factory->keyFact('Blocked by', static::blockedExecutionSource($record))
|
|
||||||
: null,
|
|
||||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
||||||
])),
|
])),
|
||||||
),
|
),
|
||||||
@ -378,7 +369,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
|
|||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
id: 'failures',
|
id: 'failures',
|
||||||
kind: 'operational_context',
|
kind: 'operational_context',
|
||||||
title: (string) $record->outcome === OperationRunOutcome::Blocked->value ? 'Blocked execution details' : 'Failures',
|
title: 'Failures',
|
||||||
view: 'filament.infolists.entries.snapshot-json',
|
view: 'filament.infolists.entries.snapshot-json',
|
||||||
viewData: ['payload' => $record->failure_summary ?? []],
|
viewData: ['payload' => $record->failure_summary ?? []],
|
||||||
),
|
),
|
||||||
@ -460,51 +451,6 @@ private static function summaryCountFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function blockedExecutionReasonCode(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
|
|
||||||
$reasonCode = data_get($context, 'execution_legitimacy.reason_code')
|
|
||||||
?? data_get($context, 'reason_code')
|
|
||||||
?? data_get($record->failure_summary, '0.reason_code');
|
|
||||||
|
|
||||||
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function blockedExecutionDetail(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = data_get($record->failure_summary, '0.message');
|
|
||||||
|
|
||||||
return is_string($message) && trim($message) !== '' ? trim($message) : 'Execution was refused before work began.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function blockedExecutionSource(OperationRun $record): ?string
|
|
||||||
{
|
|
||||||
if ((string) $record->outcome !== OperationRunOutcome::Blocked->value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$context = is_array($record->context) ? $record->context : [];
|
|
||||||
$blockedBy = $context['blocked_by'] ?? null;
|
|
||||||
|
|
||||||
if (! is_string($blockedBy) || trim($blockedBy) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (trim($blockedBy)) {
|
|
||||||
'queued_execution_legitimacy' => 'Execution legitimacy revalidation',
|
|
||||||
default => ucfirst(str_replace('_', ' ', trim($blockedBy))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
|
||||||
use App\Filament\Resources\PolicyResource\Pages;
|
use App\Filament\Resources\PolicyResource\Pages;
|
||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
@ -56,9 +54,7 @@
|
|||||||
|
|
||||||
class PolicyResource extends Resource
|
class PolicyResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
use ScopesGlobalSearchToTenant;
|
|
||||||
|
|
||||||
protected static ?string $model = Policy::class;
|
protected static ?string $model = Policy::class;
|
||||||
|
|
||||||
@ -1014,25 +1010,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::getTenantOwnedEloquentQuery()
|
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
->withCount('versions')
|
->withCount('versions')
|
||||||
->with([
|
->with([
|
||||||
'versions' => fn ($query) => $query->orderByDesc('captured_at')->limit(1),
|
'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
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -3,20 +3,12 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
class ListPolicies extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = PolicyResource::class;
|
protected static string $resource = PolicyResource::class;
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->syncCanonicalAdminTenantFilterState();
|
|
||||||
|
|
||||||
parent::mount();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -30,14 +22,4 @@ protected function getTableEmptyStateActions(): array
|
|||||||
PolicyResource::makeSyncAction(),
|
PolicyResource::makeSyncAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncCanonicalAdminTenantFilterState(): void
|
|
||||||
{
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
tenantSensitiveFilters: [],
|
|
||||||
request: request(),
|
|
||||||
tenantFilterName: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class ViewPolicy extends ViewRecord
|
class ViewPolicy extends ViewRecord
|
||||||
@ -25,11 +24,6 @@ class ViewPolicy extends ViewRecord
|
|||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return PolicyResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getActions(): array
|
protected function getActions(): array
|
||||||
{
|
{
|
||||||
return [$this->makeCaptureSnapshotAction()];
|
return [$this->makeCaptureSnapshotAction()];
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -32,19 +31,6 @@ class VersionsRelationManager extends RelationManager
|
|||||||
|
|
||||||
protected static string $relationship = 'versions';
|
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
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
@ -69,8 +55,7 @@ public function table(Table $table): Table
|
|||||||
->label('Preview only (dry-run)')
|
->label('Preview only (dry-run)')
|
||||||
->default(true),
|
->default(true),
|
||||||
])
|
])
|
||||||
->action(function (mixed $record, array $data, RestoreService $restoreService) {
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
$record = $this->resolveOwnerScopedVersionRecord($this->getOwnerRecord(), $record);
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -193,26 +178,4 @@ public function table(Table $table): Table
|
|||||||
->emptyStateHeading('No versions captured')
|
->emptyStateHeading('No versions captured')
|
||||||
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveOwnerScopedVersionRecord(Policy $policy, mixed $record): PolicyVersion
|
|
||||||
{
|
|
||||||
$recordId = $record instanceof PolicyVersion
|
|
||||||
? (int) $record->getKey()
|
|
||||||
: (is_numeric($record) ? (int) $record : 0);
|
|
||||||
|
|
||||||
if ($recordId <= 0) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedRecord = $policy->versions()
|
|
||||||
->where('tenant_id', (int) $policy->tenant_id)
|
|
||||||
->whereKey($recordId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $resolvedRecord instanceof PolicyVersion) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolvedRecord;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
|
||||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||||
@ -61,9 +59,7 @@
|
|||||||
|
|
||||||
class PolicyVersionResource extends Resource
|
class PolicyVersionResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
use ScopesGlobalSearchToTenant;
|
|
||||||
|
|
||||||
protected static ?string $model = PolicyVersion::class;
|
protected static ?string $model = PolicyVersion::class;
|
||||||
|
|
||||||
@ -897,6 +893,7 @@ public static function table(Table $table): Table
|
|||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
$tenantId = $tenant->getKey();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
@ -906,7 +903,8 @@ public static function getEloquentQuery(): Builder
|
|||||||
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
|| $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)
|
||||||
);
|
);
|
||||||
|
|
||||||
return static::getTenantOwnedEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
->when(! $canSeeBaselinePurposeEvidence, function (Builder $query): Builder {
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
@ -920,36 +918,6 @@ public static function getEloquentQuery(): Builder
|
|||||||
->with('policy');
|
->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{
|
* @return list<array{
|
||||||
* key: string,
|
* key: string,
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Filament\Support\Enums\Width;
|
use Filament\Support\Enums\Width;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewPolicyVersion extends ViewRecord
|
class ViewPolicyVersion extends ViewRecord
|
||||||
{
|
{
|
||||||
@ -17,11 +16,6 @@ class ViewPolicyVersion extends ViewRecord
|
|||||||
|
|
||||||
protected Width|string|null $maxContentWidth = Width::Full;
|
protected Width|string|null $maxContentWidth = Width::Full;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return PolicyVersionResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Contracts\Hardening\WriteGateInterface;
|
use App\Contracts\Hardening\WriteGateInterface;
|
||||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||||
@ -67,7 +66,6 @@
|
|||||||
|
|
||||||
class RestoreRunResource extends Resource
|
class RestoreRunResource extends Resource
|
||||||
{
|
{
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static ?string $model = RestoreRun::class;
|
protected static ?string $model = RestoreRun::class;
|
||||||
@ -244,44 +242,18 @@ public static function makeCreateAction(): Actions\CreateAction
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
|
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||||
->with('backupSet');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
return parent::getEloquentQuery()
|
||||||
{
|
->with('backupSet')
|
||||||
return static::resolveTenantOwnedRecordOrFail(
|
->when(
|
||||||
$key,
|
$tenantId !== null,
|
||||||
parent::getEloquentQuery()->withTrashed()->with('backupSet'),
|
fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId),
|
||||||
);
|
)
|
||||||
}
|
->when(
|
||||||
|
$tenantId === null,
|
||||||
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
|
fn (Builder $query): Builder => $query->whereRaw('1 = 0'),
|
||||||
{
|
);
|
||||||
$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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -874,8 +846,6 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -907,8 +877,6 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
|
||||||
|
|
||||||
if (! $record->isDeletable()) {
|
if (! $record->isDeletable()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run cannot be archived')
|
->title('Restore run cannot be archived')
|
||||||
@ -950,8 +918,6 @@ public static function table(Table $table): Table
|
|||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
@ -1012,7 +978,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -1082,7 +1048,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -1172,7 +1138,7 @@ public static function table(Table $table): Table
|
|||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = static::resolveProtectedRestoreRunIds($records);
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return;
|
return;
|
||||||
@ -1728,8 +1694,6 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||||
'execution_authority_mode' => 'actor_bound',
|
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
|
||||||
],
|
],
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
@ -1961,7 +1925,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
HasTable $livewire
|
HasTable $livewire
|
||||||
) {
|
) {
|
||||||
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
@ -2129,8 +2092,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
'restore_run_id' => (int) $newRun->getKey(),
|
'restore_run_id' => (int) $newRun->getKey(),
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
||||||
'execution_authority_mode' => 'actor_bound',
|
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
|
||||||
],
|
],
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,42 +3,12 @@
|
|||||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
||||||
|
|
||||||
class ListRestoreRuns extends ListRecords
|
class ListRestoreRuns extends ListRecords
|
||||||
{
|
{
|
||||||
protected static string $resource = RestoreRunResource::class;
|
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
|
private function tableHasRecords(): bool
|
||||||
{
|
{
|
||||||
return $this->getTableRecords()->count() > 0;
|
return $this->getTableRecords()->count() > 0;
|
||||||
|
|||||||
@ -4,14 +4,8 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewRestoreRun extends ViewRecord
|
class ViewRestoreRun extends ViewRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return RestoreRunResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -166,21 +164,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('initiator.name')->label('Initiated by')->placeholder('—'),
|
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')
|
TextEntry::make('operationRun.id')
|
||||||
->label('Operation run')
|
->label('Operation run')
|
||||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||||
@ -194,33 +177,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Evidence snapshot')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('summary.evidence_resolution.outcome')
|
|
||||||
->label('Resolution')
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
|
||||||
->label('Snapshot')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
|
||||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
|
||||||
->label('Snapshot completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
|
||||||
->label('Snapshot fingerprint')
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—'),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,10 +201,6 @@ public static function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—'),
|
->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')
|
Tables\Columns\TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
@ -379,23 +331,7 @@ public static function executeGeneration(array $data): void
|
|||||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
$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) {
|
if (! $reviewPack->wasRecentlyCreated) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -41,10 +41,7 @@
|
|||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Tenants\TenantActionDescriptor;
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
use App\Support\Tenants\TenantActionSurface;
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantLifecyclePresentation;
|
use App\Support\Tenants\TenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantOperabilityOutcome;
|
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -231,7 +228,7 @@ public static function getGlobalSearchEloquentQuery(): Builder
|
|||||||
return static::getEloquentQuery()->whereRaw('1 = 0');
|
return static::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::tenantOperability()->applyAdministrativeDiscoverabilityScope(
|
return static::tenantOperability()->applySelectableScope(
|
||||||
static::getEloquentQuery(),
|
static::getEloquentQuery(),
|
||||||
(new Tenant)->getTable(),
|
(new Tenant)->getTable(),
|
||||||
);
|
);
|
||||||
@ -516,7 +513,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => static::verificationActionVisible($record))
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
@ -1084,26 +1081,6 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
|||||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function verificationActionVisible(Tenant $tenant): bool
|
|
||||||
{
|
|
||||||
$outcome = static::verificationReadinessOutcome($tenant);
|
|
||||||
|
|
||||||
return $outcome->allowed || $outcome->isDeniedForCapability();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function verificationReadinessOutcome(Tenant $tenant): TenantOperabilityOutcome
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return static::tenantOperability()->outcomeFor(
|
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::VerificationReadinessEligibility,
|
|
||||||
actor: $user instanceof User ? $user : null,
|
|
||||||
workspaceId: app(WorkspaceContext::class)->currentWorkspaceId(request()),
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
private static function tenantActionDescriptorForSurface(Tenant $tenant, TenantActionSurface $surface, string $key): ?TenantActionDescriptor
|
||||||
{
|
{
|
||||||
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
$descriptor = static::tenantActionCatalog($tenant, $surface)
|
||||||
|
|||||||
@ -87,7 +87,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::verificationActionVisible($record))
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
StartVerification $verification,
|
StartVerification $verification,
|
||||||
|
|||||||
@ -1,562 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\TenantReviewSection;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Support\Enums\TextSize;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class TenantReviewResource extends Resource
|
|
||||||
{
|
|
||||||
use InteractsWithTenantOwnedRecords;
|
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static ?string $model = TenantReview::class;
|
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews';
|
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
|
||||||
|
|
||||||
protected static bool $isGloballySearchable = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-magnifying-glass';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Reviews';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 45;
|
|
||||||
|
|
||||||
public static function shouldRegisterNavigation(): bool
|
|
||||||
{
|
|
||||||
return Filament::getCurrentPanel()?->getId() === 'tenant';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can(Capabilities::TENANT_REVIEW_VIEW, $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
|
||||||
{
|
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User || ! $record instanceof TenantReview) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->can('view', $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create review is available from the review library header.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Primary row actions stay limited to View review and Export executive pack.')
|
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return static::getTenantOwnedEloquentQuery()
|
|
||||||
->with(['tenant', 'evidenceSnapshot', 'operationRun', 'initiator', 'publisher', 'currentExportReviewPack', 'sections'])
|
|
||||||
->latest('generated_at')
|
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
|
||||||
{
|
|
||||||
return static::resolveTenantOwnedRecordOrFail($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Review')
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextEntry::make('tenant.name')->label('Tenant'),
|
|
||||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('published_at')->dateTime()->placeholder('—'),
|
|
||||||
TextEntry::make('evidenceSnapshot.id')
|
|
||||||
->label('Evidence snapshot')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
|
|
||||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('currentExportReviewPack.id')
|
|
||||||
->label('Current export')
|
|
||||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
|
||||||
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
|
|
||||||
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
|
|
||||||
: null),
|
|
||||||
TextEntry::make('fingerprint')
|
|
||||||
->copyable()
|
|
||||||
->placeholder('—')
|
|
||||||
->columnSpanFull()
|
|
||||||
->fontFamily('mono')
|
|
||||||
->size(TextSize::ExtraSmall),
|
|
||||||
])
|
|
||||||
->columns(2)
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Executive posture')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('review_summary')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.tenant-review-summary')
|
|
||||||
->state(fn (TenantReview $record): array => static::summaryPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
Section::make('Sections')
|
|
||||||
->schema([
|
|
||||||
RepeatableEntry::make('sections')
|
|
||||||
->hiddenLabel()
|
|
||||||
->schema([
|
|
||||||
TextEntry::make('title'),
|
|
||||||
TextEntry::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
|
|
||||||
TextEntry::make('measured_at')->dateTime()->placeholder('—'),
|
|
||||||
Section::make('Details')
|
|
||||||
->schema([
|
|
||||||
ViewEntry::make('section_payload')
|
|
||||||
->hiddenLabel()
|
|
||||||
->view('filament.infolists.entries.tenant-review-section')
|
|
||||||
->state(fn (TenantReviewSection $record): array => static::sectionPresentation($record))
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->collapsible()
|
|
||||||
->collapsed()
|
|
||||||
->columnSpanFull(),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
])
|
|
||||||
->columnSpanFull(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('generated_at', 'desc')
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant))
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('completeness_state')
|
|
||||||
->label('Completeness')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness))
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
|
||||||
Tables\Columns\TextColumn::make('summary.section_state_counts.missing')->label('Missing'),
|
|
||||||
Tables\Columns\IconColumn::make('summary.has_ready_export')
|
|
||||||
->label('Export')
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('fingerprint')
|
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\SelectFilter::make('status')
|
|
||||||
->options(collect(TenantReviewStatus::cases())
|
|
||||||
->mapWithKeys(fn (TenantReviewStatus $status): array => [$status->value => Str::headline($status->value)])
|
|
||||||
->all()),
|
|
||||||
Tables\Filters\SelectFilter::make('completeness_state')
|
|
||||||
->options([
|
|
||||||
'complete' => 'Complete',
|
|
||||||
'partial' => 'Partial',
|
|
||||||
'missing' => 'Missing',
|
|
||||||
'stale' => 'Stale',
|
|
||||||
]),
|
|
||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(fn (TenantReview $record): string => static::tenantScopedUrl('view', ['record' => $record], $record->tenant)),
|
|
||||||
UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No tenant reviews yet')
|
|
||||||
->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
|
|
||||||
->emptyStateActions([
|
|
||||||
static::makeCreateReviewAction(
|
|
||||||
name: 'create_first_review',
|
|
||||||
label: 'Create first review',
|
|
||||||
icon: 'heroicon-o-plus',
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => Pages\ListTenantReviews::route('/'),
|
|
||||||
'view' => Pages\ViewTenantReview::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function makeCreateReviewAction(
|
|
||||||
string $name = 'create_review',
|
|
||||||
string $label = 'Create review',
|
|
||||||
string $icon = 'heroicon-o-plus',
|
|
||||||
): Actions\Action {
|
|
||||||
return UiEnforcement::forAction(
|
|
||||||
Actions\Action::make($name)
|
|
||||||
->label($label)
|
|
||||||
->icon($icon)
|
|
||||||
->form([
|
|
||||||
Section::make('Evidence basis')
|
|
||||||
->schema([
|
|
||||||
Select::make('evidence_snapshot_id')
|
|
||||||
->label('Evidence snapshot')
|
|
||||||
->required()
|
|
||||||
->options(fn (): array => static::evidenceSnapshotOptions())
|
|
||||||
->searchable()
|
|
||||||
->helperText('Choose the anchored evidence snapshot for this review.'),
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->action(fn (array $data): mixed => static::executeCreateReview($data)),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
public static function executeCreateReview(array $data): void
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
|
||||||
Notification::make()->danger()->title('Unable to create review — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshotId = $data['evidence_snapshot_id'] ?? null;
|
|
||||||
$snapshot = is_numeric($snapshotId)
|
|
||||||
? EvidenceSnapshot::query()
|
|
||||||
->whereKey((int) $snapshotId)
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->first()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
|
||||||
Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $review->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Review already available')
|
|
||||||
->body('A matching mutable review already exists for this evidence basis.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_review')
|
|
||||||
->label('View review')
|
|
||||||
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
|
|
||||||
->body('The review is being composed in the background.');
|
|
||||||
|
|
||||||
if ($review->operation_run_id) {
|
|
||||||
$toast->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
|
||||||
{
|
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
|
|
||||||
Notification::make()->danger()->title('Unable to export review — missing context.')->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($review->tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can('export', $review)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(ReviewPackService::class);
|
|
||||||
|
|
||||||
if ($service->checkActiveRunForReview($review)) {
|
|
||||||
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->body('An executive pack export is already queued or running for this review.')
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pack = $service->generateFromReview($review, $user, [
|
|
||||||
'include_pii' => true,
|
|
||||||
'include_operations' => true,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $pack->wasRecentlyCreated) {
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Executive pack already available')
|
|
||||||
->body('A matching executive pack already exists for this review.')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_pack')
|
|
||||||
->label('View pack')
|
|
||||||
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->body('The executive pack is being generated in the background.')
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $parameters
|
|
||||||
*/
|
|
||||||
public static function tenantScopedUrl(
|
|
||||||
string $page = 'index',
|
|
||||||
array $parameters = [],
|
|
||||||
?Tenant $tenant = null,
|
|
||||||
?string $panel = null,
|
|
||||||
): string {
|
|
||||||
$panelId = $panel ?? 'tenant';
|
|
||||||
|
|
||||||
return static::getUrl($page, $parameters, panel: $panelId, tenant: $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function evidenceSnapshotOptions(): array
|
|
||||||
{
|
|
||||||
$tenant = Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return EvidenceSnapshot::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->whereNotNull('generated_at')
|
|
||||||
->orderByDesc('generated_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(static fn (EvidenceSnapshot $snapshot): array => [
|
|
||||||
(string) $snapshot->getKey() => sprintf(
|
|
||||||
'#%d · %s · %s',
|
|
||||||
(int) $snapshot->getKey(),
|
|
||||||
Str::headline((string) $snapshot->completeness_state),
|
|
||||||
$snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function summaryPresentation(TenantReview $record): array
|
|
||||||
{
|
|
||||||
$summary = is_array($record->summary) ? $record->summary : [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
|
||||||
'metrics' => [
|
|
||||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
|
||||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
|
||||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
|
||||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function sectionPresentation(TenantReviewSection $section): array
|
|
||||||
{
|
|
||||||
$summary = is_array($section->summary_payload) ? $section->summary_payload : [];
|
|
||||||
$render = is_array($section->render_payload) ? $section->render_payload : [];
|
|
||||||
$review = $section->tenantReview;
|
|
||||||
$tenant = $section->tenant;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'summary' => collect($summary)->map(function (mixed $value, string $key): ?array {
|
|
||||||
if (is_array($value) || $value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => Str::headline($key),
|
|
||||||
'value' => (string) $value,
|
|
||||||
];
|
|
||||||
})->filter()->values()->all(),
|
|
||||||
'highlights' => is_array($render['highlights'] ?? null) ? $render['highlights'] : [],
|
|
||||||
'entries' => is_array($render['entries'] ?? null) ? $render['entries'] : [],
|
|
||||||
'disclosure' => is_string($render['disclosure'] ?? null) ? $render['disclosure'] : null,
|
|
||||||
'next_actions' => is_array($render['next_actions'] ?? null) ? $render['next_actions'] : [],
|
|
||||||
'empty_state' => is_string($render['empty_state'] ?? null) ? $render['empty_state'] : null,
|
|
||||||
'links' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenantReviews extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantReviewResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
TenantReviewResource::makeCreateReviewAction(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ViewTenantReview extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantReviewResource::class;
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
|
||||||
{
|
|
||||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function authorizeAccess(): void
|
|
||||||
{
|
|
||||||
$tenant = TenantReviewResource::panelTenantContext();
|
|
||||||
$record = $this->getRecord();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant || ! $record instanceof TenantReview) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can('view', $record)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id
|
|
||||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_export')
|
|
||||||
->label('View executive pack')
|
|
||||||
->icon('heroicon-o-document-arrow-down')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
|
||||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
|
||||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
Actions\Action::make('view_evidence')
|
|
||||||
->label('View evidence snapshot')
|
|
||||||
->icon('heroicon-o-shield-check')
|
|
||||||
->color('gray')
|
|
||||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
|
||||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
|
||||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
|
||||||
: null),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('refresh_review')
|
|
||||||
->label('Refresh review')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewService::class)->refresh($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to refresh review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Refresh review queued')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('publish_review')
|
|
||||||
->label('Publish review')
|
|
||||||
->icon('heroicon-o-check-badge')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(TenantReviewLifecycleService::class)->publish($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to publish review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
|
||||||
Notification::make()->success()->title('Review published')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
Actions\ActionGroup::make([
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('create_next_review')
|
|
||||||
->label('Create next review')
|
|
||||||
->icon('heroicon-o-document-duplicate')
|
|
||||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$nextReview = app(TenantReviewLifecycleService::class)->createNextReview($this->record, $user);
|
|
||||||
} catch (\Throwable $throwable) {
|
|
||||||
Notification::make()->danger()->title('Unable to create next review')->body($throwable->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirect(TenantReviewResource::tenantScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('archive_review')
|
|
||||||
->label('Archive review')
|
|
||||||
->icon('heroicon-o-archive-box')
|
|
||||||
->color('danger')
|
|
||||||
->hidden(fn (): bool => $this->record->statusEnum()->isTerminal())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->action(function (): void {
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
app(TenantReviewLifecycleService::class)->archive($this->record, $user);
|
|
||||||
$this->refreshFormData(['status', 'archived_at']);
|
|
||||||
|
|
||||||
Notification::make()->success()->title('Review archived')->send();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->apply(),
|
|
||||||
])
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-m-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -107,7 +107,10 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-magnifying-glass')
|
->icon('heroicon-o-magnifying-glass')
|
||||||
->form($this->findingsScopeForm())
|
->form($this->findingsScopeForm())
|
||||||
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
->action(function (array $data, FindingsLifecycleBackfillRunbookService $runbookService): void {
|
||||||
$scope = $this->trustedFindingsScopeFromFormData($data, app(AllowedTenantUniverse::class));
|
$scope = FindingsLifecycleBackfillScope::fromArray([
|
||||||
|
'mode' => $data['scope_mode'] ?? null,
|
||||||
|
'tenant_id' => $data['tenant_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->findingsScopeMode = $scope->mode;
|
$this->findingsScopeMode = $scope->mode;
|
||||||
$this->findingsTenantId = $scope->tenantId;
|
$this->findingsTenantId = $scope->tenantId;
|
||||||
@ -139,7 +142,9 @@ protected function getHeaderActions(): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope = $this->trustedFindingsScopeFromState(app(AllowedTenantUniverse::class));
|
$scope = $this->findingsScopeMode === FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT
|
||||||
|
? FindingsLifecycleBackfillScope::singleTenant((int) $this->findingsTenantId)
|
||||||
|
: FindingsLifecycleBackfillScope::allTenants();
|
||||||
|
|
||||||
$user = auth('platform')->user();
|
$user = auth('platform')->user();
|
||||||
|
|
||||||
@ -281,34 +286,4 @@ private function lastRunForType(string $type): ?OperationRun
|
|||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
private function trustedFindingsScopeFromFormData(array $data, AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
|
||||||
{
|
|
||||||
$scope = FindingsLifecycleBackfillScope::fromArray([
|
|
||||||
'mode' => $data['scope_mode'] ?? null,
|
|
||||||
'tenant_id' => $data['tenant_id'] ?? null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $scope->isSingleTenant()) {
|
|
||||||
return $scope;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($scope->tenantId);
|
|
||||||
|
|
||||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function trustedFindingsScopeFromState(AllowedTenantUniverse $allowedTenantUniverse): FindingsLifecycleBackfillScope
|
|
||||||
{
|
|
||||||
if ($this->findingsScopeMode !== FindingsLifecycleBackfillScope::MODE_SINGLE_TENANT) {
|
|
||||||
return FindingsLifecycleBackfillScope::allTenants();
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = $allowedTenantUniverse->resolveAllowedOrFail($this->findingsTenantId);
|
|
||||||
|
|
||||||
return FindingsLifecycleBackfillScope::singleTenant((int) $tenant->getKey());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,7 +131,6 @@ protected function getViewData(): array
|
|||||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||||
|
|
||||||
$latestPack = ReviewPack::query()
|
$latestPack = ReviewPack::query()
|
||||||
->with('tenantReview')
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
@ -147,7 +146,6 @@ protected function getViewData(): array
|
|||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,11 +158,6 @@ protected function getViewData(): array
|
|||||||
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
$downloadUrl = $service->generateDownloadUrl($latestPack);
|
||||||
}
|
}
|
||||||
|
|
||||||
$reviewUrl = null;
|
|
||||||
if ($latestPack->tenantReview && $canView) {
|
|
||||||
$reviewUrl = \App\Filament\Resources\TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPack->tenantReview], $tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
$failedReason = null;
|
$failedReason = null;
|
||||||
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
|
||||||
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
|
||||||
@ -180,7 +173,6 @@ protected function getViewData(): array
|
|||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'downloadUrl' => $downloadUrl,
|
'downloadUrl' => $downloadUrl,
|
||||||
'failedReason' => $failedReason,
|
'failedReason' => $failedReason,
|
||||||
'reviewUrl' => $reviewUrl,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +200,6 @@ private function emptyState(): array
|
|||||||
'canManage' => false,
|
'canManage' => false,
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
use App\Services\Tenants\TenantOperabilityService;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -49,15 +47,7 @@ public function __invoke(Request $request): RedirectResponse
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outcome = app(TenantOperabilityService::class)->outcomeFor(
|
if (! app(TenantOperabilityService::class)->canSelectAsContext($tenant)) {
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::SelectorEligibility,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: $workspaceId,
|
|
||||||
lane: TenantInteractionLane::StandardActiveOperating,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $outcome->allowed) {
|
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,7 +177,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->isLivewireUpdatePath($path)) {
|
if ($path === '/livewire/update') {
|
||||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||||
|
|
||||||
@ -193,11 +193,6 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
|||||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
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
|
private function isChooserFirstPath(string $path): bool
|
||||||
{
|
{
|
||||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
|
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -33,11 +32,6 @@ public function __construct(
|
|||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(OperationRunService $runs): void
|
public function handle(OperationRunService $runs): void
|
||||||
{
|
{
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -33,11 +32,6 @@ public function __construct(
|
|||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(OperationRunService $runs): void
|
public function handle(OperationRunService $runs): void
|
||||||
{
|
{
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -29,7 +29,6 @@
|
|||||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||||
use App\Services\Findings\FindingSlaPolicy;
|
use App\Services\Findings\FindingSlaPolicy;
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -2131,14 +2130,20 @@ private function upsertFindings(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
|
if ($resolvedAt === null || $observedAt->greaterThan($resolvedAt)) {
|
||||||
$finding->save();
|
$severity = (string) $driftItem['severity'];
|
||||||
|
$slaDays = $slaPolicy->daysForSeverity($severity, $tenant);
|
||||||
|
|
||||||
app(FindingWorkflowService::class)->reopenBySystem(
|
$finding->forceFill([
|
||||||
finding: $finding,
|
'status' => Finding::STATUS_REOPENED,
|
||||||
tenant: $tenant,
|
'reopened_at' => $observedAt,
|
||||||
reopenedAt: $observedAt,
|
'resolved_at' => null,
|
||||||
operationRunId: (int) $this->operationRun->getKey(),
|
'resolved_reason' => null,
|
||||||
);
|
'closed_at' => null,
|
||||||
|
'closed_reason' => null,
|
||||||
|
'closed_by_user_id' => null,
|
||||||
|
'sla_days' => $slaDays,
|
||||||
|
'due_at' => $slaPolicy->dueAtForSeverity($severity, $tenant, $observedAt),
|
||||||
|
]);
|
||||||
|
|
||||||
$reopenedCount++;
|
$reopenedCount++;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,79 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ComposeTenantReviewJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $tenantReviewId,
|
|
||||||
public int $operationRunId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(TenantReviewService $service, OperationRunService $operationRuns): void
|
|
||||||
{
|
|
||||||
$review = TenantReview::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->tenantReviewId);
|
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview || ! $operationRun instanceof OperationRun || ! $review->tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
|
||||||
$review->update(['status' => TenantReviewStatus::Draft->value]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$review = $service->compose($review);
|
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$review->update([
|
|
||||||
'status' => TenantReviewStatus::Failed->value,
|
|
||||||
'summary' => array_merge(is_array($review->summary) ? $review->summary : [], [
|
|
||||||
'error' => $throwable->getMessage(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'tenant_review_compose.failed',
|
|
||||||
'message' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
use App\Contracts\Hardening\WriteGateInterface;
|
use App\Contracts\Hardening\WriteGateInterface;
|
||||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Listeners\SyncRestoreRunToOperationRun;
|
use App\Listeners\SyncRestoreRunToOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
@ -36,14 +34,6 @@ public function __construct(
|
|||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, object>
|
|
||||||
*/
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
||||||
{
|
{
|
||||||
if (! $this->operationRun) {
|
if (! $this->operationRun) {
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GenerateEvidenceSnapshotJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public int $snapshotId,
|
|
||||||
public int $operationRunId,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(EvidenceSnapshotService $service, OperationRunService $operationRuns): void
|
|
||||||
{
|
|
||||||
$snapshot = EvidenceSnapshot::query()->with('tenant')->find($this->snapshotId);
|
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
|
||||||
|
|
||||||
if (! $snapshot instanceof EvidenceSnapshot || ! $operationRun instanceof OperationRun || ! $snapshot->tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
|
||||||
$snapshot->update(['status' => EvidenceSnapshotStatus::Generating->value]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
|
||||||
$previousActive = EvidenceSnapshot::query()
|
|
||||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
|
||||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
|
||||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
|
||||||
->whereKeyNot((int) $snapshot->getKey())
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$snapshot->items()->delete();
|
|
||||||
|
|
||||||
foreach ($payload['items'] as $item) {
|
|
||||||
$snapshot->items()->create([
|
|
||||||
'tenant_id' => (int) $snapshot->tenant_id,
|
|
||||||
'workspace_id' => (int) $snapshot->workspace_id,
|
|
||||||
'dimension_key' => $item['dimension_key'],
|
|
||||||
'state' => $item['state'],
|
|
||||||
'required' => $item['required'],
|
|
||||||
'source_kind' => $item['source_kind'],
|
|
||||||
'source_record_type' => $item['source_record_type'],
|
|
||||||
'source_record_id' => $item['source_record_id'],
|
|
||||||
'source_fingerprint' => $item['source_fingerprint'],
|
|
||||||
'measured_at' => $item['measured_at'],
|
|
||||||
'freshness_at' => $item['freshness_at'],
|
|
||||||
'summary_payload' => $item['summary_payload'],
|
|
||||||
'sort_order' => $item['sort_order'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
|
||||||
$previousActive->update([
|
|
||||||
'status' => EvidenceSnapshotStatus::Superseded->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot->update([
|
|
||||||
'fingerprint' => $payload['fingerprint'],
|
|
||||||
'previous_fingerprint' => $previousActive?->fingerprint,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => $payload['completeness'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
'summary' => $payload['summary'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} catch (Throwable $throwable) {
|
|
||||||
$snapshot->update([
|
|
||||||
'status' => EvidenceSnapshotStatus::Failed->value,
|
|
||||||
'summary' => [
|
|
||||||
'error' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Failed->value,
|
|
||||||
failures: [
|
|
||||||
[
|
|
||||||
'code' => 'evidence_snapshot_generation.failed',
|
|
||||||
'message' => $throwable->getMessage(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,12 +4,11 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Services\Intune\SecretClassificationService;
|
use App\Services\Intune\SecretClassificationService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
@ -35,7 +34,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function handle(OperationRunService $operationRunService): void
|
public function handle(OperationRunService $operationRunService): void
|
||||||
{
|
{
|
||||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items', 'tenantReview.sections'])->find($this->reviewPackId);
|
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||||
|
|
||||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||||
@ -55,20 +54,12 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
return;
|
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)
|
// Mark running via OperationRunService (auto-sets started_at)
|
||||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||||
|
|
||||||
@ -76,44 +67,60 @@ public function handle(OperationRunService $operationRunService): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||||
{
|
{
|
||||||
$review = $reviewPack->tenantReview;
|
|
||||||
|
|
||||||
if ($review instanceof TenantReview) {
|
|
||||||
$this->executeReviewDerivedGeneration($reviewPack, $review, $operationRun, $tenant, $snapshot, $operationRunService);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$options = $reviewPack->options ?? [];
|
$options = $reviewPack->options ?? [];
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||||
$items = $snapshot->items->keyBy('dimension_key');
|
$tenantId = (int) $tenant->getKey();
|
||||||
$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'] : []);
|
|
||||||
|
|
||||||
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
// 1. Collect StoredReports
|
||||||
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
$storedReports = StoredReport::query()
|
||||||
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
->where('tenant_id', $tenantId)
|
||||||
$dataFreshness = $this->computeDataFreshness($items);
|
->whereIn('report_type', [
|
||||||
|
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||||
|
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||||
|
])
|
||||||
|
->get()
|
||||||
|
->keyBy('report_type');
|
||||||
|
|
||||||
|
// 2. Collect open findings
|
||||||
|
$findings = Finding::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->orderBy('severity')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// 3. Collect tenant hardening fields
|
||||||
|
$hardening = [
|
||||||
|
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||||
|
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||||
|
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||||
|
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||||
|
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Collect recent OperationRuns (30 days)
|
||||||
|
$recentOperations = $includeOperations
|
||||||
|
? OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
// 5. Data freshness
|
||||||
|
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||||
|
|
||||||
// 6. Build file map
|
// 6. Build file map
|
||||||
$fileMap = $this->buildFileMap(
|
$fileMap = $this->buildFileMap(
|
||||||
|
storedReports: $storedReports,
|
||||||
findings: $findings,
|
findings: $findings,
|
||||||
hardening: $hardening,
|
hardening: $hardening,
|
||||||
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
|
||||||
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
|
||||||
recentOperations: $recentOperations,
|
recentOperations: $recentOperations,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
snapshot: $snapshot,
|
|
||||||
dataFreshness: $dataFreshness,
|
dataFreshness: $dataFreshness,
|
||||||
riskAcceptance: $riskAcceptance,
|
|
||||||
includePii: $includePii,
|
includePii: $includePii,
|
||||||
includeOperations: $includeOperations,
|
includeOperations: $includeOperations,
|
||||||
);
|
);
|
||||||
@ -147,24 +154,16 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
|
|
||||||
// 11. Compute summary
|
// 11. Compute summary
|
||||||
$summary = [
|
$summary = [
|
||||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
'finding_count' => $findings->count(),
|
||||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
'report_count' => $storedReports->count(),
|
||||||
'operation_count' => $recentOperations->count(),
|
'operation_count' => $recentOperations->count(),
|
||||||
'data_freshness' => $dataFreshness,
|
'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
|
// 12. Update ReviewPack
|
||||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||||
$reviewPack->update([
|
$reviewPack->update([
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
'status' => ReviewPackStatus::Ready->value,
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
'fingerprint' => $fingerprint,
|
||||||
'sha256' => $sha256,
|
'sha256' => $sha256,
|
||||||
'file_size' => $fileSize,
|
'file_size' => $fileSize,
|
||||||
@ -184,113 +183,18 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function executeReviewDerivedGeneration(
|
|
||||||
ReviewPack $reviewPack,
|
|
||||||
TenantReview $review,
|
|
||||||
OperationRun $operationRun,
|
|
||||||
Tenant $tenant,
|
|
||||||
EvidenceSnapshot $snapshot,
|
|
||||||
OperationRunService $operationRunService,
|
|
||||||
): void {
|
|
||||||
$options = $reviewPack->options ?? [];
|
|
||||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
|
||||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
|
||||||
|
|
||||||
$fileMap = $this->buildReviewDerivedFileMap(
|
|
||||||
review: $review,
|
|
||||||
tenant: $tenant,
|
|
||||||
snapshot: $snapshot,
|
|
||||||
includePii: $includePii,
|
|
||||||
includeOperations: $includeOperations,
|
|
||||||
);
|
|
||||||
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'review-pack-');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->assembleZip($tempFile, $fileMap);
|
|
||||||
|
|
||||||
$sha256 = hash_file('sha256', $tempFile);
|
|
||||||
$fileSize = filesize($tempFile);
|
|
||||||
$filePath = sprintf(
|
|
||||||
'review-packs/%s/review-%d-%s.zip',
|
|
||||||
$tenant->external_id,
|
|
||||||
(int) $review->getKey(),
|
|
||||||
now()->format('Y-m-d-His'),
|
|
||||||
);
|
|
||||||
|
|
||||||
Storage::disk('exports')->put($filePath, file_get_contents($tempFile));
|
|
||||||
} finally {
|
|
||||||
if (file_exists($tempFile)) {
|
|
||||||
unlink($tempFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options);
|
|
||||||
$reviewSummary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$summary = [
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'review_status' => (string) $review->status,
|
|
||||||
'review_completeness_state' => (string) $review->completeness_state,
|
|
||||||
'section_count' => $review->sections->count(),
|
|
||||||
'finding_count' => (int) ($reviewSummary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($reviewSummary['report_count'] ?? 0),
|
|
||||||
'operation_count' => $includeOperations ? (int) ($reviewSummary['operation_count'] ?? 0) : 0,
|
|
||||||
'highlights' => is_array($reviewSummary['highlights'] ?? null) ? $reviewSummary['highlights'] : [],
|
|
||||||
'publish_blockers' => is_array($reviewSummary['publish_blockers'] ?? null) ? $reviewSummary['publish_blockers'] : [],
|
|
||||||
'evidence_resolution' => [
|
|
||||||
'outcome' => 'resolved',
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
|
||||||
'completeness_state' => (string) $snapshot->completeness_state,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
|
||||||
$reviewPack->update([
|
|
||||||
'status' => ReviewPackStatus::Ready->value,
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'sha256' => $sha256,
|
|
||||||
'file_size' => $fileSize,
|
|
||||||
'file_path' => $filePath,
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
'generated_at' => now(),
|
|
||||||
'expires_at' => now()->addDays($retentionDays),
|
|
||||||
'summary' => $summary,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$review->update([
|
|
||||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
||||||
'summary' => array_merge($reviewSummary, [
|
|
||||||
'has_ready_export' => true,
|
|
||||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$operationRunService->updateRun(
|
|
||||||
$operationRun,
|
|
||||||
status: OperationRunStatus::Completed->value,
|
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
|
||||||
summaryCounts: [
|
|
||||||
'created' => 1,
|
|
||||||
'finding_count' => (int) ($summary['finding_count'] ?? 0),
|
|
||||||
'report_count' => (int) ($summary['report_count'] ?? 0),
|
|
||||||
'operation_count' => (int) ($summary['operation_count'] ?? 0),
|
|
||||||
'errors_recorded' => 0,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||||
|
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||||
* @return array<string, ?string>
|
* @return array<string, ?string>
|
||||||
*/
|
*/
|
||||||
private function computeDataFreshness($items): array
|
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||||
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||||
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||||
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,15 +204,12 @@ private function computeDataFreshness($items): array
|
|||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
private function buildFileMap(
|
private function buildFileMap(
|
||||||
|
$storedReports,
|
||||||
$findings,
|
$findings,
|
||||||
array $hardening,
|
array $hardening,
|
||||||
array $permissionPosture,
|
|
||||||
array $entraAdminRoles,
|
|
||||||
$recentOperations,
|
$recentOperations,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
EvidenceSnapshot $snapshot,
|
|
||||||
array $dataFreshness,
|
array $dataFreshness,
|
||||||
array $riskAcceptance,
|
|
||||||
bool $includePii,
|
bool $includePii,
|
||||||
bool $includeOperations,
|
bool $includeOperations,
|
||||||
): array {
|
): array {
|
||||||
@ -326,12 +227,6 @@ private function buildFileMap(
|
|||||||
'tenant_id' => $tenant->external_id,
|
'tenant_id' => $tenant->external_id,
|
||||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||||
'generated_at' => now()->toIso8601String(),
|
'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' => [
|
'redaction_integrity' => [
|
||||||
'protected_values_hidden' => true,
|
'protected_values_hidden' => true,
|
||||||
'note' => RedactionIntegrity::protectedValueNote(),
|
'note' => RedactionIntegrity::protectedValueNote(),
|
||||||
@ -346,14 +241,16 @@ private function buildFileMap(
|
|||||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||||
|
|
||||||
// reports/entra_admin_roles.json
|
// reports/entra_admin_roles.json
|
||||||
|
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||||
$this->redactReportPayload($entraAdminRoles, $includePii),
|
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||||
);
|
);
|
||||||
|
|
||||||
// reports/permission_posture.json
|
// reports/permission_posture.json
|
||||||
|
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||||
$files['reports/permission_posture.json'] = json_encode(
|
$files['reports/permission_posture.json'] = json_encode(
|
||||||
$this->redactReportPayload($permissionPosture, $includePii),
|
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -361,10 +258,8 @@ private function buildFileMap(
|
|||||||
$files['summary.json'] = json_encode([
|
$files['summary.json'] = json_encode([
|
||||||
'data_freshness' => $dataFreshness,
|
'data_freshness' => $dataFreshness,
|
||||||
'finding_count' => $findings->count(),
|
'finding_count' => $findings->count(),
|
||||||
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
'report_count' => $storedReports->count(),
|
||||||
'operation_count' => $recentOperations->count(),
|
'operation_count' => $recentOperations->count(),
|
||||||
'risk_acceptance' => $riskAcceptance,
|
|
||||||
'snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
return $files;
|
return $files;
|
||||||
@ -378,33 +273,18 @@ private function buildFileMap(
|
|||||||
private function buildFindingsCsv($findings, bool $includePii): string
|
private function buildFindingsCsv($findings, bool $includePii): string
|
||||||
{
|
{
|
||||||
$handle = fopen('php://temp', 'r+');
|
$handle = fopen('php://temp', 'r+');
|
||||||
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||||
|
|
||||||
foreach ($findings as $finding) {
|
foreach ($findings as $finding) {
|
||||||
$row = $finding instanceof Finding
|
fputcsv($handle, [
|
||||||
? [
|
$finding->id,
|
||||||
$finding->id,
|
$finding->finding_type,
|
||||||
$finding->finding_type,
|
$finding->severity,
|
||||||
$finding->severity,
|
$finding->status,
|
||||||
$finding->status,
|
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
$finding->created_at?->toIso8601String(),
|
||||||
$finding->created_at?->toIso8601String(),
|
$finding->updated_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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -421,31 +301,17 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
|||||||
private function buildOperationsCsv($operations, bool $includePii): string
|
private function buildOperationsCsv($operations, bool $includePii): string
|
||||||
{
|
{
|
||||||
$handle = fopen('php://temp', 'r+');
|
$handle = fopen('php://temp', 'r+');
|
||||||
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||||
|
|
||||||
foreach ($operations as $operation) {
|
foreach ($operations as $operation) {
|
||||||
$row = $operation instanceof OperationRun
|
fputcsv($handle, [
|
||||||
? [
|
$operation->id,
|
||||||
$operation->id,
|
$operation->type,
|
||||||
$operation->type,
|
$operation->status,
|
||||||
$operation->status,
|
$operation->outcome,
|
||||||
$operation->outcome,
|
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
$operation->started_at?->toIso8601String(),
|
||||||
$operation->started_at?->toIso8601String(),
|
$operation->completed_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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,15 +322,6 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
|||||||
return $content;
|
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.
|
* Redact PII from a report payload.
|
||||||
*
|
*
|
||||||
@ -574,98 +431,9 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
|||||||
$zip->close();
|
$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
|
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||||
{
|
{
|
||||||
$reviewPack->update([
|
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||||
'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(
|
$operationRunService->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
@ -676,13 +444,4 @@ private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun,
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function itemSummaryPayload(mixed $item): array
|
|
||||||
{
|
|
||||||
if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item->summary_payload;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Jobs\Middleware;
|
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Operations\QueuedExecutionLegitimacyGate;
|
|
||||||
use Closure;
|
|
||||||
|
|
||||||
class EnsureQueuedExecutionLegitimate
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param mixed $job
|
|
||||||
* @param callable $next
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function handle($job, Closure $next)
|
|
||||||
{
|
|
||||||
$run = $this->resolveRun($job);
|
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
|
||||||
return $next($job);
|
|
||||||
}
|
|
||||||
|
|
||||||
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
|
|
||||||
|
|
||||||
if (! $decision->allowed) {
|
|
||||||
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($job);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $job
|
|
||||||
*/
|
|
||||||
private function resolveRun($job): ?OperationRun
|
|
||||||
{
|
|
||||||
if (method_exists($job, 'getOperationRun')) {
|
|
||||||
$run = $job->getOperationRun();
|
|
||||||
|
|
||||||
return $run instanceof OperationRun ? $run : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property_exists($job, 'operationRun')) {
|
|
||||||
$run = $job->operationRun;
|
|
||||||
|
|
||||||
return $run instanceof OperationRun ? $run : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,7 +17,14 @@ class TrackOperationRun
|
|||||||
*/
|
*/
|
||||||
public function handle($job, Closure $next)
|
public function handle($job, Closure $next)
|
||||||
{
|
{
|
||||||
$run = $this->resolveRun($job);
|
// Check if the job has an 'operationRun' property or method
|
||||||
|
$run = null;
|
||||||
|
|
||||||
|
if (method_exists($job, 'getOperationRun')) {
|
||||||
|
$run = $job->getOperationRun();
|
||||||
|
} elseif (property_exists($job, 'operationRun')) {
|
||||||
|
$run = $job->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $run instanceof OperationRun) {
|
if (! $run instanceof OperationRun) {
|
||||||
return $next($job);
|
return $next($job);
|
||||||
@ -26,23 +33,19 @@ public function handle($job, Closure $next)
|
|||||||
/** @var OperationRunService $service */
|
/** @var OperationRunService $service */
|
||||||
$service = app(OperationRunService::class);
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
$run->refresh();
|
// Mark as running
|
||||||
|
$service->updateRun($run, 'running');
|
||||||
if ($run->status === 'completed') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->status !== 'running') {
|
|
||||||
$service->updateRun($run, 'running');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $next($job);
|
$response = $next($job);
|
||||||
|
|
||||||
|
// If the job was released back onto the queue (retry / delay), do not mark the run as completed.
|
||||||
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
|
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the job didn't already mark it as completed/failed, we do it here.
|
||||||
|
// Re-fetch to check current status
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
|
|
||||||
if ($run->status === 'running') {
|
if ($run->status === 'running') {
|
||||||
@ -55,24 +58,4 @@ public function handle($job, Closure $next)
|
|||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $job
|
|
||||||
*/
|
|
||||||
private function resolveRun($job): ?OperationRun
|
|
||||||
{
|
|
||||||
if (method_exists($job, 'getOperationRun')) {
|
|
||||||
$run = $job->getOperationRun();
|
|
||||||
|
|
||||||
return $run instanceof OperationRun ? $run : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property_exists($job, 'operationRun')) {
|
|
||||||
$run = $job->operationRun;
|
|
||||||
|
|
||||||
return $run instanceof OperationRun ? $run : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Operations;
|
namespace App\Jobs\Operations;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -38,7 +37,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(OperationRunService $runs): void
|
public function handle(OperationRunService $runs): void
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Operations;
|
namespace App\Jobs\Operations;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -39,7 +38,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(OperationRunService $runs): void
|
public function handle(OperationRunService $runs): void
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Operations;
|
namespace App\Jobs\Operations;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -34,11 +33,6 @@ public function __construct(
|
|||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||||
{
|
{
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs\Operations;
|
namespace App\Jobs\Operations;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -37,11 +36,6 @@ public function __construct(
|
|||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
OperationRunService $runs,
|
OperationRunService $runs,
|
||||||
TargetScopeConcurrencyLimiter $limiter,
|
TargetScopeConcurrencyLimiter $limiter,
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -42,7 +41,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -53,7 +52,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
@ -42,7 +41,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
use App\Contracts\Hardening\WriteGateInterface;
|
use App\Contracts\Hardening\WriteGateInterface;
|
||||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -14,7 +12,6 @@
|
|||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Operations\ExecutionAuthorityMode;
|
|
||||||
use App\Support\OpsUx\AssignmentJobFingerprint;
|
use App\Support\OpsUx\AssignmentJobFingerprint;
|
||||||
use App\Support\OpsUx\RunFailureSanitizer;
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -42,14 +39,6 @@ class RestoreAssignmentsJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $backoff = 0;
|
public int $backoff = 0;
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, object>
|
|
||||||
*/
|
|
||||||
public function middleware(): array
|
|
||||||
{
|
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
@ -414,8 +403,6 @@ private static function operationRunContext(
|
|||||||
'policy_type' => trim($policyType),
|
'policy_type' => trim($policyType),
|
||||||
'policy_id' => trim($policyId),
|
'policy_id' => trim($policyId),
|
||||||
'assignment_item_count' => count($assignments),
|
'assignment_item_count' => count($assignments),
|
||||||
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
|
|
||||||
'required_capability' => 'tenant.manage',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
@ -59,7 +58,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -46,7 +45,7 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,6 +89,11 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
|||||||
$successCount = 0;
|
$successCount = 0;
|
||||||
$failedCount = 0;
|
$failedCount = 0;
|
||||||
|
|
||||||
|
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
||||||
|
// It will also handle success completion if no exceptions thrown.
|
||||||
|
// However, InventorySyncService execution logic might be complex with partial failures.
|
||||||
|
// We might want to explicitly update the OperationRun if partial failures occur.
|
||||||
|
|
||||||
$result = $inventorySyncService->executeSelection(
|
$result = $inventorySyncService->executeSelection(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
$tenant,
|
$tenant,
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
@ -40,7 +39,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [new EnsureQueuedExecutionLegitimate, new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
|
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
|
||||||
|
|||||||
@ -18,14 +18,6 @@ class AuditLog extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
private const INTERNAL_METADATA_KEYS = [
|
|
||||||
'_actor_type',
|
|
||||||
'_dedupe_key',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -210,12 +202,7 @@ public function contextItems(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($metadata as $key => $value) {
|
foreach ($metadata as $key => $value) {
|
||||||
if (
|
if (in_array($key, $seen, true) || in_array($key, ['before', 'after'], true)) {
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class EvidenceSnapshot extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary' => 'array',
|
|
||||||
'generated_at' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<OperationRun, $this>
|
|
||||||
*/
|
|
||||||
public function operationRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function initiator(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<EvidenceSnapshotItem, $this>
|
|
||||||
*/
|
|
||||||
public function items(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(EvidenceSnapshotItem::class)->orderBy('sort_order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function reviewPacks(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ReviewPack::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantReview, $this>
|
|
||||||
*/
|
|
||||||
public function tenantReviews(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeActive(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', EvidenceSnapshotStatus::Active->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeCurrent(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->whereIn('status', [
|
|
||||||
EvidenceSnapshotStatus::Queued->value,
|
|
||||||
EvidenceSnapshotStatus::Generating->value,
|
|
||||||
EvidenceSnapshotStatus::Active->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isCurrent(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
EvidenceSnapshotStatus::Queued->value,
|
|
||||||
EvidenceSnapshotStatus::Generating->value,
|
|
||||||
EvidenceSnapshotStatus::Active->value,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessState(): EvidenceCompletenessState
|
|
||||||
{
|
|
||||||
return EvidenceCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? EvidenceCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class EvidenceSnapshotItem extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required' => 'boolean',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
'freshness_at' => 'datetime',
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'sort_order' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
|
||||||
*/
|
|
||||||
public function snapshot(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(EvidenceSnapshot::class, 'evidence_snapshot_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,7 +7,6 @@
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class Finding extends Model
|
class Finding extends Model
|
||||||
@ -99,14 +98,6 @@ public function closedByUser(): BelongsTo
|
|||||||
return $this->belongsTo(User::class, 'closed_by_user_id');
|
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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -169,15 +160,10 @@ public function hasOpenStatus(): bool
|
|||||||
return self::isOpenStatus($this->status);
|
return self::isOpenStatus($this->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isRiskAccepted(): bool
|
public function acknowledge(User $user): void
|
||||||
{
|
|
||||||
return (string) $this->status === self::STATUS_RISK_ACCEPTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function acknowledge(User $user): self
|
|
||||||
{
|
{
|
||||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||||
return $this;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->forceFill([
|
$this->forceFill([
|
||||||
@ -187,38 +173,29 @@ public function acknowledge(User $user): self
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resolve(string $reason): self
|
|
||||||
{
|
|
||||||
$this->forceFill([
|
|
||||||
'status' => self::STATUS_RESOLVED,
|
|
||||||
'resolved_at' => now(),
|
|
||||||
'resolved_reason' => $reason,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $evidence
|
* Auto-resolve the finding.
|
||||||
*/
|
*/
|
||||||
public function reopen(array $evidence): self
|
public function resolve(string $reason): void
|
||||||
{
|
{
|
||||||
$this->forceFill([
|
$this->status = self::STATUS_RESOLVED;
|
||||||
'status' => self::STATUS_NEW,
|
$this->resolved_at = now();
|
||||||
'resolved_at' => null,
|
$this->resolved_reason = $reason;
|
||||||
'resolved_reason' => null,
|
|
||||||
'evidence_jsonb' => $evidence,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
return $this;
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolvedSubjectDisplayName(): ?string
|
public function resolvedSubjectDisplayName(): ?string
|
||||||
|
|||||||
@ -1,243 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class FindingException extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string STATUS_PENDING = 'pending';
|
|
||||||
|
|
||||||
public const string STATUS_ACTIVE = 'active';
|
|
||||||
|
|
||||||
public const string STATUS_EXPIRING = 'expiring';
|
|
||||||
|
|
||||||
public const string STATUS_EXPIRED = 'expired';
|
|
||||||
|
|
||||||
public const string STATUS_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string STATUS_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
public const string STATUS_SUPERSEDED = 'superseded';
|
|
||||||
|
|
||||||
public const string VALIDITY_VALID = 'valid';
|
|
||||||
|
|
||||||
public const string VALIDITY_EXPIRING = 'expiring';
|
|
||||||
|
|
||||||
public const string VALIDITY_EXPIRED = 'expired';
|
|
||||||
|
|
||||||
public const string VALIDITY_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
public const string VALIDITY_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string VALIDITY_MISSING_SUPPORT = 'missing_support';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'requested_at' => 'datetime',
|
|
||||||
'approved_at' => 'datetime',
|
|
||||||
'rejected_at' => 'datetime',
|
|
||||||
'revoked_at' => 'datetime',
|
|
||||||
'effective_from' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'review_due_at' => 'datetime',
|
|
||||||
'evidence_summary' => 'array',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Finding, $this>
|
|
||||||
*/
|
|
||||||
public function finding(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Finding::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function requester(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'requested_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function owner(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'owner_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function approver(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingExceptionDecision, $this>
|
|
||||||
*/
|
|
||||||
public function currentDecision(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingExceptionDecision::class, 'current_decision_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<FindingExceptionDecision, $this>
|
|
||||||
*/
|
|
||||||
public function decisions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(FindingExceptionDecision::class)
|
|
||||||
->orderBy('decided_at')
|
|
||||||
->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<FindingExceptionEvidenceReference, $this>
|
|
||||||
*/
|
|
||||||
public function evidenceReferences(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(FindingExceptionEvidenceReference::class)
|
|
||||||
->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForFinding(Builder $query, Finding $finding): Builder
|
|
||||||
{
|
|
||||||
return $query->where('finding_id', (int) $finding->getKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopePending(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', self::STATUS_PENDING);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeCurrent(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', [
|
|
||||||
self::STATUS_PENDING,
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPending(): bool
|
|
||||||
{
|
|
||||||
return (string) $this->status === self::STATUS_PENDING;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isActiveLike(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasPriorApproval(): bool
|
|
||||||
{
|
|
||||||
return $this->approved_at !== null
|
|
||||||
&& $this->effective_from !== null
|
|
||||||
&& is_numeric($this->approved_by_user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasValidGovernance(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->current_validity_state, [
|
|
||||||
self::VALIDITY_VALID,
|
|
||||||
self::VALIDITY_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentDecisionType(): ?string
|
|
||||||
{
|
|
||||||
$decision = $this->relationLoaded('currentDecision')
|
|
||||||
? $this->currentDecision
|
|
||||||
: $this->currentDecision()->first();
|
|
||||||
|
|
||||||
return $decision instanceof FindingExceptionDecision
|
|
||||||
? (string) $decision->decision_type
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPendingRenewal(): bool
|
|
||||||
{
|
|
||||||
return $this->isPending()
|
|
||||||
&& $this->hasPriorApproval()
|
|
||||||
&& $this->currentDecisionType() === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function requiresFreshDecisionForFinding(Finding $finding): bool
|
|
||||||
{
|
|
||||||
return ! $finding->isRiskAccepted()
|
|
||||||
&& ! $this->isPending()
|
|
||||||
&& $this->hasValidGovernance();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canBeRenewed(): bool
|
|
||||||
{
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
self::STATUS_EXPIRED,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canBeRevoked(): bool
|
|
||||||
{
|
|
||||||
if ($this->isPendingRenewal()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array((string) $this->status, [
|
|
||||||
self::STATUS_ACTIVE,
|
|
||||||
self::STATUS_EXPIRING,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use LogicException;
|
|
||||||
|
|
||||||
class FindingExceptionDecision extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public const string TYPE_REQUESTED = 'requested';
|
|
||||||
|
|
||||||
public const string TYPE_APPROVED = 'approved';
|
|
||||||
|
|
||||||
public const string TYPE_REJECTED = 'rejected';
|
|
||||||
|
|
||||||
public const string TYPE_RENEWAL_REQUESTED = 'renewal_requested';
|
|
||||||
|
|
||||||
public const string TYPE_RENEWED = 'renewed';
|
|
||||||
|
|
||||||
public const string TYPE_REVOKED = 'revoked';
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'effective_from' => 'datetime',
|
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'metadata' => 'array',
|
|
||||||
'decided_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function booted(): void
|
|
||||||
{
|
|
||||||
static::updating(static function (): void {
|
|
||||||
throw new LogicException('Finding exception decisions are append-only.');
|
|
||||||
});
|
|
||||||
|
|
||||||
static::deleting(static function (): void {
|
|
||||||
throw new LogicException('Finding exception decisions are append-only.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingException, $this>
|
|
||||||
*/
|
|
||||||
public function exception(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingException::class, 'finding_exception_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function actor(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'actor_user_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class FindingExceptionEvidenceReference extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<FindingException, $this>
|
|
||||||
*/
|
|
||||||
public function exception(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(FindingException::class, 'finding_exception_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -71,22 +71,6 @@ public function initiator(): BelongsTo
|
|||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
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
|
* @param Builder<self> $query
|
||||||
* @return Builder<self>
|
* @return Builder<self>
|
||||||
|
|||||||
@ -261,21 +261,6 @@ public function auditLogs(): HasMany
|
|||||||
return $this->hasMany(AuditLog::class);
|
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
|
public function settings(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantSetting::class);
|
return $this->hasMany(TenantSetting::class);
|
||||||
|
|||||||
@ -1,195 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class TenantReview extends Model
|
|
||||||
{
|
|
||||||
use DerivesWorkspaceIdFromTenant;
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'summary' => 'array',
|
|
||||||
'generated_at' => 'datetime',
|
|
||||||
'published_at' => 'datetime',
|
|
||||||
'archived_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
|
||||||
*/
|
|
||||||
public function evidenceSnapshot(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(EvidenceSnapshot::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<OperationRun, $this>
|
|
||||||
*/
|
|
||||||
public function operationRun(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(OperationRun::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function initiator(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<User, $this>
|
|
||||||
*/
|
|
||||||
public function publisher(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'published_by_user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function currentExportReviewPack(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(ReviewPack::class, 'current_export_review_pack_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<self, $this>
|
|
||||||
*/
|
|
||||||
public function supersededByReview(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(self::class, 'superseded_by_review_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<self, $this>
|
|
||||||
*/
|
|
||||||
public function supersededReviews(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(self::class, 'superseded_by_review_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<TenantReviewSection, $this>
|
|
||||||
*/
|
|
||||||
public function sections(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TenantReviewSection::class)->orderBy('sort_order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return HasMany<ReviewPack, $this>
|
|
||||||
*/
|
|
||||||
public function reviewPacks(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ReviewPack::class)->latest('generated_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
|
|
||||||
{
|
|
||||||
return $query->where('workspace_id', $workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopePublished(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('status', TenantReviewStatus::Published->value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeMutable(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereIn('status', [
|
|
||||||
TenantReviewStatus::Draft->value,
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Failed->value,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function statusEnum(): TenantReviewStatus
|
|
||||||
{
|
|
||||||
return TenantReviewStatus::from((string) $this->status);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessEnum(): TenantReviewCompletenessState
|
|
||||||
{
|
|
||||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? TenantReviewCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isPublished(): bool
|
|
||||||
{
|
|
||||||
return $this->statusEnum()->isPublished();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isMutable(): bool
|
|
||||||
{
|
|
||||||
return $this->statusEnum()->isMutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function publishBlockers(): array
|
|
||||||
{
|
|
||||||
$summary = is_array($this->summary) ? $this->summary : [];
|
|
||||||
$blockers = $summary['publish_blockers'] ?? [];
|
|
||||||
|
|
||||||
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Support\TenantReviewCompletenessState;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class TenantReviewSection extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $guarded = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required' => 'boolean',
|
|
||||||
'summary_payload' => 'array',
|
|
||||||
'render_payload' => 'array',
|
|
||||||
'measured_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<TenantReview, $this>
|
|
||||||
*/
|
|
||||||
public function tenantReview(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TenantReview::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Workspace, $this>
|
|
||||||
*/
|
|
||||||
public function workspace(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Workspace::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return BelongsTo<Tenant, $this>
|
|
||||||
*/
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<self> $query
|
|
||||||
* @return Builder<self>
|
|
||||||
*/
|
|
||||||
public function scopeRequired(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('required', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function completenessEnum(): TenantReviewCompletenessState
|
|
||||||
{
|
|
||||||
return TenantReviewCompletenessState::tryFrom((string) $this->completeness_state)
|
|
||||||
?? TenantReviewCompletenessState::Missing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -27,22 +26,19 @@ public function via(object $notifiable): array
|
|||||||
public function toDatabase(object $notifiable): array
|
public function toDatabase(object $notifiable): array
|
||||||
{
|
{
|
||||||
$tenant = $this->run->tenant;
|
$tenant = $this->run->tenant;
|
||||||
$runUrl = match (true) {
|
|
||||||
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
|
|
||||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
|
||||||
default => OperationRunLinks::tenantlessView($this->run),
|
|
||||||
};
|
|
||||||
|
|
||||||
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
||||||
run: $this->run,
|
run: $this->run,
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
$notification->actions([
|
if ($notifiable instanceof PlatformUser) {
|
||||||
\Filament\Actions\Action::make('view_run')
|
$notification->actions([
|
||||||
->label('View run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->url($runUrl),
|
->label('View run')
|
||||||
]);
|
->url(SystemOperationRunLinks::view($this->run)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $notification->getDatabaseMessage();
|
return $notification->getDatabaseMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class BackupSchedulePolicy
|
class BackupSchedulePolicy
|
||||||
@ -18,7 +15,7 @@ class BackupSchedulePolicy
|
|||||||
|
|
||||||
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
||||||
{
|
{
|
||||||
$tenant ??= $this->resolvedTenant();
|
$tenant ??= Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
@ -29,74 +26,58 @@ public function viewAny(User $user): bool
|
|||||||
return $this->isTenantMember($user);
|
return $this->isTenantMember($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, BackupSchedule $schedule): Response|bool
|
public function view(User $user, BackupSchedule $schedule): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->resolvedTenant();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $this->isTenantMember($user, $tenant)) {
|
if (! $this->isTenantMember($user, $tenant)) {
|
||||||
return Response::denyAsNotFound();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->resolvedTenant();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant
|
return $tenant instanceof Tenant
|
||||||
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, BackupSchedule $schedule): Response|bool
|
public function update(User $user, BackupSchedule $schedule): bool
|
||||||
{
|
{
|
||||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, BackupSchedule $schedule): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restore(User $user, BackupSchedule $schedule): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeScheduleAction($user, $schedule, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
return $tenant instanceof Tenant
|
||||||
|
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, BackupSchedule $schedule): 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, BackupSchedule $schedule): 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, BackupSchedule $schedule): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& (int) $schedule->tenant_id === (int) $tenant->getKey()
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Support\OperateHub\OperateHubShell;
|
use App\Support\OperateHub\OperateHubShell;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class EntraGroupPolicy
|
class EntraGroupPolicy
|
||||||
{
|
{
|
||||||
@ -25,29 +24,25 @@ public function viewAny(User $user): bool
|
|||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, EntraGroup $group): Response|bool
|
public function view(User $user, EntraGroup $group): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->resolvedTenant();
|
$tenant = $this->resolvedTenant();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
return Response::denyAsNotFound();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
return Response::denyAsNotFound();
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $group->tenant_id !== (int) $tenant->getKey()) {
|
return (int) $group->tenant_id === (int) $tenant->getKey();
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvedTenant(): ?Tenant
|
private function resolvedTenant(): ?Tenant
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
|
|
||||||
class EvidenceSnapshotPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, EvidenceSnapshot $snapshot): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, EvidenceSnapshot $snapshot): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\FindingException;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class FindingExceptionPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = $this->resolvedTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $exception);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::FINDING_EXCEPTION_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function approve(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeCanonicalApproval($user, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reject(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeCanonicalApproval($user, $exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeCanonicalApproval(User $user, FindingException $exception): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $exception->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId) || $workspaceId !== (int) $exception->workspace_id) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = $tenant->workspace;
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var WorkspaceCapabilityResolver $resolver */
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $workspace)) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $resolver->can($user, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizedTenantOrNull(User $user, FindingException $exception): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = $this->resolvedTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $exception->tenant_id !== (int) $tenant->getKey()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvedTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant && (int) $tenant->workspace_id === $workspaceId ? $tenant : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,10 +7,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class FindingPolicy
|
class FindingPolicy
|
||||||
{
|
{
|
||||||
@ -18,7 +15,7 @@ class FindingPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->resolvedTenant();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
@ -31,23 +28,31 @@ public function viewAny(User $user): bool
|
|||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, Finding $finding): Response|bool
|
public function view(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $finding);
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant) {
|
||||||
return Response::denyAsNotFound();
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, Finding $finding): Response|bool
|
public function update(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->triage($user, $finding);
|
return $this->triage($user, $finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function triage(User $user, Finding $finding): Response|bool
|
public function triage(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithAnyCapability($user, $finding, [
|
return $this->canMutateWithAnyCapability($user, $finding, [
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
@ -55,32 +60,32 @@ public function triage(User $user, Finding $finding): Response|bool
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assign(User $user, Finding $finding): Response|bool
|
public function assign(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_ASSIGN);
|
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_ASSIGN);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolve(User $user, Finding $finding): Response|bool
|
public function resolve(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RESOLVE);
|
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RESOLVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function close(User $user, Finding $finding): Response|bool
|
public function close(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_CLOSE);
|
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_CLOSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function riskAccept(User $user, Finding $finding): Response|bool
|
public function riskAccept(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RISK_ACCEPT);
|
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_RISK_ACCEPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reopen(User $user, Finding $finding): Response|bool
|
public function reopen(User $user, Finding $finding): bool
|
||||||
{
|
{
|
||||||
return $this->triage($user, $finding);
|
return $this->triage($user, $finding);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canMutateWithCapability(User $user, Finding $finding, string $capability): Response|bool
|
private function canMutateWithCapability(User $user, Finding $finding, string $capability): bool
|
||||||
{
|
{
|
||||||
return $this->canMutateWithAnyCapability($user, $finding, [$capability]);
|
return $this->canMutateWithAnyCapability($user, $finding, [$capability]);
|
||||||
}
|
}
|
||||||
@ -88,12 +93,20 @@ private function canMutateWithCapability(User $user, Finding $finding, string $c
|
|||||||
/**
|
/**
|
||||||
* @param array<int, string> $capabilities
|
* @param array<int, string> $capabilities
|
||||||
*/
|
*/
|
||||||
private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): Response|bool
|
private function canMutateWithAnyCapability(User $user, Finding $finding, array $capabilities): bool
|
||||||
{
|
{
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $finding);
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return Response::denyAsNotFound();
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
@ -105,42 +118,6 @@ private function canMutateWithAnyCapability(User $user, Finding $finding, array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response::deny();
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,10 +9,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Tenants\TenantOperabilityService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Auth\Access\Response;
|
use Illuminate\Auth\Access\Response;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@ -98,18 +95,8 @@ private function authorizeForDraft(
|
|||||||
|
|
||||||
$tenant = $tenantOnboardingSession->tenant;
|
$tenant = $tenantOnboardingSession->tenant;
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant && ! $user->canAccessTenant($tenant)) {
|
||||||
$viewability = app(TenantOperabilityService::class)->outcomeFor(
|
return Response::denyAsNotFound();
|
||||||
tenant: $tenant,
|
|
||||||
question: TenantOperabilityQuestion::TenantBoundViewability,
|
|
||||||
actor: $user,
|
|
||||||
workspaceId: (int) $workspace->getKey(),
|
|
||||||
lane: TenantInteractionLane::AdministrativeManagement,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $viewability->allowed) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
return $this->authorizeForWorkspace($user, $workspace, $capability);
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Policies;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
|
||||||
use Illuminate\Auth\Access\Response;
|
|
||||||
|
|
||||||
class TenantReviewPolicy
|
|
||||||
{
|
|
||||||
use HandlesAuthorization;
|
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_VIEW)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function refresh(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function publish(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function archive(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function export(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createNextReview(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
return $this->authorizeManageAction($user, $review);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeManageAction(User $user, TenantReview $review): Response|bool
|
|
||||||
{
|
|
||||||
$tenant = $this->authorizedTenantOrNull($user, $review);
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return Response::denyAsNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
? true
|
|
||||||
: Response::deny();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizedTenantOrNull(User $user, TenantReview $review): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = $review->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) $review->workspace_id !== (int) $tenant->workspace_id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -39,7 +39,6 @@
|
|||||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||||
use App\Services\Operations\QueuedExecutionLegitimacyGate;
|
|
||||||
use App\Services\PermissionPosture\FindingGeneratorContract;
|
use App\Services\PermissionPosture\FindingGeneratorContract;
|
||||||
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
|
use App\Services\PermissionPosture\PermissionPostureFindingGenerator;
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
@ -123,7 +122,6 @@ public function register(): void
|
|||||||
$this->app->singleton(EntraGroupReferenceResolver::class);
|
$this->app->singleton(EntraGroupReferenceResolver::class);
|
||||||
$this->app->singleton(EntraRoleDefinitionReferenceResolver::class);
|
$this->app->singleton(EntraRoleDefinitionReferenceResolver::class);
|
||||||
$this->app->singleton(PrincipalReferenceResolver::class);
|
$this->app->singleton(PrincipalReferenceResolver::class);
|
||||||
$this->app->singleton(QueuedExecutionLegitimacyGate::class);
|
|
||||||
$this->app->singleton(ReferenceResolverRegistry::class, function ($app): ReferenceResolverRegistry {
|
$this->app->singleton(ReferenceResolverRegistry::class, function ($app): ReferenceResolverRegistry {
|
||||||
/** @var array<int, ReferenceResolver> $resolvers */
|
/** @var array<int, ReferenceResolver> $resolvers */
|
||||||
$resolvers = [
|
$resolvers = [
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
@ -18,7 +17,6 @@
|
|||||||
use App\Policies\AlertRulePolicy;
|
use App\Policies\AlertRulePolicy;
|
||||||
use App\Policies\ProviderConnectionPolicy;
|
use App\Policies\ProviderConnectionPolicy;
|
||||||
use App\Policies\TenantOnboardingSessionPolicy;
|
use App\Policies\TenantOnboardingSessionPolicy;
|
||||||
use App\Policies\TenantReviewPolicy;
|
|
||||||
use App\Policies\WorkspaceSettingPolicy;
|
use App\Policies\WorkspaceSettingPolicy;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
@ -32,7 +30,6 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
protected $policies = [
|
protected $policies = [
|
||||||
ProviderConnection::class => ProviderConnectionPolicy::class,
|
ProviderConnection::class => ProviderConnectionPolicy::class,
|
||||||
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
TenantOnboardingSession::class => TenantOnboardingSessionPolicy::class,
|
||||||
TenantReview::class => TenantReviewPolicy::class,
|
|
||||||
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
WorkspaceSetting::class => WorkspaceSettingPolicy::class,
|
||||||
AlertDestination::class => AlertDestinationPolicy::class,
|
AlertDestination::class => AlertDestinationPolicy::class,
|
||||||
AlertDelivery::class => AlertDeliveryPolicy::class,
|
AlertDelivery::class => AlertDeliveryPolicy::class,
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
use App\Filament\Pages\WorkspaceOverview;
|
use App\Filament\Pages\WorkspaceOverview;
|
||||||
@ -173,15 +171,12 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
FindingExceptionsQueue::class,
|
|
||||||
ReviewRegister::class,
|
|
||||||
])
|
])
|
||||||
->widgets([
|
->widgets([
|
||||||
AccountWidget::class,
|
AccountWidget::class,
|
||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->unsavedChangesAlerts()
|
->unsavedChangesAlerts()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@ -34,7 +34,6 @@ public function panel(Panel $panel): Panel
|
|||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_START,
|
PanelsRenderHook::BODY_START,
|
||||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -77,9 +76,6 @@ public function panel(Panel $panel): Panel
|
|||||||
: ''
|
: ''
|
||||||
)
|
)
|
||||||
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters')
|
||||||
->resources([
|
|
||||||
TenantReviewResource::class,
|
|
||||||
])
|
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
@ -91,7 +87,6 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
@ -36,42 +36,19 @@ public function record(
|
|||||||
): AuditLog {
|
): AuditLog {
|
||||||
$actionValue = $action instanceof AuditActionId ? $action->value : trim($action);
|
$actionValue = $action instanceof AuditActionId ? $action->value : trim($action);
|
||||||
|
|
||||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
return AuditLog::query()->create(
|
||||||
$dedupeKey = is_string($metadata['_dedupe_key'] ?? null) ? trim((string) $metadata['_dedupe_key']) : null;
|
$this->builder->buildRecordAttributes(
|
||||||
|
action: $actionValue,
|
||||||
if ($dedupeKey !== '') {
|
context: $context,
|
||||||
$metadata['_dedupe_key'] = $dedupeKey;
|
workspace: $workspace,
|
||||||
$context['metadata'] = $metadata;
|
tenant: $tenant,
|
||||||
}
|
actor: $actor,
|
||||||
|
target: $target,
|
||||||
$attributes = $this->builder->buildRecordAttributes(
|
outcome: $outcome,
|
||||||
action: $actionValue,
|
recordedAt: $recordedAt,
|
||||||
context: $context,
|
summary: $summary,
|
||||||
workspace: $workspace,
|
operationRunId: $operationRunId,
|
||||||
tenant: $tenant,
|
),
|
||||||
actor: $actor,
|
|
||||||
target: $target,
|
|
||||||
outcome: $outcome,
|
|
||||||
recordedAt: $recordedAt,
|
|
||||||
summary: $summary,
|
|
||||||
operationRunId: $operationRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($dedupeKey !== null && $dedupeKey !== '') {
|
|
||||||
$existing = AuditLog::query()
|
|
||||||
->where('tenant_id', $attributes['tenant_id'])
|
|
||||||
->where('action', $attributes['action'])
|
|
||||||
->where('resource_type', $attributes['resource_type'])
|
|
||||||
->where('resource_id', $attributes['resource_id'])
|
|
||||||
->whereRaw("metadata ->> '_dedupe_key' = ?", [$dedupeKey])
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($existing instanceof AuditLog) {
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AuditLog::query()->create($attributes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
@ -52,10 +50,6 @@ class RoleCapabilityMap
|
|||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Manager->value => [
|
TenantRole::Manager->value => [
|
||||||
@ -70,8 +64,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_CLOSE,
|
Capabilities::TENANT_FINDINGS_CLOSE,
|
||||||
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
Capabilities::FINDING_EXCEPTION_MANAGE,
|
|
||||||
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
@ -92,10 +84,6 @@ class RoleCapabilityMap
|
|||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::REVIEW_PACK_MANAGE,
|
Capabilities::REVIEW_PACK_MANAGE,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::TENANT_REVIEW_MANAGE,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
Capabilities::EVIDENCE_MANAGE,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
@ -105,7 +93,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
@ -120,14 +107,11 @@ class RoleCapabilityMap
|
|||||||
Capabilities::ENTRA_ROLES_VIEW,
|
Capabilities::ENTRA_ROLES_VIEW,
|
||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
TenantRole::Readonly->value => [
|
TenantRole::Readonly->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::FINDING_EXCEPTION_VIEW,
|
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
@ -139,8 +123,6 @@ class RoleCapabilityMap
|
|||||||
Capabilities::ENTRA_ROLES_VIEW,
|
Capabilities::ENTRA_ROLES_VIEW,
|
||||||
|
|
||||||
Capabilities::REVIEW_PACK_VIEW,
|
Capabilities::REVIEW_PACK_VIEW,
|
||||||
Capabilities::TENANT_REVIEW_VIEW,
|
|
||||||
Capabilities::EVIDENCE_VIEW,
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user