feat: implement evidence domain foundation (#183)
## Summary - add the Evidence Snapshot domain with immutable tenant-scoped snapshots, per-dimension items, queued generation, audit actions, badge mappings, and Filament list/detail surfaces - add the workspace evidence overview, capability and policy wiring, Livewire update-path hardening, and review-pack integration through explicit evidence snapshot resolution - add spec 153 artifacts, migrations, factories, and focused Pest coverage for evidence, review-pack reuse, authorization, action-surface regressions, and audit behavior ## Testing - `vendor/bin/sail artisan test --compact --stop-on-failure` - `CI=1 vendor/bin/sail artisan test --compact` - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - branch: `153-evidence-domain-foundation` - commit: `b7dfa279` - spec: `specs/153-evidence-domain-foundation/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #183
This commit is contained in:
parent
5ec62cd117
commit
a74ab12f04
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -90,6 +90,8 @@ ## Active Technologies
|
||||
- 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 (feat/005-bulk-operations)
|
||||
|
||||
@ -109,8 +111,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 153-evidence-domain-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
- 152-livewire-context-locking: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4
|
||||
- 151-findings-workflow-backstop: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
|
||||
- 150-tenant-owned-query-canon-and-wrong-tenant-guards: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Pest 4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
27
app/Exceptions/ReviewPackEvidenceResolutionException.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Services\Evidence\EvidenceResolutionResult;
|
||||
use RuntimeException;
|
||||
|
||||
class ReviewPackEvidenceResolutionException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly EvidenceResolutionResult $result,
|
||||
?string $message = null,
|
||||
) {
|
||||
parent::__construct($message ?? self::defaultMessage($result));
|
||||
}
|
||||
|
||||
private static function defaultMessage(EvidenceResolutionResult $result): string
|
||||
{
|
||||
return match ($result->outcome) {
|
||||
'missing_snapshot' => 'No eligible evidence snapshot is available for this review pack.',
|
||||
'snapshot_ineligible' => 'The latest evidence snapshot is not eligible for review-pack generation.',
|
||||
default => 'Evidence snapshot resolution failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
116
app/Filament/Pages/Monitoring/EvidenceOverview.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages\Monitoring;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceOverview extends Page
|
||||
{
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $title = 'Evidence Overview';
|
||||
|
||||
protected string $view = 'filament.pages.monitoring.evidence-overview';
|
||||
|
||||
/**
|
||||
* @var list<array<string, mixed>>
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
public ?int $tenantFilter = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'The overview header exposes a clear-filters action when a tenant prefilter is active.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The overview exposes a single drill-down link per row without a More menu.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The overview does not expose bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains the current scope and offers a clear-filters CTA.');
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new AuthenticationException;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
|
||||
$accessibleTenants = $user->tenants()
|
||||
->where('tenants.workspace_id', $workspaceId)
|
||||
->orderBy('tenants.name')
|
||||
->get()
|
||||
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant))
|
||||
->values();
|
||||
|
||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||
|
||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->where('status', 'active')
|
||||
->latest('generated_at');
|
||||
|
||||
if ($this->tenantFilter !== null) {
|
||||
$query->where('tenant_id', $this->tenantFilter);
|
||||
}
|
||||
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot): array {
|
||||
return [
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toDateTimeString(),
|
||||
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)),
|
||||
'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)),
|
||||
'view_url' => EvidenceSnapshotResource::getUrl('index', tenant: $snapshot->tenant),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||
->url(route('admin.evidence.overview')),
|
||||
];
|
||||
}
|
||||
}
|
||||
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
637
app/Filament/Resources/EvidenceSnapshotResource.php
Normal file
@ -0,0 +1,637 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Panel;
|
||||
use Filament\Resources\Pages\PageRegistration;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceSnapshotResource extends Resource
|
||||
{
|
||||
use InteractsWithTenantOwnedRecords;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = EvidenceSnapshot::class;
|
||||
|
||||
protected static ?string $slug = 'evidence';
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
|
||||
protected static ?string $navigationLabel = 'Evidence';
|
||||
|
||||
protected static ?int $navigationSort = 55;
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant) || ! $user->can(Capabilities::EVIDENCE_VIEW, $tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $record instanceof EvidenceSnapshot
|
||||
|| ((int) $record->tenant_id === (int) $tenant->getKey() && (int) $record->workspace_id === (int) $tenant->workspace_id);
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create snapshot is available from the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Evidence snapshots keep only primary View and Expire row actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return static::getTenantOwnedEloquentQuery()->with(['tenant', 'initiator', 'operationRun', 'items']);
|
||||
}
|
||||
|
||||
public static function resolveScopedRecordOrFail(int|string|null $record): Model
|
||||
{
|
||||
return static::resolveTenantOwnedRecordOrFail($record);
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus)),
|
||||
TextEntry::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||
TextEntry::make('tenant.name')->label('Tenant'),
|
||||
TextEntry::make('generated_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('expires_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation run')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (EvidenceSnapshot $record): ?string => $record->operation_run_id ? OperationRunLinks::tenantlessView((int) $record->operation_run_id) : null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
TextEntry::make('previous_fingerprint')->copyable()->placeholder('—')->columnSpanFull()->fontFamily('mono'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Summary')
|
||||
->schema([
|
||||
TextEntry::make('summary.finding_count')->label('Findings')->placeholder('—'),
|
||||
TextEntry::make('summary.report_count')->label('Reports')->placeholder('—'),
|
||||
TextEntry::make('summary.operation_count')->label('Operations')->placeholder('—'),
|
||||
TextEntry::make('summary.missing_dimensions')->label('Missing dimensions')->placeholder('—'),
|
||||
TextEntry::make('summary.stale_dimensions')->label('Stale dimensions')->placeholder('—'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Evidence dimensions')
|
||||
->schema([
|
||||
RepeatableEntry::make('items')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextEntry::make('dimension_key')->label('Dimension')
|
||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||
TextEntry::make('state')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness)),
|
||||
TextEntry::make('source_kind')->label('Source')
|
||||
->formatStateUsing(fn (string $state): string => Str::headline($state)),
|
||||
TextEntry::make('freshness_at')->dateTime()->placeholder('—'),
|
||||
ViewEntry::make('summary_payload_highlights')
|
||||
->label('Summary')
|
||||
->view('filament.infolists.entries.evidence-dimension-summary')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => static::dimensionSummaryPresentation($record))
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('summary_payload_raw')
|
||||
->label('Raw summary JSON')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EvidenceSnapshotItem $record): array => is_array($record->summary_payload) ? $record->summary_payload : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(4),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordUrl(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record]))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceSnapshotStatus))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('completeness_state')
|
||||
->label('Completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('generated_at')->dateTime()->sortable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('summary.finding_count')->label('Findings'),
|
||||
Tables\Columns\TextColumn::make('summary.missing_dimensions')->label('Missing'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
'queued' => 'Queued',
|
||||
'generating' => 'Generating',
|
||||
'active' => 'Active',
|
||||
'superseded' => 'Superseded',
|
||||
'expired' => 'Expired',
|
||||
'failed' => 'Failed',
|
||||
]),
|
||||
Tables\Filters\SelectFilter::make('completeness_state')
|
||||
->options([
|
||||
'complete' => 'Complete',
|
||||
'partial' => 'Partial',
|
||||
'missing' => 'Missing',
|
||||
'stale' => 'Stale',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
->label('View snapshot')
|
||||
->url(fn (EvidenceSnapshot $record): string => static::getUrl('view', ['record' => $record])),
|
||||
UiEnforcement::forTableAction(
|
||||
Actions\Action::make('expire')
|
||||
->label('Expire snapshot')
|
||||
->color('danger')
|
||||
->hidden(fn (EvidenceSnapshot $record): bool => ! static::canExpireRecord($record))
|
||||
->requiresConfirmation()
|
||||
->action(function (EvidenceSnapshot $record): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($record, $user);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
fn (EvidenceSnapshot $record): EvidenceSnapshot => $record,
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No evidence snapshots yet')
|
||||
->emptyStateDescription('Create the first snapshot to capture immutable evidence for this tenant.')
|
||||
->emptyStateActions([
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_first_snapshot')
|
||||
->label('Create first snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(fn (): mixed => static::executeGeneration([])),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEvidenceSnapshots::route('/'),
|
||||
'view' => new PageRegistration(
|
||||
page: Pages\ViewEvidenceSnapshot::class,
|
||||
route: fn (Panel $panel): Route => RouteFacade::get('/{record}', Pages\ViewEvidenceSnapshot::class)
|
||||
->whereNumber('record')
|
||||
->middleware(Pages\ViewEvidenceSnapshot::getRouteMiddleware($panel))
|
||||
->withoutMiddleware(Pages\ViewEvidenceSnapshot::getWithoutRouteMiddleware($panel)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function dimensionSummaryPresentation(EvidenceSnapshotItem $item): array
|
||||
{
|
||||
$payload = is_array($item->summary_payload) ? $item->summary_payload : [];
|
||||
|
||||
return match ($item->dimension_key) {
|
||||
'findings_summary' => static::findingsSummaryPresentation($payload),
|
||||
'permission_posture' => static::permissionPosturePresentation($payload),
|
||||
'entra_admin_roles' => static::entraAdminRolesPresentation($payload),
|
||||
'baseline_drift_posture' => static::baselineDriftPosturePresentation($payload),
|
||||
'operations_summary' => static::operationsSummaryPresentation($payload),
|
||||
default => static::genericSummaryPresentation($payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function findingsSummaryPresentation(array $payload): array
|
||||
{
|
||||
$count = (int) ($payload['count'] ?? 0);
|
||||
$openCount = (int) ($payload['open_count'] ?? 0);
|
||||
$severityCounts = is_array($payload['severity_counts'] ?? null) ? $payload['severity_counts'] : [];
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d findings, %d open.', $count, $openCount),
|
||||
'highlights' => [
|
||||
['label' => 'Findings', 'value' => (string) $count],
|
||||
['label' => 'Open findings', 'value' => (string) $openCount],
|
||||
['label' => 'Critical', 'value' => (string) ((int) ($severityCounts['critical'] ?? 0))],
|
||||
['label' => 'High', 'value' => (string) ((int) ($severityCounts['high'] ?? 0))],
|
||||
['label' => 'Medium', 'value' => (string) ((int) ($severityCounts['medium'] ?? 0))],
|
||||
['label' => 'Low', 'value' => (string) ((int) ($severityCounts['low'] ?? 0))],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::findingEntryLabel($entry) : null)
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function permissionPosturePresentation(array $payload): array
|
||||
{
|
||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||
$postureScore = $payload['posture_score'] ?? null;
|
||||
$reportPayload = is_array($payload['payload'] ?? null) ? $payload['payload'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d of %d required permissions granted.', $grantedCount, $requiredCount),
|
||||
'highlights' => [
|
||||
['label' => 'Granted permissions', 'value' => (string) $grantedCount],
|
||||
['label' => 'Required permissions', 'value' => (string) $requiredCount],
|
||||
['label' => 'Posture score', 'value' => $postureScore === null ? '—' : (string) $postureScore],
|
||||
],
|
||||
'items' => static::namedItemsFromArray(
|
||||
Arr::get($reportPayload, 'missing_permissions', Arr::get($reportPayload, 'missing', [])),
|
||||
'No missing permission details captured.'
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function entraAdminRolesPresentation(array $payload): array
|
||||
{
|
||||
$roleCount = (int) ($payload['role_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d privileged Entra roles captured.', $roleCount),
|
||||
'highlights' => [
|
||||
['label' => 'Role count', 'value' => (string) $roleCount],
|
||||
],
|
||||
'items' => static::namedItemsFromArray($payload['roles'] ?? [], 'No role details captured.'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function baselineDriftPosturePresentation(array $payload): array
|
||||
{
|
||||
$driftCount = (int) ($payload['drift_count'] ?? 0);
|
||||
$openDriftCount = (int) ($payload['open_drift_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d drift findings, %d still open.', $driftCount, $openDriftCount),
|
||||
'highlights' => [
|
||||
['label' => 'Drift findings', 'value' => (string) $driftCount],
|
||||
['label' => 'Open drift findings', 'value' => (string) $openDriftCount],
|
||||
],
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function operationsSummaryPresentation(array $payload): array
|
||||
{
|
||||
$operationCount = (int) ($payload['operation_count'] ?? 0);
|
||||
$failedCount = (int) ($payload['failed_count'] ?? 0);
|
||||
$partialCount = (int) ($payload['partial_count'] ?? 0);
|
||||
$entries = is_array($payload['entries'] ?? null) ? $payload['entries'] : [];
|
||||
|
||||
return [
|
||||
'summary' => sprintf('%d operations in the last 30 days, %d failed, %d partial.', $operationCount, $failedCount, $partialCount),
|
||||
'highlights' => [
|
||||
['label' => 'Operations', 'value' => (string) $operationCount],
|
||||
['label' => 'Failed operations', 'value' => (string) $failedCount],
|
||||
['label' => 'Partial operations', 'value' => (string) $partialCount],
|
||||
],
|
||||
'items' => collect($entries)
|
||||
->map(fn (mixed $entry): ?string => is_array($entry) ? static::operationEntryLabel($entry) : null)
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{summary:?string,highlights:list<array{label:string,value:string}>,items:list<string>}
|
||||
*/
|
||||
private static function genericSummaryPresentation(array $payload): array
|
||||
{
|
||||
$highlights = collect($payload)
|
||||
->reject(fn (mixed $value, string|int $key): bool => in_array((string) $key, ['entries', 'payload', 'roles'], true) || is_array($value))
|
||||
->take(6)
|
||||
->map(fn (mixed $value, string|int $key): array => [
|
||||
'label' => Str::headline((string) $key),
|
||||
'value' => static::stringifySummaryValue($value),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'summary' => empty($highlights) ? 'No summary details captured.' : null,
|
||||
'highlights' => $highlights,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function namedItemsFromArray(mixed $items, string $emptyFallback): array
|
||||
{
|
||||
if (! is_array($items) || $items === []) {
|
||||
return [$emptyFallback];
|
||||
}
|
||||
|
||||
$labels = collect($items)
|
||||
->map(function (mixed $item): ?string {
|
||||
if (is_string($item)) {
|
||||
return trim($item) !== '' ? $item : null;
|
||||
}
|
||||
|
||||
if (! is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['display_name', 'displayName', 'name', 'title', 'id'] as $key) {
|
||||
$value = $item[$key] ?? null;
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
->filter()
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $labels === [] ? [$emptyFallback] : $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function findingEntryLabel(array $entry): ?string
|
||||
{
|
||||
$title = $entry['title'] ?? null;
|
||||
$severity = $entry['severity'] ?? null;
|
||||
$status = $entry['status'] ?? null;
|
||||
|
||||
if (! is_string($title) || trim($title) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [trim($title)];
|
||||
|
||||
if (is_string($severity) && trim($severity) !== '') {
|
||||
$parts[] = Str::headline($severity);
|
||||
}
|
||||
|
||||
if (is_string($status) && trim($status) !== '') {
|
||||
$parts[] = Str::headline($status);
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function operationEntryLabel(array $entry): ?string
|
||||
{
|
||||
$type = $entry['type'] ?? null;
|
||||
|
||||
if (! is_string($type) || trim($type) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [static::operationTypeLabel($type)];
|
||||
|
||||
$stateLabel = static::operationEntryStateLabel($entry);
|
||||
|
||||
if ($stateLabel !== null) {
|
||||
$parts[] = $stateLabel;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
public static function canExpireRecord(EvidenceSnapshot $record): bool
|
||||
{
|
||||
return (string) $record->status !== EvidenceSnapshotStatus::Expired->value;
|
||||
}
|
||||
|
||||
private static function operationTypeLabel(string $type): string
|
||||
{
|
||||
$label = OperationCatalog::label($type);
|
||||
|
||||
return $label === 'Unknown operation' ? 'Operation' : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private static function operationEntryStateLabel(array $entry): ?string
|
||||
{
|
||||
$status = is_string($entry['status'] ?? null) ? trim((string) $entry['status']) : null;
|
||||
$outcome = is_string($entry['outcome'] ?? null) ? trim((string) $entry['outcome']) : null;
|
||||
|
||||
return match ($status) {
|
||||
OperationRunStatus::Queued->value => 'Queued',
|
||||
OperationRunStatus::Running->value => 'Running',
|
||||
OperationRunStatus::Completed->value => match ($outcome) {
|
||||
OperationRunOutcome::Succeeded->value => 'Completed',
|
||||
OperationRunOutcome::PartiallySucceeded->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::PartiallySucceeded->value],
|
||||
OperationRunOutcome::Blocked->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Blocked->value],
|
||||
OperationRunOutcome::Failed->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Failed->value],
|
||||
OperationRunOutcome::Cancelled->value => OperationRunOutcome::uiLabels(true)[OperationRunOutcome::Cancelled->value],
|
||||
default => 'Completed',
|
||||
},
|
||||
default => $outcome !== null ? (OperationRunOutcome::uiLabels(true)[$outcome] ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function stringifySummaryValue(mixed $value): string
|
||||
{
|
||||
return match (true) {
|
||||
$value === null => '—',
|
||||
is_bool($value) => $value ? 'Yes' : 'No',
|
||||
is_scalar($value) => (string) $value,
|
||||
default => '—',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function executeGeneration(array $data): void
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
Notification::make()->danger()->title('Unable to create snapshot — missing context.')->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = app(EvidenceSnapshotService::class)->generate(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
allowStale: (bool) ($data['allow_stale'] ?? false),
|
||||
);
|
||||
|
||||
if (! $snapshot->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Snapshot already available')
|
||||
->body('A matching active snapshot already exists. No new run was started.')
|
||||
->actions([
|
||||
Actions\Action::make('view_snapshot')
|
||||
->label('View snapshot')
|
||||
->url(static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Create snapshot queued')
|
||||
->body('The snapshot is being generated in the background.')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($snapshot->operation_run_id ? OperationRunLinks::tenantlessView((int) $snapshot->operation_run_id) : static::getUrl('view', ['record' => $snapshot], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
class ListEvidenceSnapshots extends ListRecords
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('create_snapshot')
|
||||
->label('Create snapshot')
|
||||
->icon('heroicon-o-plus')
|
||||
->action(fn (array $data): mixed => EvidenceSnapshotResource::executeGeneration($data))
|
||||
->form([
|
||||
Section::make('Snapshot options')
|
||||
->schema([
|
||||
Toggle::make('allow_stale')
|
||||
->label('Allow stale dimensions')
|
||||
->default(false),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ViewEvidenceSnapshot extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EvidenceSnapshotResource::class;
|
||||
|
||||
protected function resolveRecord(int|string $key): Model
|
||||
{
|
||||
return EvidenceSnapshotResource::resolveScopedRecordOrFail($key);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
||||
Actions\Action::make('view_review_pack')
|
||||
->label('View review pack')
|
||||
->icon('heroicon-o-document-text')
|
||||
->color('gray')
|
||||
->url(function (): ?string {
|
||||
$pack = $this->latestReviewPack();
|
||||
|
||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
||||
})
|
||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('refresh_snapshot')
|
||||
->label('Refresh evidence')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->refresh($this->record, $user);
|
||||
|
||||
Notification::make()->success()->title('Refresh evidence queued')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('expire_snapshot')
|
||||
->label('Expire snapshot')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->hidden(fn (): bool => ! EvidenceSnapshotResource::canExpireRecord($this->record))
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
app(EvidenceSnapshotService::class)->expire($this->record, $user);
|
||||
$this->refreshFormData(['status', 'expires_at']);
|
||||
|
||||
Notification::make()->success()->title('Snapshot expired')->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::EVIDENCE_MANAGE)
|
||||
->apply(),
|
||||
];
|
||||
}
|
||||
|
||||
private function latestReviewPack(): ?ReviewPack
|
||||
{
|
||||
return $this->record->reviewPacks()
|
||||
->latest('created_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\Tenant;
|
||||
@ -177,6 +179,33 @@ public static function infolist(Schema $schema): Schema
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Evidence snapshot')
|
||||
->schema([
|
||||
TextEntry::make('summary.evidence_resolution.outcome')
|
||||
->label('Resolution')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('evidenceSnapshot.id')
|
||||
->label('Snapshot')
|
||||
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
|
||||
->url(fn (ReviewPack $record): ?string => $record->evidenceSnapshot
|
||||
? TenantEvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
|
||||
: null),
|
||||
TextEntry::make('evidenceSnapshot.completeness_state')
|
||||
->label('Snapshot completeness')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EvidenceCompleteness))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EvidenceCompleteness))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EvidenceCompleteness))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EvidenceCompleteness))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('summary.evidence_resolution.snapshot_fingerprint')
|
||||
->label('Snapshot fingerprint')
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -331,7 +360,23 @@ public static function executeGeneration(array $data): void
|
||||
'include_operations' => (bool) ($data['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
try {
|
||||
$reviewPack = $service->generate($tenant, $user, $options);
|
||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||
$reasons = $exception->result->reasons;
|
||||
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(match ($exception->result->outcome) {
|
||||
'missing_snapshot' => 'Create snapshot required',
|
||||
'snapshot_ineligible' => 'Snapshot is not eligible',
|
||||
default => 'Unable to generate review pack',
|
||||
})
|
||||
->body($reasons === [] ? $exception->getMessage() : implode(' ', $reasons))
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $reviewPack->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
|
||||
@ -177,7 +177,7 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
if ($this->isLivewireUpdatePath($path)) {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
@ -193,6 +193,11 @@ private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isLivewireUpdatePath(string $path): bool
|
||||
{
|
||||
return preg_match('#^/livewire(?:-[^/]+)?/update$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isChooserFirstPath(string $path): bool
|
||||
{
|
||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||
|
||||
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
118
app/Jobs/GenerateEvidenceSnapshotJob.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class GenerateEvidenceSnapshotJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public int $snapshotId,
|
||||
public int $operationRunId,
|
||||
) {}
|
||||
|
||||
public function handle(EvidenceSnapshotService $service, OperationRunService $operationRuns): void
|
||||
{
|
||||
$snapshot = EvidenceSnapshot::query()->with('tenant')->find($this->snapshotId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot || ! $operationRun instanceof OperationRun || ! $snapshot->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRuns->updateRun($operationRun, OperationRunStatus::Running->value, OperationRunOutcome::Pending->value);
|
||||
$snapshot->update(['status' => EvidenceSnapshotStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$payload = $service->buildSnapshotPayload($snapshot->tenant);
|
||||
$previousActive = EvidenceSnapshot::query()
|
||||
->where('tenant_id', (int) $snapshot->tenant_id)
|
||||
->where('workspace_id', (int) $snapshot->workspace_id)
|
||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
||||
->whereKeyNot((int) $snapshot->getKey())
|
||||
->first();
|
||||
|
||||
$snapshot->items()->delete();
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'workspace_id' => (int) $snapshot->workspace_id,
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($previousActive instanceof EvidenceSnapshot && $previousActive->fingerprint !== $payload['fingerprint']) {
|
||||
$previousActive->update([
|
||||
'status' => EvidenceSnapshotStatus::Superseded->value,
|
||||
]);
|
||||
}
|
||||
|
||||
$snapshot->update([
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'previous_fingerprint' => $previousActive?->fingerprint,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'generated_at' => now(),
|
||||
'summary' => $payload['summary'],
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'created' => 1,
|
||||
'finding_count' => (int) ($payload['summary']['finding_count'] ?? 0),
|
||||
'report_count' => (int) ($payload['summary']['report_count'] ?? 0),
|
||||
'operation_count' => (int) ($payload['summary']['operation_count'] ?? 0),
|
||||
'errors_recorded' => 0,
|
||||
],
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
$snapshot->update([
|
||||
'status' => EvidenceSnapshotStatus::Failed->value,
|
||||
'summary' => [
|
||||
'error' => $throwable->getMessage(),
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRuns->updateRun(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'evidence_snapshot_generation.failed',
|
||||
'message' => $throwable->getMessage(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,10 +4,10 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\SecretClassificationService;
|
||||
use App\Services\OperationRunService;
|
||||
@ -34,7 +34,7 @@ public function __construct(
|
||||
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$reviewPack = ReviewPack::query()->find($this->reviewPackId);
|
||||
$reviewPack = ReviewPack::query()->with(['tenant', 'evidenceSnapshot.items'])->find($this->reviewPackId);
|
||||
$operationRun = OperationRun::query()->find($this->operationRunId);
|
||||
|
||||
if (! $reviewPack instanceof ReviewPack || ! $operationRun instanceof OperationRun) {
|
||||
@ -54,12 +54,20 @@ public function handle(OperationRunService $operationRunService): void
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $reviewPack->evidenceSnapshot;
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'missing_snapshot', 'Evidence snapshot not found');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark running via OperationRunService (auto-sets started_at)
|
||||
$operationRunService->updateRun($operationRun, OperationRunStatus::Running->value);
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Generating->value]);
|
||||
|
||||
try {
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $operationRunService);
|
||||
$this->executeGeneration($reviewPack, $operationRun, $tenant, $snapshot, $operationRunService);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($reviewPack, $operationRun, $operationRunService, 'generation_error', $e->getMessage());
|
||||
|
||||
@ -67,59 +75,31 @@ public function handle(OperationRunService $operationRunService): void
|
||||
}
|
||||
}
|
||||
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, OperationRunService $operationRunService): void
|
||||
private function executeGeneration(ReviewPack $reviewPack, OperationRun $operationRun, Tenant $tenant, EvidenceSnapshot $snapshot, OperationRunService $operationRunService): void
|
||||
{
|
||||
$options = $reviewPack->options ?? [];
|
||||
$includePii = (bool) ($options['include_pii'] ?? true);
|
||||
$includeOperations = (bool) ($options['include_operations'] ?? true);
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$findingsPayload = $this->itemSummaryPayload($items->get('findings_summary'));
|
||||
$permissionPosturePayload = $this->itemSummaryPayload($items->get('permission_posture'));
|
||||
$entraRolesPayload = $this->itemSummaryPayload($items->get('entra_admin_roles'));
|
||||
$operationsPayload = $this->itemSummaryPayload($items->get('operations_summary'));
|
||||
|
||||
// 1. Collect StoredReports
|
||||
$storedReports = StoredReport::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->get()
|
||||
->keyBy('report_type');
|
||||
|
||||
// 2. Collect open findings
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->orderBy('severity')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// 3. Collect tenant hardening fields
|
||||
$hardening = [
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||
];
|
||||
|
||||
// 4. Collect recent OperationRuns (30 days)
|
||||
$recentOperations = $includeOperations
|
||||
? OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
: collect();
|
||||
|
||||
// 5. Data freshness
|
||||
$dataFreshness = $this->computeDataFreshness($storedReports, $findings, $tenant);
|
||||
$findings = collect(is_array($findingsPayload['entries'] ?? null) ? $findingsPayload['entries'] : []);
|
||||
$recentOperations = collect($includeOperations && is_array($operationsPayload['entries'] ?? null) ? $operationsPayload['entries'] : []);
|
||||
$hardening = is_array($snapshot->summary['hardening'] ?? null) ? $snapshot->summary['hardening'] : [];
|
||||
$dataFreshness = $this->computeDataFreshness($items);
|
||||
|
||||
// 6. Build file map
|
||||
$fileMap = $this->buildFileMap(
|
||||
storedReports: $storedReports,
|
||||
findings: $findings,
|
||||
hardening: $hardening,
|
||||
permissionPosture: is_array($permissionPosturePayload['payload'] ?? null) ? $permissionPosturePayload['payload'] : [],
|
||||
entraAdminRoles: ['roles' => is_array($entraRolesPayload['roles'] ?? null) ? $entraRolesPayload['roles'] : []],
|
||||
recentOperations: $recentOperations,
|
||||
tenant: $tenant,
|
||||
snapshot: $snapshot,
|
||||
dataFreshness: $dataFreshness,
|
||||
includePii: $includePii,
|
||||
includeOperations: $includeOperations,
|
||||
@ -154,16 +134,23 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
|
||||
// 11. Compute summary
|
||||
$summary = [
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'finding_count' => (int) ($snapshot->summary['finding_count'] ?? $findings->count()),
|
||||
'report_count' => (int) ($snapshot->summary['report_count'] ?? 0),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'data_freshness' => $dataFreshness,
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
],
|
||||
];
|
||||
|
||||
// 12. Update ReviewPack
|
||||
$retentionDays = (int) config('tenantpilot.review_pack.retention_days', 90);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'sha256' => $sha256,
|
||||
'file_size' => $fileSize,
|
||||
@ -184,17 +171,15 @@ private function executeGeneration(ReviewPack $reviewPack, OperationRun $operati
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<string, StoredReport> $storedReports
|
||||
* @param \Illuminate\Database\Eloquent\Collection<int, Finding> $findings
|
||||
* @return array<string, ?string>
|
||||
*/
|
||||
private function computeDataFreshness($storedReports, $findings, Tenant $tenant): array
|
||||
private function computeDataFreshness($items): array
|
||||
{
|
||||
return [
|
||||
'permission_posture' => $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE)?->updated_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)?->updated_at?->toIso8601String(),
|
||||
'findings' => $findings->max('updated_at')?->toIso8601String() ?? $findings->max('created_at')?->toIso8601String(),
|
||||
'hardening' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'permission_posture' => $items->get('permission_posture')?->freshness_at?->toIso8601String(),
|
||||
'entra_admin_roles' => $items->get('entra_admin_roles')?->freshness_at?->toIso8601String(),
|
||||
'findings' => $items->get('findings_summary')?->freshness_at?->toIso8601String(),
|
||||
'hardening' => $items->get('baseline_drift_posture')?->freshness_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
@ -204,11 +189,13 @@ private function computeDataFreshness($storedReports, $findings, Tenant $tenant)
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildFileMap(
|
||||
$storedReports,
|
||||
$findings,
|
||||
array $hardening,
|
||||
array $permissionPosture,
|
||||
array $entraAdminRoles,
|
||||
$recentOperations,
|
||||
Tenant $tenant,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $dataFreshness,
|
||||
bool $includePii,
|
||||
bool $includeOperations,
|
||||
@ -227,6 +214,12 @@ private function buildFileMap(
|
||||
'tenant_id' => $tenant->external_id,
|
||||
'tenant_name' => $includePii ? $tenant->name : '[REDACTED]',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'evidence_snapshot' => [
|
||||
'id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'generated_at' => $snapshot->generated_at?->toIso8601String(),
|
||||
],
|
||||
'redaction_integrity' => [
|
||||
'protected_values_hidden' => true,
|
||||
'note' => RedactionIntegrity::protectedValueNote(),
|
||||
@ -241,16 +234,14 @@ private function buildFileMap(
|
||||
$files['operations.csv'] = $this->buildOperationsCsv($recentOperations, $includePii);
|
||||
|
||||
// reports/entra_admin_roles.json
|
||||
$entraReport = $storedReports->get(StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES);
|
||||
$files['reports/entra_admin_roles.json'] = json_encode(
|
||||
$entraReport ? $this->redactReportPayload($entraReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($entraAdminRoles, $includePii),
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
// reports/permission_posture.json
|
||||
$postureReport = $storedReports->get(StoredReport::REPORT_TYPE_PERMISSION_POSTURE);
|
||||
$files['reports/permission_posture.json'] = json_encode(
|
||||
$postureReport ? $this->redactReportPayload($postureReport->payload ?? [], $includePii) : [],
|
||||
$this->redactReportPayload($permissionPosture, $includePii),
|
||||
JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR,
|
||||
);
|
||||
|
||||
@ -258,8 +249,9 @@ private function buildFileMap(
|
||||
$files['summary.json'] = json_encode([
|
||||
'data_freshness' => $dataFreshness,
|
||||
'finding_count' => $findings->count(),
|
||||
'report_count' => $storedReports->count(),
|
||||
'report_count' => count(array_filter([$permissionPosture, $entraAdminRoles], static fn (array $payload): bool => $payload !== [])),
|
||||
'operation_count' => $recentOperations->count(),
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
|
||||
return $files;
|
||||
@ -273,18 +265,33 @@ private function buildFileMap(
|
||||
private function buildFindingsCsv($findings, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'finding_type', 'severity', 'status', 'title', 'description', 'created_at', 'updated_at']);
|
||||
|
||||
foreach ($findings as $finding) {
|
||||
fputcsv($handle, [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
$row = $finding instanceof Finding
|
||||
? [
|
||||
$finding->id,
|
||||
$finding->finding_type,
|
||||
$finding->severity,
|
||||
$finding->status,
|
||||
$includePii ? ($finding->title ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding->description ?? '') : '[REDACTED]',
|
||||
$finding->created_at?->toIso8601String(),
|
||||
$finding->updated_at?->toIso8601String(),
|
||||
]
|
||||
: [
|
||||
$finding['id'] ?? '',
|
||||
$finding['finding_type'] ?? '',
|
||||
$finding['severity'] ?? '',
|
||||
$finding['status'] ?? '',
|
||||
$includePii ? ($finding['title'] ?? '') : '[REDACTED]',
|
||||
$includePii ? ($finding['description'] ?? '') : '[REDACTED]',
|
||||
$finding['created_at'] ?? '',
|
||||
$finding['updated_at'] ?? '',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
...$row,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -301,17 +308,31 @@ private function buildFindingsCsv($findings, bool $includePii): string
|
||||
private function buildOperationsCsv($operations, bool $includePii): string
|
||||
{
|
||||
$handle = fopen('php://temp', 'r+');
|
||||
fputcsv($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
$this->writeCsvRow($handle, ['id', 'type', 'status', 'outcome', 'initiator', 'started_at', 'completed_at']);
|
||||
|
||||
foreach ($operations as $operation) {
|
||||
fputcsv($handle, [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
$row = $operation instanceof OperationRun
|
||||
? [
|
||||
$operation->id,
|
||||
$operation->type,
|
||||
$operation->status,
|
||||
$operation->outcome,
|
||||
$includePii ? ($operation->user?->name ?? '') : '[REDACTED]',
|
||||
$operation->started_at?->toIso8601String(),
|
||||
$operation->completed_at?->toIso8601String(),
|
||||
]
|
||||
: [
|
||||
$operation['id'] ?? '',
|
||||
$operation['type'] ?? '',
|
||||
$operation['status'] ?? '',
|
||||
$operation['outcome'] ?? '',
|
||||
$includePii ? ($operation['initiator_name'] ?? '') : '[REDACTED]',
|
||||
$operation['started_at'] ?? '',
|
||||
$operation['completed_at'] ?? '',
|
||||
];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
...$row,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -322,6 +343,15 @@ private function buildOperationsCsv($operations, bool $includePii): string
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resource $handle
|
||||
* @param array<int, mixed> $row
|
||||
*/
|
||||
private function writeCsvRow($handle, array $row): void
|
||||
{
|
||||
fputcsv($handle, $row, ',', '"', '\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact PII from a report payload.
|
||||
*
|
||||
@ -433,7 +463,15 @@ private function assembleZip(string $tempFile, array $fileMap): void
|
||||
|
||||
private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun, OperationRunService $operationRunService, string $reasonCode, string $errorMessage): void
|
||||
{
|
||||
$reviewPack->update(['status' => ReviewPackStatus::Failed->value]);
|
||||
$reviewPack->update([
|
||||
'status' => ReviewPackStatus::Failed->value,
|
||||
'summary' => array_merge($reviewPack->summary ?? [], [
|
||||
'evidence_resolution' => array_merge($reviewPack->summary['evidence_resolution'] ?? [], [
|
||||
'outcome' => $reasonCode,
|
||||
'reasons' => [mb_substr($errorMessage, 0, 500)],
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
@ -444,4 +482,13 @@ private function markFailed(ReviewPack $reviewPack, OperationRun $operationRun,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function itemSummaryPayload(mixed $item): array
|
||||
{
|
||||
if (! $item instanceof \App\Models\EvidenceSnapshotItem || ! is_array($item->summary_payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $item->summary_payload;
|
||||
}
|
||||
}
|
||||
|
||||
129
app/Models/EvidenceSnapshot.php
Normal file
129
app/Models/EvidenceSnapshot.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeForTenant(Builder $query, int $tenantId): Builder
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', EvidenceSnapshotStatus::Active->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
*/
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereIn('status', [
|
||||
EvidenceSnapshotStatus::Queued->value,
|
||||
EvidenceSnapshotStatus::Generating->value,
|
||||
EvidenceSnapshotStatus::Active->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
return in_array((string) $this->status, [
|
||||
EvidenceSnapshotStatus::Queued->value,
|
||||
EvidenceSnapshotStatus::Generating->value,
|
||||
EvidenceSnapshotStatus::Active->value,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function completenessState(): EvidenceCompletenessState
|
||||
{
|
||||
return EvidenceCompletenessState::tryFrom((string) $this->completeness_state)
|
||||
?? EvidenceCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
48
app/Models/EvidenceSnapshotItem.php
Normal file
48
app/Models/EvidenceSnapshotItem.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EvidenceSnapshotItem extends Model
|
||||
{
|
||||
use DerivesWorkspaceIdFromTenant;
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'boolean',
|
||||
'measured_at' => 'datetime',
|
||||
'freshness_at' => 'datetime',
|
||||
'summary_payload' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function snapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class, 'evidence_snapshot_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -160,6 +160,53 @@ public function hasOpenStatus(): bool
|
||||
return self::isOpenStatus($this->status);
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): self
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$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
|
||||
*/
|
||||
public function reopen(array $evidence): self
|
||||
{
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_NEW,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
]);
|
||||
|
||||
$this->save();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolvedSubjectDisplayName(): ?string
|
||||
{
|
||||
$displayName = $this->getAttribute('subject_display_name');
|
||||
|
||||
@ -71,6 +71,14 @@ public function initiator(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<EvidenceSnapshot, $this>
|
||||
*/
|
||||
public function evidenceSnapshot(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<self> $query
|
||||
* @return Builder<self>
|
||||
|
||||
@ -261,6 +261,11 @@ public function auditLogs(): HasMany
|
||||
return $this->hasMany(AuditLog::class);
|
||||
}
|
||||
|
||||
public function evidenceSnapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(EvidenceSnapshot::class);
|
||||
}
|
||||
|
||||
public function settings(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantSetting::class);
|
||||
|
||||
69
app/Policies/EvidenceSnapshotPolicy.php
Normal file
69
app/Policies/EvidenceSnapshotPolicy.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class EvidenceSnapshotPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
}
|
||||
|
||||
public function view(User $user, EvidenceSnapshot $snapshot): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_VIEW);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
}
|
||||
|
||||
public function delete(User $user, EvidenceSnapshot $snapshot): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $snapshot->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::EVIDENCE_MANAGE);
|
||||
}
|
||||
}
|
||||
@ -177,6 +177,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->unsavedChangesAlerts()
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
@ -34,6 +34,7 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Blue,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||
|
||||
@ -87,6 +87,7 @@ public function panel(Panel $panel): Panel
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->databaseNotifications()
|
||||
->databaseNotificationsPolling('30s')
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
||||
@ -50,6 +50,8 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Manager->value => [
|
||||
@ -84,6 +86,8 @@ class RoleCapabilityMap
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::REVIEW_PACK_MANAGE,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
Capabilities::EVIDENCE_MANAGE,
|
||||
],
|
||||
|
||||
TenantRole::Operator->value => [
|
||||
@ -107,6 +111,7 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
|
||||
TenantRole::Readonly->value => [
|
||||
@ -123,6 +128,7 @@ class RoleCapabilityMap
|
||||
Capabilities::ENTRA_ROLES_VIEW,
|
||||
|
||||
Capabilities::REVIEW_PACK_VIEW,
|
||||
Capabilities::EVIDENCE_VIEW,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
30
app/Services/Evidence/Contracts/EvidenceSourceProvider.php
Normal file
30
app/Services/Evidence/Contracts/EvidenceSourceProvider.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Contracts;
|
||||
|
||||
use App\Models\Tenant;
|
||||
|
||||
interface EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* dimension_key: string,
|
||||
* state: string,
|
||||
* required: bool,
|
||||
* source_kind: string,
|
||||
* source_record_type: ?string,
|
||||
* source_record_id: ?string,
|
||||
* source_fingerprint: ?string,
|
||||
* measured_at: ?\DateTimeInterface,
|
||||
* freshness_at: ?\DateTimeInterface,
|
||||
* summary_payload: array<string, mixed>,
|
||||
* fingerprint_payload: array<string, mixed>,
|
||||
* sort_order: int
|
||||
* }
|
||||
*/
|
||||
public function collect(Tenant $tenant): array;
|
||||
}
|
||||
42
app/Services/Evidence/EvidenceCompletenessEvaluator.php
Normal file
42
app/Services/Evidence/EvidenceCompletenessEvaluator.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessEvaluator
|
||||
{
|
||||
/**
|
||||
* @param list<array{state: string, required: bool}> $items
|
||||
*/
|
||||
public function evaluate(array $items): EvidenceCompletenessState
|
||||
{
|
||||
$requiredItems = array_values(array_filter($items, static fn (array $item): bool => $item['required'] === true));
|
||||
|
||||
if ($requiredItems === []) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Missing->value) {
|
||||
return EvidenceCompletenessState::Missing;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Stale->value) {
|
||||
return EvidenceCompletenessState::Stale;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requiredItems as $item) {
|
||||
if ($item['state'] === EvidenceCompletenessState::Partial->value) {
|
||||
return EvidenceCompletenessState::Partial;
|
||||
}
|
||||
}
|
||||
|
||||
return EvidenceCompletenessState::Complete;
|
||||
}
|
||||
}
|
||||
19
app/Services/Evidence/EvidenceResolutionRequest.php
Normal file
19
app/Services/Evidence/EvidenceResolutionRequest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceResolutionRequest
|
||||
{
|
||||
/**
|
||||
* @param list<string> $requiredDimensions
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $workspaceId,
|
||||
public readonly int $tenantId,
|
||||
public readonly ?int $snapshotId = null,
|
||||
public readonly array $requiredDimensions = [],
|
||||
public readonly bool $allowStale = false,
|
||||
) {}
|
||||
}
|
||||
47
app/Services/Evidence/EvidenceResolutionResult.php
Normal file
47
app/Services/Evidence/EvidenceResolutionResult.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
|
||||
final class EvidenceResolutionResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $eligibleDimensions
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly string $outcome,
|
||||
public readonly ?EvidenceSnapshot $snapshot,
|
||||
public readonly array $eligibleDimensions = [],
|
||||
public readonly array $reasons = [],
|
||||
) {}
|
||||
|
||||
public static function resolved(EvidenceSnapshot $snapshot, array $eligibleDimensions): self
|
||||
{
|
||||
return new self('resolved', $snapshot, $eligibleDimensions, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function missingSnapshot(array $reasons = []): self
|
||||
{
|
||||
return new self('missing_snapshot', null, [], $reasons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $reasons
|
||||
*/
|
||||
public static function snapshotIneligible(EvidenceSnapshot $snapshot, array $reasons): self
|
||||
{
|
||||
return new self('snapshot_ineligible', $snapshot, [], $reasons);
|
||||
}
|
||||
|
||||
public function isResolved(): bool
|
||||
{
|
||||
return $this->outcome === 'resolved' && $this->snapshot instanceof EvidenceSnapshot;
|
||||
}
|
||||
}
|
||||
35
app/Services/Evidence/EvidenceSnapshotFingerprint.php
Normal file
35
app/Services/Evidence/EvidenceSnapshotFingerprint.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
final class EvidenceSnapshotFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function hash(array $payload): string
|
||||
{
|
||||
return hash('sha256', json_encode(self::normalize($payload), JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private static function normalize(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
ksort($value);
|
||||
|
||||
return array_map(self::normalize(...), $value);
|
||||
}
|
||||
|
||||
if ($value instanceof \BackedEnum) {
|
||||
return $value->value;
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
62
app/Services/Evidence/EvidenceSnapshotResolver.php
Normal file
62
app/Services/Evidence/EvidenceSnapshotResolver.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceSnapshotResolver
|
||||
{
|
||||
public function resolve(EvidenceResolutionRequest $request): EvidenceResolutionResult
|
||||
{
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('items')
|
||||
->where('workspace_id', $request->workspaceId)
|
||||
->where('tenant_id', $request->tenantId)
|
||||
->where('status', 'active')
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->latest('generated_at');
|
||||
|
||||
if ($request->snapshotId !== null) {
|
||||
$query->whereKey($request->snapshotId);
|
||||
}
|
||||
|
||||
$snapshot = $query->first();
|
||||
|
||||
if (! $snapshot instanceof EvidenceSnapshot) {
|
||||
return EvidenceResolutionResult::missingSnapshot(['No active snapshot found.']);
|
||||
}
|
||||
|
||||
$requiredDimensions = $request->requiredDimensions;
|
||||
$items = $snapshot->items->keyBy('dimension_key');
|
||||
$reasons = [];
|
||||
|
||||
foreach ($requiredDimensions as $dimension) {
|
||||
$item = $items->get($dimension);
|
||||
|
||||
if ($item === null) {
|
||||
$reasons[] = sprintf('Missing dimension: %s', $dimension);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $item->state === EvidenceCompletenessState::Missing->value) {
|
||||
$reasons[] = sprintf('Missing dimension: %s', $dimension);
|
||||
}
|
||||
|
||||
if (! $request->allowStale && (string) $item->state === EvidenceCompletenessState::Stale->value) {
|
||||
$reasons[] = sprintf('Stale dimension: %s', $dimension);
|
||||
}
|
||||
}
|
||||
|
||||
if ($reasons !== []) {
|
||||
return EvidenceResolutionResult::snapshotIneligible($snapshot, $reasons);
|
||||
}
|
||||
|
||||
return EvidenceResolutionResult::resolved($snapshot, $requiredDimensions === [] ? $items->keys()->all() : $requiredDimensions);
|
||||
}
|
||||
}
|
||||
252
app/Services/Evidence/EvidenceSnapshotService.php
Normal file
252
app/Services/Evidence/EvidenceSnapshotService.php
Normal file
@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence;
|
||||
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Services\Evidence\Sources\BaselineDriftPostureSource;
|
||||
use App\Services\Evidence\Sources\EntraAdminRolesSource;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\Evidence\Sources\OperationsSummarySource;
|
||||
use App\Services\Evidence\Sources\PermissionPostureSource;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class EvidenceSnapshotService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunService $operationRuns,
|
||||
private readonly WorkspaceAuditLogger $auditLogger,
|
||||
private readonly EvidenceCompletenessEvaluator $completenessEvaluator,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, User $user, bool $allowStale = false): EvidenceSnapshot
|
||||
{
|
||||
$fingerprint = $this->computeFingerprint($tenant);
|
||||
$existing = $this->findExistingSnapshot($tenant, $fingerprint);
|
||||
|
||||
if ($existing instanceof EvidenceSnapshot) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$operationRun = $this->operationRuns->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
context: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'allow_stale' => $allowStale,
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'status' => EvidenceSnapshotStatus::Queued->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Missing->value,
|
||||
'summary' => [
|
||||
'allow_stale' => $allowStale,
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRuns->dispatchOrFail($operationRun, function () use ($snapshot, $operationRun): void {
|
||||
GenerateEvidenceSnapshotJob::dispatch(
|
||||
snapshotId: (int) $snapshot->getKey(),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
);
|
||||
});
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotCreated,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'status' => EvidenceSnapshotStatus::Queued->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $snapshot->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
||||
operationRunId: (int) $operationRun->getKey(),
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public function refresh(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
{
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new InvalidArgumentException('Snapshot tenant could not be resolved.');
|
||||
}
|
||||
|
||||
$refreshed = $this->generate($tenant, $user);
|
||||
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotRefreshed,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'previous_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'new_snapshot_id' => (int) $refreshed->getKey(),
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $refreshed->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $refreshed->getKey()),
|
||||
operationRunId: $refreshed->operation_run_id,
|
||||
tenant: $tenant,
|
||||
);
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function expire(EvidenceSnapshot $snapshot, User $user): EvidenceSnapshot
|
||||
{
|
||||
$snapshot->forceFill([
|
||||
'status' => EvidenceSnapshotStatus::Expired->value,
|
||||
'expires_at' => now(),
|
||||
])->save();
|
||||
|
||||
$tenant = $snapshot->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
$this->auditLogger->log(
|
||||
workspace: $tenant->workspace,
|
||||
action: AuditActionId::EvidenceSnapshotExpired,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'before_status' => EvidenceSnapshotStatus::Active->value,
|
||||
'after_status' => EvidenceSnapshotStatus::Expired->value,
|
||||
],
|
||||
],
|
||||
actor: $user,
|
||||
resourceType: 'evidence_snapshot',
|
||||
resourceId: (string) $snapshot->getKey(),
|
||||
targetLabel: sprintf('Evidence snapshot #%d', (int) $snapshot->getKey()),
|
||||
tenant: $tenant,
|
||||
);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<EvidenceSourceProvider>
|
||||
*/
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
app(FindingsSummarySource::class),
|
||||
app(PermissionPostureSource::class),
|
||||
app(EntraAdminRolesSource::class),
|
||||
app(BaselineDriftPostureSource::class),
|
||||
app(OperationsSummarySource::class),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: list<array<string, mixed>>, fingerprint: string, completeness: string, summary: array<string, mixed>}
|
||||
*/
|
||||
public function buildSnapshotPayload(Tenant $tenant): array
|
||||
{
|
||||
$items = [];
|
||||
$fingerprintPayload = [];
|
||||
|
||||
foreach ($this->providers() as $provider) {
|
||||
$item = $provider->collect($tenant);
|
||||
$items[] = $item;
|
||||
$fingerprintPayload[$provider->key()] = $item['fingerprint_payload'];
|
||||
}
|
||||
|
||||
$completeness = $this->completenessEvaluator->evaluate(array_map(
|
||||
static fn (array $item): array => [
|
||||
'state' => (string) $item['state'],
|
||||
'required' => (bool) $item['required'],
|
||||
],
|
||||
$items,
|
||||
));
|
||||
|
||||
$summary = [
|
||||
'dimension_count' => count($items),
|
||||
'finding_count' => (int) ($items[0]['summary_payload']['count'] ?? 0),
|
||||
'report_count' => count(array_filter($items, static fn (array $item): bool => in_array($item['dimension_key'], ['permission_posture', 'entra_admin_roles'], true) && $item['source_record_id'] !== null)),
|
||||
'operation_count' => (int) ($items[4]['summary_payload']['operation_count'] ?? 0),
|
||||
'missing_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Missing->value)),
|
||||
'stale_dimensions' => count(array_filter($items, static fn (array $item): bool => $item['state'] === EvidenceCompletenessState::Stale->value)),
|
||||
'dimensions' => array_map(static fn (array $item): array => [
|
||||
'key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
], $items),
|
||||
'hardening' => [
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
'rbac_last_setup_at' => $tenant->rbac_last_setup_at?->toIso8601String(),
|
||||
'rbac_canary_results' => $tenant->rbac_canary_results,
|
||||
'rbac_last_warnings' => $tenant->rbac_last_warnings,
|
||||
'rbac_scope_mode' => $tenant->rbac_scope_mode,
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'fingerprint' => EvidenceSnapshotFingerprint::hash($fingerprintPayload),
|
||||
'completeness' => $completeness->value,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
public function computeFingerprint(Tenant $tenant): string
|
||||
{
|
||||
return $this->buildSnapshotPayload($tenant)['fingerprint'];
|
||||
}
|
||||
|
||||
public function checkActiveRun(Tenant $tenant): bool
|
||||
{
|
||||
return $this->operationRuns->findCanonicalRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: OperationRunType::EvidenceSnapshotGenerate->value,
|
||||
identityInputs: [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'fingerprint' => $this->computeFingerprint($tenant),
|
||||
],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
private function findExistingSnapshot(Tenant $tenant, string $fingerprint): ?EvidenceSnapshot
|
||||
{
|
||||
return EvidenceSnapshot::query()
|
||||
->forTenant((int) $tenant->getKey())
|
||||
->where('workspace_id', (int) $tenant->workspace_id)
|
||||
->where('fingerprint', $fingerprint)
|
||||
->where('status', EvidenceSnapshotStatus::Active->value)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->first();
|
||||
}
|
||||
}
|
||||
57
app/Services/Evidence/Sources/BaselineDriftPostureSource.php
Normal file
57
app/Services/Evidence/Sources/BaselineDriftPostureSource.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class BaselineDriftPostureSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'baseline_drift_posture';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
|
||||
$isStale = $latest !== null && $latest->lt(now()->subDays(30));
|
||||
|
||||
$state = match (true) {
|
||||
$findings->isEmpty() => EvidenceCompletenessState::Missing->value,
|
||||
$isStale => EvidenceCompletenessState::Stale->value,
|
||||
default => EvidenceCompletenessState::Complete->value,
|
||||
};
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $state,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => Finding::class,
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => $findings->max('fingerprint'),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => [
|
||||
'drift_count' => $findings->count(),
|
||||
'open_drift_count' => $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count(),
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'latest' => $latest?->format(DATE_ATOM),
|
||||
'count' => $findings->count(),
|
||||
],
|
||||
'sort_order' => 40,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Services/Evidence/Sources/EntraAdminRolesSource.php
Normal file
51
app/Services/Evidence/Sources/EntraAdminRolesSource.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EntraAdminRolesSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'entra_admin_roles';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$payload = is_array($report?->payload) ? $report->payload : [];
|
||||
$roles = is_array($payload['roles'] ?? null) ? $payload['roles'] : [];
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $report instanceof StoredReport ? EvidenceCompletenessState::Complete->value : EvidenceCompletenessState::Missing->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'stored_report',
|
||||
'source_record_type' => StoredReport::class,
|
||||
'source_record_id' => $report instanceof StoredReport ? (string) $report->getKey() : null,
|
||||
'source_fingerprint' => $report?->fingerprint,
|
||||
'measured_at' => $report?->updated_at,
|
||||
'freshness_at' => $report?->updated_at,
|
||||
'summary_payload' => [
|
||||
'role_count' => count($roles),
|
||||
'roles' => $roles,
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'fingerprint' => $report?->fingerprint,
|
||||
'role_count' => count($roles),
|
||||
],
|
||||
'sort_order' => 30,
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Services/Evidence/Sources/FindingsSummarySource.php
Normal file
64
app/Services/Evidence/Sources/FindingsSummarySource.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'findings_summary';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$findings = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
$latest = $findings->max('updated_at') ?? $findings->max('created_at');
|
||||
|
||||
$summary = [
|
||||
'count' => $findings->count(),
|
||||
'open_count' => $findings->filter(fn (Finding $finding): bool => $finding->hasOpenStatus())->count(),
|
||||
'severity_counts' => [
|
||||
'critical' => $findings->where('severity', Finding::SEVERITY_CRITICAL)->count(),
|
||||
'high' => $findings->where('severity', Finding::SEVERITY_HIGH)->count(),
|
||||
'medium' => $findings->where('severity', Finding::SEVERITY_MEDIUM)->count(),
|
||||
'low' => $findings->where('severity', Finding::SEVERITY_LOW)->count(),
|
||||
],
|
||||
'entries' => $findings->map(static fn (Finding $finding): array => [
|
||||
'id' => (int) $finding->getKey(),
|
||||
'finding_type' => (string) $finding->finding_type,
|
||||
'severity' => (string) $finding->severity,
|
||||
'status' => (string) $finding->status,
|
||||
'title' => $finding->title,
|
||||
'description' => $finding->description,
|
||||
'created_at' => $finding->created_at?->toIso8601String(),
|
||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||
])->all(),
|
||||
];
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $findings->isEmpty() ? EvidenceCompletenessState::Missing->value : EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => 'finding',
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => $findings->max('fingerprint'),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => $summary,
|
||||
'fingerprint_payload' => $summary + ['latest' => $latest?->format(DATE_ATOM)],
|
||||
'sort_order' => 10,
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Services/Evidence/Sources/OperationsSummarySource.php
Normal file
62
app/Services/Evidence/Sources/OperationsSummarySource.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\OperationRunOutcome;
|
||||
|
||||
final class OperationsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'operations_summary';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->latest('created_at')
|
||||
->get();
|
||||
|
||||
$latest = $runs->max('created_at');
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $runs->isEmpty() ? EvidenceCompletenessState::Missing->value : EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'operation_rollup',
|
||||
'source_record_type' => OperationRun::class,
|
||||
'source_record_id' => null,
|
||||
'source_fingerprint' => hash('sha256', implode('|', $runs->pluck('run_identity_hash')->all())),
|
||||
'measured_at' => $latest,
|
||||
'freshness_at' => $latest,
|
||||
'summary_payload' => [
|
||||
'operation_count' => $runs->count(),
|
||||
'failed_count' => $runs->where('outcome', OperationRunOutcome::Failed->value)->count(),
|
||||
'partial_count' => $runs->where('outcome', OperationRunOutcome::PartiallySucceeded->value)->count(),
|
||||
'entries' => $runs->map(static fn (OperationRun $run): array => [
|
||||
'id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
'initiator_name' => $run->user?->name,
|
||||
'started_at' => $run->started_at?->toIso8601String(),
|
||||
'completed_at' => $run->completed_at?->toIso8601String(),
|
||||
])->all(),
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'count' => $runs->count(),
|
||||
'latest' => $latest?->format(DATE_ATOM),
|
||||
'hashes' => $runs->pluck('run_identity_hash')->values()->all(),
|
||||
],
|
||||
'sort_order' => 50,
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Services/Evidence/Sources/PermissionPostureSource.php
Normal file
61
app/Services/Evidence/Sources/PermissionPostureSource.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Evidence\Sources;
|
||||
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class PermissionPostureSource implements EvidenceSourceProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'permission_posture';
|
||||
}
|
||||
|
||||
public function collect(Tenant $tenant): array
|
||||
{
|
||||
$report = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('report_type', StoredReport::REPORT_TYPE_PERMISSION_POSTURE)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$payload = is_array($report?->payload) ? $report->payload : [];
|
||||
$requiredCount = (int) ($payload['required_count'] ?? 0);
|
||||
$grantedCount = (int) ($payload['granted_count'] ?? 0);
|
||||
|
||||
$state = match (true) {
|
||||
! $report instanceof StoredReport => EvidenceCompletenessState::Missing->value,
|
||||
$requiredCount > 0 && $grantedCount < $requiredCount => EvidenceCompletenessState::Partial->value,
|
||||
default => EvidenceCompletenessState::Complete->value,
|
||||
};
|
||||
|
||||
return [
|
||||
'dimension_key' => $this->key(),
|
||||
'state' => $state,
|
||||
'required' => true,
|
||||
'source_kind' => 'stored_report',
|
||||
'source_record_type' => $report instanceof StoredReport ? StoredReport::class : StoredReport::class,
|
||||
'source_record_id' => $report instanceof StoredReport ? (string) $report->getKey() : null,
|
||||
'source_fingerprint' => $report?->fingerprint,
|
||||
'measured_at' => $report?->updated_at,
|
||||
'freshness_at' => $report?->updated_at,
|
||||
'summary_payload' => [
|
||||
'posture_score' => $payload['posture_score'] ?? null,
|
||||
'required_count' => $requiredCount,
|
||||
'granted_count' => $grantedCount,
|
||||
'payload' => $payload,
|
||||
],
|
||||
'fingerprint_payload' => [
|
||||
'fingerprint' => $report?->fingerprint,
|
||||
'required_count' => $requiredCount,
|
||||
'granted_count' => $grantedCount,
|
||||
],
|
||||
'sort_order' => 20,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,15 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
@ -19,8 +21,20 @@ class ReviewPackService
|
||||
{
|
||||
public function __construct(
|
||||
private OperationRunService $operationRunService,
|
||||
private EvidenceSnapshotResolver $snapshotResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const REQUIRED_EVIDENCE_DIMENSIONS = [
|
||||
'findings_summary',
|
||||
'permission_posture',
|
||||
'entra_admin_roles',
|
||||
'baseline_drift_posture',
|
||||
'operations_summary',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create an OperationRun + ReviewPack and dispatch the generation job.
|
||||
*
|
||||
@ -29,7 +43,8 @@ public function __construct(
|
||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||
{
|
||||
$options = $this->normalizeOptions($options);
|
||||
$fingerprint = $this->computeFingerprint($tenant, $options);
|
||||
$snapshot = $this->resolveSnapshot($tenant);
|
||||
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
||||
|
||||
$existing = $this->findExistingPack($tenant, $fingerprint);
|
||||
if ($existing instanceof ReviewPack) {
|
||||
@ -42,6 +57,7 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
inputs: [
|
||||
'include_pii' => $options['include_pii'],
|
||||
'include_operations' => $options['include_operations'],
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -50,10 +66,19 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [],
|
||||
'summary' => [
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'completeness_state' => (string) $snapshot->completeness_state,
|
||||
'required_dimensions' => self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->operationRunService->dispatchOrFail($operationRun, function () use ($reviewPack, $operationRun): void {
|
||||
@ -73,31 +98,7 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
*/
|
||||
public function computeFingerprint(Tenant $tenant, array $options): string
|
||||
{
|
||||
$reportFingerprints = StoredReport::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('report_type', [
|
||||
StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
])
|
||||
->orderBy('report_type')
|
||||
->pluck('fingerprint')
|
||||
->toArray();
|
||||
|
||||
$maxFindingDate = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->whereIn('status', Finding::openStatusesForQuery())
|
||||
->max('updated_at');
|
||||
|
||||
$data = [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
'report_fingerprints' => $reportFingerprints,
|
||||
'max_finding_date' => $maxFindingDate,
|
||||
'rbac_last_checked_at' => $tenant->rbac_last_checked_at?->toIso8601String(),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
return $this->computeFingerprintForSnapshot($this->resolveSnapshot($tenant), $this->normalizeOptions($options));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -150,4 +151,32 @@ private function normalizeOptions(array $options): array
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? config('tenantpilot.review_pack.include_operations_default', true)),
|
||||
];
|
||||
}
|
||||
|
||||
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
||||
{
|
||||
$data = [
|
||||
'tenant_id' => (int) $snapshot->tenant_id,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'evidence_fingerprint' => (string) $snapshot->fingerprint,
|
||||
'include_pii' => (bool) ($options['include_pii'] ?? true),
|
||||
'include_operations' => (bool) ($options['include_operations'] ?? true),
|
||||
];
|
||||
|
||||
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function resolveSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
$result = $this->snapshotResolver->resolve(new EvidenceResolutionRequest(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
requiredDimensions: self::REQUIRED_EVIDENCE_DIMENSIONS,
|
||||
));
|
||||
|
||||
if (! $result->isResolved()) {
|
||||
throw new ReviewPackEvidenceResolutionException($result);
|
||||
}
|
||||
|
||||
return $result->snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +80,10 @@ enum AuditActionId: string
|
||||
case FindingRiskAccepted = 'finding.risk_accepted';
|
||||
case FindingReopened = 'finding.reopened';
|
||||
|
||||
case EvidenceSnapshotCreated = 'evidence_snapshot.created';
|
||||
case EvidenceSnapshotRefreshed = 'evidence_snapshot.refreshed';
|
||||
case EvidenceSnapshotExpired = 'evidence_snapshot.expired';
|
||||
|
||||
// Workspace selection / switch events (Spec 107).
|
||||
case WorkspaceAutoSelected = 'workspace.auto_selected';
|
||||
case WorkspaceSelected = 'workspace.selected';
|
||||
@ -197,6 +201,9 @@ private static function labels(): array
|
||||
self::FindingClosed->value => 'Finding closed',
|
||||
self::FindingRiskAccepted->value => 'Finding risk accepted',
|
||||
self::FindingReopened->value => 'Finding reopened',
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
'baseline.capture.started' => 'Baseline capture started',
|
||||
'baseline.capture.completed' => 'Baseline capture completed',
|
||||
'baseline.capture.failed' => 'Baseline capture failed',
|
||||
@ -262,6 +269,9 @@ private static function summaries(): array
|
||||
self::FindingClosed->value => 'Finding closed',
|
||||
self::FindingRiskAccepted->value => 'Finding risk accepted',
|
||||
self::FindingReopened->value => 'Finding reopened',
|
||||
self::EvidenceSnapshotCreated->value => 'Evidence snapshot created',
|
||||
self::EvidenceSnapshotRefreshed->value => 'Evidence snapshot refreshed',
|
||||
self::EvidenceSnapshotExpired->value => 'Evidence snapshot expired',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -132,6 +132,11 @@ class Capabilities
|
||||
|
||||
public const REVIEW_PACK_MANAGE = 'review_pack.manage';
|
||||
|
||||
// Evidence snapshots
|
||||
public const EVIDENCE_VIEW = 'evidence.view';
|
||||
|
||||
public const EVIDENCE_MANAGE = 'evidence.manage';
|
||||
|
||||
/**
|
||||
* Get all capability constants
|
||||
*
|
||||
|
||||
@ -47,6 +47,8 @@ final class BadgeCatalog
|
||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
|
||||
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
|
||||
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
|
||||
|
||||
@ -38,6 +38,8 @@ enum BadgeDomain: string
|
||||
case BaselineProfileStatus = 'baseline_profile_status';
|
||||
case FindingType = 'finding_type';
|
||||
case ReviewPackStatus = 'review_pack_status';
|
||||
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
||||
case EvidenceCompleteness = 'evidence_completeness';
|
||||
case SystemHealth = 'system_health';
|
||||
case ReferenceResolutionState = 'reference_resolution_state';
|
||||
case DiffRowStatus = 'diff_row_status';
|
||||
|
||||
26
app/Support/Badges/Domains/EvidenceCompletenessBadge.php
Normal file
26
app/Support/Badges/Domains/EvidenceCompletenessBadge.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class EvidenceCompletenessBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
EvidenceCompletenessState::Complete->value => new BadgeSpec('Complete', 'success', 'heroicon-m-check-badge'),
|
||||
EvidenceCompletenessState::Partial->value => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
EvidenceCompletenessState::Missing->value => new BadgeSpec('Missing', 'danger', 'heroicon-m-x-circle'),
|
||||
EvidenceCompletenessState::Stale->value => new BadgeSpec('Stale', 'gray', 'heroicon-m-clock'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
28
app/Support/Badges/Domains/EvidenceSnapshotStatusBadge.php
Normal file
28
app/Support/Badges/Domains/EvidenceSnapshotStatusBadge.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
|
||||
final class EvidenceSnapshotStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
EvidenceSnapshotStatus::Queued->value => new BadgeSpec('Queued', 'warning', 'heroicon-m-clock'),
|
||||
EvidenceSnapshotStatus::Generating->value => new BadgeSpec('Generating', 'info', 'heroicon-m-arrow-path'),
|
||||
EvidenceSnapshotStatus::Active->value => new BadgeSpec('Active', 'success', 'heroicon-m-check-circle'),
|
||||
EvidenceSnapshotStatus::Superseded->value => new BadgeSpec('Superseded', 'gray', 'heroicon-m-arrow-uturn-left'),
|
||||
EvidenceSnapshotStatus::Expired->value => new BadgeSpec('Expired', 'gray', 'heroicon-m-archive-box'),
|
||||
EvidenceSnapshotStatus::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Support/Evidence/EvidenceCompletenessState.php
Normal file
21
app/Support/Evidence/EvidenceCompletenessState.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Evidence;
|
||||
|
||||
enum EvidenceCompletenessState: string
|
||||
{
|
||||
case Complete = 'complete';
|
||||
case Partial = 'partial';
|
||||
case Missing = 'missing';
|
||||
case Stale = 'stale';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
23
app/Support/Evidence/EvidenceSnapshotStatus.php
Normal file
23
app/Support/Evidence/EvidenceSnapshotStatus.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Evidence;
|
||||
|
||||
enum EvidenceSnapshotStatus: string
|
||||
{
|
||||
case Queued = 'queued';
|
||||
case Generating = 'generating';
|
||||
case Active = 'active';
|
||||
case Superseded = 'superseded';
|
||||
case Expired = 'expired';
|
||||
case Failed = 'failed';
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@ -50,7 +50,7 @@ public function handle(Request $request, Closure $next): Response
|
||||
Filament::setTenant(null, true);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
if ($this->isLivewireUpdatePath($path)) {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
@ -273,6 +273,11 @@ private function isWorkspaceScopedPageWithTenant(string $path): bool
|
||||
return preg_match('#^/admin/tenants/[^/]+/required-permissions$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isLivewireUpdatePath(string $path): bool
|
||||
{
|
||||
return preg_match('#^/livewire(?:-[^/]+)?/update$#', $path) === 1;
|
||||
}
|
||||
|
||||
private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
|
||||
{
|
||||
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
|
||||
|
||||
@ -53,6 +53,7 @@ public static function labels(): array
|
||||
'permission_posture_check' => 'Permission posture check',
|
||||
'entra.admin_roles.scan' => 'Entra admin roles scan',
|
||||
'tenant.review_pack.generate' => 'Review pack generation',
|
||||
'tenant.evidence.snapshot.generate' => 'Evidence snapshot generation',
|
||||
'rbac.health_check' => 'RBAC health check',
|
||||
'findings.lifecycle.backfill' => 'Findings lifecycle backfill',
|
||||
];
|
||||
@ -88,6 +89,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'permission_posture_check' => 30,
|
||||
'entra.admin_roles.scan' => 60,
|
||||
'tenant.review_pack.generate' => 60,
|
||||
'tenant.evidence.snapshot.generate' => 120,
|
||||
'rbac.health_check' => 30,
|
||||
'findings.lifecycle.backfill' => 300,
|
||||
default => null,
|
||||
|
||||
@ -19,6 +19,7 @@ enum OperationRunType: string
|
||||
case RestoreExecute = 'restore.execute';
|
||||
case EntraAdminRolesScan = 'entra.admin_roles.scan';
|
||||
case ReviewPackGenerate = 'tenant.review_pack.generate';
|
||||
case EvidenceSnapshotGenerate = 'tenant.evidence.snapshot.generate';
|
||||
case RbacHealthCheck = 'rbac.health_check';
|
||||
|
||||
public static function values(): array
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
@ -15,6 +16,7 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Policy;
|
||||
@ -98,6 +100,16 @@ public static function firstSlice(): array
|
||||
'action_surface_reason' => 'FindingResource declares its action surface contract directly.',
|
||||
'notes' => 'Findings are not part of global search in the first slice.',
|
||||
],
|
||||
'EvidenceSnapshot' => [
|
||||
'table' => 'evidence_snapshots',
|
||||
'model' => EvidenceSnapshot::class,
|
||||
'resource' => EvidenceSnapshotResource::class,
|
||||
'tenant_relationship' => 'tenant',
|
||||
'search_posture' => 'disabled',
|
||||
'action_surface' => 'declared',
|
||||
'action_surface_reason' => 'EvidenceSnapshotResource declares its action surface contract directly.',
|
||||
'notes' => 'Evidence snapshots stay off global search until broader evidence discovery is introduced.',
|
||||
],
|
||||
'InventoryItem' => [
|
||||
'table' => 'inventory_items',
|
||||
'model' => InventoryItem::class,
|
||||
|
||||
@ -31,6 +31,7 @@ public static function firstSlice(): array
|
||||
'backup_sets',
|
||||
'restore_runs',
|
||||
'findings',
|
||||
'evidence_snapshots',
|
||||
'inventory_items',
|
||||
'entra_groups',
|
||||
];
|
||||
|
||||
@ -41,6 +41,7 @@ public function definition(): array
|
||||
return (int) $tenant->workspace_id;
|
||||
},
|
||||
'initiated_by_user_id' => User::factory(),
|
||||
'evidence_snapshot_id' => null,
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => fake()->sha256(),
|
||||
'previous_fingerprint' => null,
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('evidence_snapshots', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('fingerprint', 64)->nullable();
|
||||
$table->string('previous_fingerprint', 64)->nullable();
|
||||
$table->string('status')->default('queued');
|
||||
$table->string('completeness_state')->default('missing');
|
||||
$table->jsonb('summary')->default('{}');
|
||||
$table->timestampTz('generated_at')->nullable();
|
||||
$table->timestampTz('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['workspace_id', 'tenant_id', 'created_at']);
|
||||
$table->index(['tenant_id', 'status', 'generated_at']);
|
||||
$table->index(['status', 'expires_at']);
|
||||
});
|
||||
|
||||
DB::statement("CREATE UNIQUE INDEX evidence_snapshots_active_unique ON evidence_snapshots (workspace_id, tenant_id) WHERE status = 'active'");
|
||||
DB::statement("CREATE UNIQUE INDEX evidence_snapshots_fingerprint_unique ON evidence_snapshots (workspace_id, tenant_id, fingerprint) WHERE fingerprint IS NOT NULL AND status NOT IN ('expired', 'failed')");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('evidence_snapshots');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('evidence_snapshot_items', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('evidence_snapshot_id')->constrained('evidence_snapshots')->cascadeOnDelete();
|
||||
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->string('dimension_key');
|
||||
$table->string('state')->default('missing');
|
||||
$table->boolean('required')->default(true);
|
||||
$table->string('source_kind');
|
||||
$table->string('source_record_type')->nullable();
|
||||
$table->string('source_record_id')->nullable();
|
||||
$table->string('source_fingerprint', 64)->nullable();
|
||||
$table->timestampTz('measured_at')->nullable();
|
||||
$table->timestampTz('freshness_at')->nullable();
|
||||
$table->jsonb('summary_payload')->default('{}');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['evidence_snapshot_id', 'dimension_key']);
|
||||
$table->index(['workspace_id', 'tenant_id', 'dimension_key']);
|
||||
$table->index(['tenant_id', 'state']);
|
||||
});
|
||||
|
||||
if (DB::getDriverName() === 'pgsql') {
|
||||
DB::statement('CREATE INDEX evidence_snapshot_items_payload_gin ON evidence_snapshot_items USING GIN (summary_payload)');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('evidence_snapshot_items');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('review_packs', function (Blueprint $table): void {
|
||||
$table->foreignId('evidence_snapshot_id')
|
||||
->nullable()
|
||||
->after('operation_run_id')
|
||||
->constrained('evidence_snapshots')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['tenant_id', 'evidence_snapshot_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('review_packs', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('evidence_snapshot_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -304,6 +304,35 @@ ### Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit
|
||||
- Concrete desired outcome without overdesigning the solution
|
||||
- Easy to promote into a full spec once operations-domain work is prioritized
|
||||
|
||||
### Operator Presentation & Lifecycle Action Hardening
|
||||
- **Type**: hardening
|
||||
- **Source**: Evidence Snapshot / Ops-UX review 2026-03-19
|
||||
- **Problem**: TenantPilot has strong shared presentation abstractions — `OperationCatalog` for operation labels, `BadgeRenderer` / `BadgeCatalog` for status/outcome badges, and some lifecycle-aware action gating patterns in selected resources — but these conventions are not consistently enforced across all operator-facing surfaces. Individual surfaces can bypass the shared sources of truth without triggering any architectural or CI feedback. This produces a repeatable class of operator-UX degradation:
|
||||
- **Operation label bypass**: surfaces that render operation names directly from internal type keys instead of going through the shared operation catalog, leaking technical identifiers like `inventory_sync` or `compliance.snapshot` into operator-visible UI.
|
||||
- **Status/outcome presentation bypass**: surfaces that render raw enum values (e.g. `queued`, `running`, `pending`, `succeeded`) directly from model attributes instead of using `BadgeRenderer`, producing unstyled debug-quality output where operators expect consistent badge rendering.
|
||||
- **Missing lifecycle-aware action gating**: mutation and destructive actions (e.g. "Expire snapshot", "Refresh snapshot") that remain visible and invocable on records in terminal lifecycle states (Expired, Failed), because no shared convention requires actions to derive visibility from valid lifecycle transitions. Backend idempotency guards prevent data corruption but do not prevent operator confusion.
|
||||
- **Unscoped global widget polling**: global widgets (e.g. `BulkOperationProgress`) that poll on every page including non-operational pages where no active runs are expected, creating unnecessary network noise and giving operators the impression that background activity is occurring when none is relevant.
|
||||
- **Why it matters**: In enterprise SaaS, operator trust depends on consistent, predictable UI behavior across every surface. A single widget rendering raw `queued` instead of a styled badge, or a single page showing an "Expire" action on an already-expired record, undermines confidence in the product's governance capabilities. These are not cosmetic issues — they are operator-trust issues that compound as the product adds more lifecycle-driven surfaces (Findings, Review Packs, Baselines, Exceptions, Alerts, Drift governance). Without shared enforceable conventions, every new surface risks re-introducing the same failure modes.
|
||||
- **Proposed direction**:
|
||||
- **Operation label convention**: codify the rule that all operator-visible operation names must resolve through `OperationCatalog::label()` (or the equivalent shared source of truth). Add a lightweight enforcement mechanism (CI check, architectural test, or documented anti-pattern) that catches direct usage of raw operation type strings in Blade templates and widget renders.
|
||||
- **Status/outcome badge convention**: codify the rule that all operator-visible status and outcome rendering must go through `BadgeRenderer` (or equivalent shared badge helpers). Enumerate the known surfaces that currently comply and identify any that bypass the convention. Add a regression mechanism to prevent new surfaces from bypassing.
|
||||
- **Lifecycle-aware action visibility convention**: define a shared contract or trait that mutation/destructive actions must consult to determine visibility based on the record's current lifecycle state. Terminal-state records must not expose invalid lifecycle transitions as available actions. Suggest introducing `isTerminal(): bool` (or equivalent) on lifecycle enums (`EvidenceSnapshotStatus`, `ReviewPackStatus`, `OperationRunStatus`, etc.) so action visibility can be derived from lifecycle semantics rather than ad hoc per-resource `->hidden()` conditions.
|
||||
- **Polling ownership convention**: codify the rule that global widgets must declare their polling scope — which pages or contexts justify active polling vs. idle/suppressed behavior. Ensure idle discovery polling intervals are intentional and documented, and that non-operational pages are not subjected to unnecessary polling overhead.
|
||||
- **Scope boundaries**:
|
||||
- **In scope**: shared convention definitions, enforcement mechanisms, anti-pattern catalog, lifecycle enum enrichment (`isTerminal()` or equivalent), regression coverage for badge/label/action consistency
|
||||
- **Out of scope**: local Evidence Snapshot fixes (those belong in the active Evidence-related spec), operations naming vocabulary redesign (tracked separately as "Operations Naming Harmonization"), visual language canon / design-system codification (tracked separately as "Admin Visual Language Canon"), BulkOperationProgress architectural redesign, new badge domains or new operation types
|
||||
- **Examples of failure modes this should prevent**:
|
||||
- A widget rendering `{{ $run->status }}` directly instead of using `BadgeRenderer::render(BadgeDomain::OperationRunStatus, $run->status)`
|
||||
- A card showing raw `outcome: pending` text instead of a styled outcome badge
|
||||
- An "Expire snapshot" action visible on a record with status `Expired`
|
||||
- A "Refresh snapshot" action visible on a record with status `Failed`
|
||||
- A global progress widget polling every 30 seconds on the Evidence detail page where no active operation runs are relevant
|
||||
- A new governance surface (e.g. Baseline review, Alert detail) shipping without badge rendering because no convention required it
|
||||
- **Why this is a follow-up candidate, not part of current local fixes**: The active Evidence-related spec should fix the specific Evidence Snapshot bugs (raw status in `RecentOperationsSummary`, missing `->hidden()` on expire/refresh actions). This candidate addresses the **shared convention layer** that prevents the same class of bugs from recurring on every future lifecycle-driven surface. The local fixes prove the bugs exist; this candidate prevents their recurrence.
|
||||
- **Dependencies**: BadgeRenderer / BadgeCatalog system (already stable), OperationCatalog (already stable), lifecycle enums (already defined, need `isTerminal()` enrichment), RBAC/capability system (066+) for action gating patterns
|
||||
- **Related candidates**: Operations Naming Harmonization (naming vocabulary — complementary but distinct), Admin Visual Language Canon (visual conventions — broader scope), Action Surface Contract v1.1 (interaction-level action rules — complementary)
|
||||
- **Priority**: medium
|
||||
|
||||
### Provider Connection Resolution Normalization
|
||||
- **Type**: hardening
|
||||
- **Source**: architecture audit – provider connection resolution analysis
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
@php
|
||||
$state = $getState();
|
||||
$state = is_array($state) ? $state : [];
|
||||
|
||||
$summary = is_string($state['summary'] ?? null) ? $state['summary'] : null;
|
||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||
$items = is_array($state['items'] ?? null) ? $state['items'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
@if ($summary)
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ $summary }}</div>
|
||||
@endif
|
||||
|
||||
@if ($highlights !== [])
|
||||
<dl class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($highlights as $highlight)
|
||||
@php
|
||||
$label = is_string($highlight['label'] ?? null) ? $highlight['label'] : null;
|
||||
$value = is_string($highlight['value'] ?? null) ? $highlight['value'] : null;
|
||||
@endphp
|
||||
|
||||
@continue($label === null || $value === null)
|
||||
|
||||
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $value }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
|
||||
@if ($items !== [])
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent items</div>
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
@foreach ($items as $item)
|
||||
@continue(! is_string($item) || trim($item) === '')
|
||||
|
||||
<li class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,44 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
@if ($rows === [])
|
||||
<div class="rounded-xl border border-dashed border-gray-300 bg-white p-8 text-center shadow-sm">
|
||||
<h2 class="text-lg font-semibold text-gray-950">No evidence snapshots in this scope</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">Adjust filters or create a tenant snapshot to populate the workspace overview.</p>
|
||||
<div class="mt-4">
|
||||
<a href="{{ route('admin.evidence.overview') }}" class="inline-flex items-center rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white">
|
||||
Clear filters
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50 text-left text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Tenant</th>
|
||||
<th class="px-4 py-3 font-medium">Completeness</th>
|
||||
<th class="px-4 py-3 font-medium">Generated</th>
|
||||
<th class="px-4 py-3 font-medium">Missing</th>
|
||||
<th class="px-4 py-3 font-medium">Stale</th>
|
||||
<th class="px-4 py-3 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white text-gray-900">
|
||||
@foreach ($rows as $row)
|
||||
<tr>
|
||||
<td class="px-4 py-3">{{ $row['tenant_name'] }}</td>
|
||||
<td class="px-4 py-3">{{ ucfirst(str_replace('_', ' ', $row['completeness_state'])) }}</td>
|
||||
<td class="px-4 py-3">{{ $row['generated_at'] ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ $row['missing_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">{{ $row['stale_dimensions'] }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="{{ $row['view_url'] }}" class="text-primary-600 hover:text-primary-500">View tenant evidence</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -15,6 +15,7 @@
|
||||
<div
|
||||
class="space-y-2"
|
||||
x-data="{
|
||||
open: false,
|
||||
text: @js($rawJson),
|
||||
copied: false,
|
||||
async copyJson() {
|
||||
@ -44,6 +45,7 @@ class="space-y-2"
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span
|
||||
x-show="copied"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
@ -53,9 +55,13 @@ class="text-sm text-gray-500 dark:text-gray-400"
|
||||
<x-filament::button size="xs" color="gray" x-on:click="copyJson()">
|
||||
Copy JSON
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button size="xs" color="gray" x-on:click="open = !open">
|
||||
<span x-text="open ? 'Hide JSON' : 'Show JSON'"></span>
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div x-show="open" x-cloak x-collapse class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<pre class="max-h-96 overflow-auto whitespace-pre font-mono text-xs text-gray-900 dark:text-gray-100" x-text="text"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
x-data="opsUxProgressWidgetPoller()"
|
||||
x-init="init()"
|
||||
wire:key="ops-ux-progress-widget"
|
||||
@if($runs->isNotEmpty()) wire:poll.5s="refreshRuns" @endif
|
||||
>
|
||||
@if($runs->isNotEmpty())
|
||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Http\Controllers\SelectTenantController;
|
||||
use App\Http\Controllers\SwitchWorkspaceController;
|
||||
use App\Http\Controllers\TenantOnboardingController;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantOnboardingSession;
|
||||
@ -102,10 +101,6 @@
|
||||
return app(OnboardingDraftResolver::class)->resolve((int) $value, $user, $workspace);
|
||||
});
|
||||
|
||||
Route::bind('run', function (string $value): OperationRun {
|
||||
return OperationRun::query()->whereKey((int) $value)->firstOrFail();
|
||||
});
|
||||
|
||||
$authorizeManagedTenantRoute = function (Tenant $tenant, Request $request): void {
|
||||
$user = $request->user();
|
||||
|
||||
@ -176,6 +171,18 @@
|
||||
->get('/admin/operations', \App\Filament\Pages\Monitoring\Operations::class)
|
||||
->name('admin.operations.index');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
'ensure-correct-guard:web',
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
FilamentAuthenticate::class,
|
||||
'ensure-workspace-selected',
|
||||
])
|
||||
->get('/admin/evidence/overview', \App\Filament\Pages\Monitoring\EvidenceOverview::class)
|
||||
->name('admin.evidence.overview');
|
||||
|
||||
Route::middleware([
|
||||
'web',
|
||||
'panel:admin',
|
||||
@ -237,7 +244,6 @@
|
||||
'ensure-workspace-selected',
|
||||
])
|
||||
->get('/admin/operations/{run}', \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class)
|
||||
->can('view', 'run')
|
||||
->name('admin.operations.view');
|
||||
|
||||
Route::middleware([
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
# Specification Quality Checklist: Evidence Domain Foundation
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-19
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/153-evidence-domain-foundation/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validated against the repository spec template and current candidate boundaries.
|
||||
- No clarification questions were required for this spec.
|
||||
- The spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,274 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Evidence Domain Foundation
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Planning contract for the tenant evidence snapshot domain. These routes describe
|
||||
the expected HTTP-level behavior behind Filament surfaces and downstream consumers.
|
||||
servers:
|
||||
- url: http://localhost
|
||||
paths:
|
||||
/admin/t/{tenant}/evidence:
|
||||
get:
|
||||
operationId: listEvidenceSnapshots
|
||||
summary: List evidence snapshots for a tenant
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceSnapshot'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
post:
|
||||
operationId: createEvidenceSnapshot
|
||||
summary: Queue evidence snapshot creation for a tenant
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateSnapshotRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Snapshot generation accepted or existing matching snapshot reused
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SnapshotGenerationResponse'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/admin/t/{tenant}/evidence/{snapshot}:
|
||||
get:
|
||||
operationId: viewEvidenceSnapshot
|
||||
summary: View one evidence snapshot and its dimension items
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EvidenceSnapshotDetail'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/admin/t/{tenant}/evidence/{snapshot}/refresh:
|
||||
post:
|
||||
operationId: refreshEvidenceSnapshot
|
||||
summary: Queue creation of a new snapshot from current evidence state
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'202':
|
||||
description: Refresh accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SnapshotGenerationResponse'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/admin/t/{tenant}/evidence/{snapshot}/expire:
|
||||
post:
|
||||
operationId: expireEvidenceSnapshot
|
||||
summary: Expire a snapshot without mutating its captured content
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/SnapshotId'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot expired
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EvidenceSnapshot'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/admin/evidence/overview:
|
||||
get:
|
||||
operationId: evidenceOverview
|
||||
summary: List workspace-scoped evidence completeness across authorized tenants
|
||||
parameters:
|
||||
- name: tenant_id
|
||||
in: query
|
||||
required: false
|
||||
description: Optional entitled-tenant prefilter carried from tenant context into the canonical overview.
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Workspace evidence overview
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceOverviewRow'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
SnapshotId:
|
||||
name: snapshot
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
responses:
|
||||
Forbidden:
|
||||
description: In-scope member lacks the required capability
|
||||
NotFound:
|
||||
description: Workspace or tenant not entitled, or snapshot is outside scope
|
||||
|
||||
schemas:
|
||||
CreateSnapshotRequest:
|
||||
type: object
|
||||
properties:
|
||||
required_dimensions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
allow_stale:
|
||||
type: boolean
|
||||
default: false
|
||||
SnapshotGenerationResponse:
|
||||
type: object
|
||||
properties:
|
||||
snapshot_id:
|
||||
type: integer
|
||||
operation_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
reused:
|
||||
type: boolean
|
||||
status:
|
||||
type: string
|
||||
enum: [queued, generating, active]
|
||||
EvidenceSnapshot:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
tenant_id:
|
||||
type: integer
|
||||
status:
|
||||
type: string
|
||||
enum: [queued, generating, active, superseded, expired, failed]
|
||||
completeness_state:
|
||||
type: string
|
||||
enum: [complete, partial, missing, stale]
|
||||
fingerprint:
|
||||
type: string
|
||||
nullable: true
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
summary:
|
||||
type: object
|
||||
EvidenceSnapshotDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EvidenceSnapshot'
|
||||
- type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceSnapshotItem'
|
||||
EvidenceSnapshotItem:
|
||||
type: object
|
||||
properties:
|
||||
dimension_key:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
enum: [complete, partial, missing, stale]
|
||||
required:
|
||||
type: boolean
|
||||
source_kind:
|
||||
type: string
|
||||
source_record_type:
|
||||
type: string
|
||||
nullable: true
|
||||
source_record_id:
|
||||
type: string
|
||||
nullable: true
|
||||
source_fingerprint:
|
||||
type: string
|
||||
nullable: true
|
||||
measured_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
freshness_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
summary_payload:
|
||||
type: object
|
||||
EvidenceOverviewRow:
|
||||
type: object
|
||||
properties:
|
||||
tenant_id:
|
||||
type: integer
|
||||
latest_snapshot_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
snapshot_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
completeness_state:
|
||||
type: string
|
||||
enum: [complete, partial, missing, stale]
|
||||
nullable: true
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
missing_dimensions:
|
||||
type: integer
|
||||
stale_dimensions:
|
||||
type: integer
|
||||
113
specs/153-evidence-domain-foundation/data-model.md
Normal file
113
specs/153-evidence-domain-foundation/data-model.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Data Model: Evidence Domain Foundation
|
||||
|
||||
## 1. EvidenceSnapshot
|
||||
|
||||
- **Purpose**: Immutable, tenant-scoped evidence package captured at a specific point in time and reused by downstream consumers.
|
||||
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` NOT NULL)
|
||||
- **Fields**:
|
||||
- `id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `operation_run_id` nullable
|
||||
- `initiated_by_user_id` nullable
|
||||
- `fingerprint` nullable, 64-char SHA-256
|
||||
- `previous_fingerprint` nullable
|
||||
- `status` enum: `queued`, `generating`, `active`, `superseded`, `expired`, `failed`
|
||||
- `completeness_state` enum: `complete`, `partial`, `missing`, `stale`
|
||||
- `summary` JSONB
|
||||
- `generated_at` timestampTz nullable
|
||||
- `expires_at` timestampTz nullable
|
||||
- `created_at`, `updated_at`
|
||||
- **Relationships**:
|
||||
- belongs to `Workspace`
|
||||
- belongs to `Tenant`
|
||||
- belongs to `OperationRun`
|
||||
- belongs to initiator `User`
|
||||
- has many `EvidenceSnapshotItem`
|
||||
- **Validation / invariants**:
|
||||
- `workspace_id` and `tenant_id` required on every row
|
||||
- successful snapshots are immutable in content after `status` becomes `active`
|
||||
- one active snapshot per tenant evidence scope in v1
|
||||
- identical fingerprint for the same tenant scope reuses the existing non-expired snapshot
|
||||
|
||||
## 2. EvidenceSnapshotItem
|
||||
|
||||
- **Purpose**: One curated evidence dimension inside an evidence snapshot.
|
||||
- **Ownership**: Tenant-owned (`workspace_id` + `tenant_id` NOT NULL)
|
||||
- **Fields**:
|
||||
- `id`
|
||||
- `evidence_snapshot_id`
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `dimension_key` string
|
||||
- `state` enum: `complete`, `partial`, `missing`, `stale`
|
||||
- `required` boolean
|
||||
- `source_kind` string
|
||||
- `source_record_type` nullable string
|
||||
- `source_record_id` nullable string
|
||||
- `source_fingerprint` nullable string
|
||||
- `measured_at` timestampTz nullable
|
||||
- `freshness_at` timestampTz nullable
|
||||
- `summary_payload` JSONB
|
||||
- `sort_order` integer default 0
|
||||
- `created_at`, `updated_at`
|
||||
- **Relationships**:
|
||||
- belongs to `EvidenceSnapshot`
|
||||
- **Validation / invariants**:
|
||||
- unique per snapshot: `(evidence_snapshot_id, dimension_key)`
|
||||
- `workspace_id`/`tenant_id` must match the parent snapshot
|
||||
- `summary_payload` stores curated summary/reference data only; no secrets, tokens, or raw Graph dumps
|
||||
|
||||
## 3. Evidence dimensions in first rollout
|
||||
|
||||
- `findings_summary`
|
||||
- `permission_posture`
|
||||
- `entra_admin_roles`
|
||||
- `baseline_drift_posture`
|
||||
- `operations_summary`
|
||||
|
||||
Each dimension stores:
|
||||
- inclusion state (`complete|partial|missing|stale`)
|
||||
- source provenance (record type, identifier, fingerprint)
|
||||
- freshness timestamp
|
||||
- dimension-specific summary payload
|
||||
|
||||
## 4. Derived completeness rules
|
||||
|
||||
- **Snapshot overall completeness precedence**:
|
||||
- `missing` if any required dimension is missing
|
||||
- `stale` if no required dimensions are missing and at least one required dimension is stale
|
||||
- `partial` if no required dimensions are missing or stale and at least one required dimension is partial
|
||||
- `complete` only when all required dimensions are complete
|
||||
|
||||
## 5. Lifecycle transitions
|
||||
|
||||
### EvidenceSnapshot status
|
||||
|
||||
- `queued -> generating`
|
||||
- `generating -> active`
|
||||
- `generating -> failed`
|
||||
- `active -> superseded`
|
||||
- `active -> expired`
|
||||
- `superseded -> expired`
|
||||
|
||||
No transition may mutate snapshot items after `active` is reached.
|
||||
|
||||
## 6. Downstream resolution contract
|
||||
|
||||
### EvidenceResolutionRequest
|
||||
|
||||
- **Purpose**: Explicit request from a downstream consumer for a tenant evidence package.
|
||||
- **Fields**:
|
||||
- `workspace_id`
|
||||
- `tenant_id`
|
||||
- `snapshot_id` nullable explicit selection
|
||||
- `required_dimensions` array
|
||||
- `allow_stale` boolean default `false`
|
||||
|
||||
### EvidenceResolutionResult
|
||||
|
||||
- **Outcomes**:
|
||||
- `resolved` with snapshot id and eligible dimensions
|
||||
- `missing_snapshot`
|
||||
- `snapshot_ineligible` with missing/stale dimension reasons
|
||||
116
specs/153-evidence-domain-foundation/plan.md
Normal file
116
specs/153-evidence-domain-foundation/plan.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Implementation Plan: Evidence Domain Foundation
|
||||
|
||||
**Branch**: `153-evidence-domain-foundation` | **Date**: 2026-03-19 | **Spec**: [/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/153-evidence-domain-foundation/spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/153-evidence-domain-foundation/spec.md)
|
||||
**Input**: Feature specification from `/specs/153-evidence-domain-foundation/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce a tenant-scoped Evidence Snapshot domain that curates existing internal governance artifacts into immutable, reusable snapshots. Implementation uses PostgreSQL-backed snapshot and snapshot-item tables, a DB-only queued `OperationRun` for snapshot generation, tenant-context Filament list/detail surfaces, a workspace-scoped evidence overview, and an explicit downstream resolver contract so review-pack and future readiness/reporting features consume curated snapshots instead of rebuilding ad hoc evidence bundles.
|
||||
|
||||
Implemented routes and surfaces center on the tenant-context evidence resource at `/admin/t/{tenant}/evidence`, the snapshot detail route at `/admin/t/{tenant}/evidence/{snapshot}`, and the canonical workspace overview at `/admin/evidence/overview`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `StoredReport`, `Finding`, `OperationRun`, and `AuditLog` infrastructure
|
||||
**Storage**: PostgreSQL with JSONB-backed snapshot metadata; existing private storage remains a downstream-consumer concern, not a primary evidence-foundation store
|
||||
**Testing**: Pest feature tests, Pest unit tests, and Livewire/Filament component tests
|
||||
**Target Platform**: Laravel Sail web application on PostgreSQL
|
||||
**Project Type**: Web application monolith
|
||||
**Performance Goals**: Evidence snapshot generation completes in the background within 120 seconds for a medium tenant; tenant evidence pages remain DB-only at render time; dedupe and active-snapshot lookup are index-backed
|
||||
**Constraints**: No Microsoft Graph calls during snapshot generation; successful snapshots are immutable; all tenant/workspace authorization is server-side; `OperationRun.status` and `OperationRun.outcome` remain service-owned; status-like UI uses centralized badge semantics
|
||||
**Scale/Scope**: First rollout supports five evidence dimensions, one active snapshot per tenant scope, explicit downstream reuse by review-pack and future readiness consumers, and tenant/workspace evidence surfaces only
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Pre-Phase-0 Gate: PASS**
|
||||
- Inventory-first: PASS. Snapshot generation curates existing last-observed artifacts (`StoredReport`, `Finding`, baseline/drift summaries, recent `OperationRun` summaries) and does not recollect live state.
|
||||
- Read/write separation: PASS. The only write is internal snapshot materialization; it is explicit, queued, auditable, and confirmation-gated for expiration. No remote tenant mutation is introduced.
|
||||
- Graph contract path: PASS. No new Graph calls are added; snapshot generation is DB-only.
|
||||
- Deterministic capabilities: PASS. New `evidence.view` and `evidence.manage` capabilities are added to the canonical registry and tested through existing RBAC patterns.
|
||||
- RBAC-UX / workspace / tenant isolation: PASS. Tenant evidence detail stays tenant-scoped, workspace overview is explicit and aggregate-only, non-members are 404, in-scope capability denials are 403.
|
||||
- Global search: PASS. No global-search expansion is required in the first slice.
|
||||
- Run observability: PASS. Snapshot generation uses a dedicated `OperationRun` type and existing `OperationRunService` patterns.
|
||||
- Ops-UX 3-surface feedback: PASS. Queue intent uses the standard presenter, live progress remains limited to the active-ops widget and Monitoring run detail, and snapshot detail links to the canonical run detail without adding a fourth progress surface.
|
||||
- Ops-UX lifecycle and summary counts: PASS. Status/outcome changes remain service-owned; summary counts use existing canonical keys (`created`, `report_count`, `finding_count`, `operation_count`, `errors_recorded`).
|
||||
- Data minimization: PASS. Snapshot items store curated references and summary payloads, not raw Graph payloads or secrets.
|
||||
- BADGE-001: PASS. New snapshot-status and completeness values will be added via centralized badge semantics with tests.
|
||||
- UI-NAMING-001: PASS. Operator-facing vocabulary remains `Create snapshot`, `Refresh evidence`, `Expire snapshot`, `View snapshot`.
|
||||
- Filament Action Surface Contract: PASS. Tenant evidence list uses clickable rows, explicit header action, confirmed destructive action, and no lone View button.
|
||||
- UX-001: PASS with explicit exemption. Snapshot creation is a modal operation trigger rather than a CRUD create page; detail view uses Infolists.
|
||||
|
||||
**Post-Phase-1 Re-check: PASS**
|
||||
- The proposed design preserves workspace/tenant ownership boundaries, keeps successful snapshots immutable, uses DB-only generation, and routes all lifecycle transitions through existing run/audit infrastructure without introducing constitution violations.
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Monitoring/
|
||||
│ ├── Resources/
|
||||
│ └── Widgets/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Services/
|
||||
│ ├── Audit/
|
||||
│ └── Evidence/
|
||||
└── Support/
|
||||
├── Auth/
|
||||
├── Badges/
|
||||
└── OpsUx/
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ └── Evidence/
|
||||
└── Unit/
|
||||
└── Evidence/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the existing Laravel monolith structure. Add the new domain under `app/Models`, `app/Services/Evidence`, `app/Jobs`, and tenant/admin Filament surfaces under `app/Filament`. Persist schema in `database/migrations` and cover behavior with focused Pest feature/unit tests under `tests/Feature/Evidence` and `tests/Unit/Evidence`.
|
||||
|
||||
Focused verification now includes the downstream review-pack integration and the shared authorization/action-surface regressions in `tests/Feature/ReviewPack`, `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`, and `tests/Feature/Guards/ActionSurfaceContractTest.php`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
|
||||
65
specs/153-evidence-domain-foundation/quickstart.md
Normal file
65
specs/153-evidence-domain-foundation/quickstart.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Quickstart: Evidence Domain Foundation
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that the application can capture, inspect, reuse, and expire immutable evidence snapshots built from existing internal governance artifacts.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start Sail and ensure the application database is up.
|
||||
2. Apply migrations for the evidence snapshot tables.
|
||||
3. Ensure at least one tenant has existing data in these domains:
|
||||
- findings
|
||||
- stored permission posture report
|
||||
- stored Entra admin roles report
|
||||
- baseline/drift posture summary or equivalent run context
|
||||
- recent operation runs
|
||||
|
||||
## Happy-path validation
|
||||
|
||||
1. Open `/admin/t/{tenant}/evidence` for an authorized tenant member.
|
||||
2. Trigger `Create snapshot`.
|
||||
3. Confirm the UI shows queued intent feedback and a linked run.
|
||||
4. Follow the linked run in Monitoring and wait for the background run to complete.
|
||||
5. Open `/admin/t/{tenant}/evidence/{snapshot}` and verify:
|
||||
- snapshot status is `active`
|
||||
- snapshot completeness state matches the available inputs
|
||||
- each first-slice evidence dimension appears exactly once
|
||||
- stale or missing dimensions are explicitly marked
|
||||
- the detail page links to the canonical run detail instead of rendering a separate progress surface
|
||||
6. Modify one live source artifact, such as a finding or stored report.
|
||||
7. Re-open the original snapshot and confirm its captured data did not change.
|
||||
8. Trigger `Refresh evidence` and confirm a new snapshot is created or the existing one is reused if the fingerprint is unchanged.
|
||||
|
||||
## Authorization checks
|
||||
|
||||
1. As a non-member, request the tenant evidence routes and confirm deny-as-not-found behavior.
|
||||
2. As an in-scope member without `evidence.manage`, confirm listing/detail works with `evidence.view` but `Create snapshot` and `Expire snapshot` are forbidden.
|
||||
|
||||
## Downstream consumer validation
|
||||
|
||||
1. Resolve tenant evidence through the new snapshot resolver from a downstream flow.
|
||||
2. Confirm the downstream flow receives either:
|
||||
- an explicit eligible snapshot id, or
|
||||
- an explicit missing/ineligible result
|
||||
3. Confirm no silent fallback to live ad hoc assembly occurs in covered consumers.
|
||||
|
||||
## Focused test commands
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Evidence tests/Unit/Evidence tests/Feature/ReviewPack tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Suggested first test files
|
||||
|
||||
- `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`
|
||||
- `tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
- `tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php`
|
||||
- `tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
|
||||
- `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`
|
||||
- `tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- `tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php`
|
||||
- `tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php`
|
||||
- `tests/Unit/Evidence/EvidenceSnapshotResolverTest.php`
|
||||
- `tests/Feature/ReviewPack/ReviewPackGenerationTest.php`
|
||||
50
specs/153-evidence-domain-foundation/research.md
Normal file
50
specs/153-evidence-domain-foundation/research.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Research: Evidence Domain Foundation
|
||||
|
||||
## Decision 1: Model evidence as immutable snapshots plus per-dimension items
|
||||
|
||||
- **Decision**: Create a dedicated `evidence_snapshots` root record and a child `evidence_snapshot_items` table keyed by evidence dimension.
|
||||
- **Rationale**: The candidate is a curation and completeness layer, not a stakeholder export. A normalized snapshot root plus dimension items supports immutable capture, completeness per dimension, tenant/workspace-safe filtering, and downstream reuse without forcing every consumer to parse a single monolithic blob.
|
||||
- **Alternatives considered**:
|
||||
- Reuse `review_packs` as the evidence domain: rejected because `ReviewPack` is export-centric, binary-file oriented, and already downstream-facing.
|
||||
- Store one large JSON blob per snapshot: rejected because it weakens queryability, completeness filtering, and per-dimension provenance.
|
||||
|
||||
## Decision 2: Snapshot generation is DB-only queued work with a new OperationRun type
|
||||
|
||||
- **Decision**: Generate evidence snapshots through a queued `OperationRun` such as `tenant.evidence.snapshot.generate`, created and updated exclusively through `OperationRunService`.
|
||||
- **Rationale**: Snapshot creation aggregates existing internal artifacts and can exceed synchronous request budgets. Existing patterns in `ReviewPackService`, `GenerateReviewPackJob`, `ScanEntraAdminRolesJob`, and `OperationRunService` already solve dedupe, active-run visibility, and canonical operator feedback.
|
||||
- **Alternatives considered**:
|
||||
- Inline synchronous snapshot creation: rejected because it would violate existing operations UX expectations and create unpredictable request latency.
|
||||
- DB-only action with no `OperationRun`: rejected because snapshot generation is operationally relevant and must remain observable and auditable.
|
||||
|
||||
## Decision 3: Reuse existing internal artifacts as source references, not as copied raw payload dumps
|
||||
|
||||
- **Decision**: Snapshot items store curated summaries plus source references and source fingerprints to `StoredReport`, `Finding` summaries, baseline/drift summaries, and recent `OperationRun` rollups.
|
||||
- **Rationale**: The foundation should preserve reproducible evidence composition while minimizing duplication. Existing artifacts already carry domain truth; the snapshot needs to record what was included, how fresh it was, and how complete it was.
|
||||
- **Alternatives considered**:
|
||||
- Deep-copy every source payload in full: rejected because it increases storage cost, duplicates secrets/redaction risks, and blurs the line between curated evidence and archival dump.
|
||||
- Store only foreign keys with no captured summary: rejected because snapshots would become unintelligible if source artifacts later changed or expired.
|
||||
|
||||
## Decision 4: Dedupe identical evidence state by fingerprint, but supersede changed active snapshots
|
||||
|
||||
- **Decision**: Use a deterministic fingerprint over the included evidence state. If the fingerprint matches an existing non-expired snapshot for the same tenant scope, reuse it. If the fingerprint changes, create a new snapshot and mark the previously active snapshot as `superseded`.
|
||||
- **Rationale**: This preserves immutability and prevents duplicate noise while still producing a historical chain when evidence actually changes. The pattern aligns with existing `StoredReport` and `ReviewPack` fingerprint behavior.
|
||||
- **Alternatives considered**:
|
||||
- Always create a new snapshot on every request: rejected because it creates operator noise and unnecessary storage churn.
|
||||
- Update the existing snapshot in place: rejected because it destroys the foundation's core immutability guarantee.
|
||||
|
||||
## Decision 5: Downstream consumers must resolve explicit snapshots, not silently fall back to live data
|
||||
|
||||
- **Decision**: Introduce a resolver contract that returns an explicit eligible snapshot or an explicit “no snapshot available” result. Downstream consumers in the first slice use this contract instead of reconstructing the same evidence set from raw live records.
|
||||
- **Rationale**: The foundation only creates real architectural leverage if downstream consumers depend on it intentionally. Silent fallback to live sources would hide evidence gaps and undermine reproducibility.
|
||||
- **Alternatives considered**:
|
||||
- Let downstream consumers fall back to live assembly when no snapshot exists: rejected because it preserves the current ad hoc architecture and weakens trust.
|
||||
- Force all consumers to always require a snapshot immediately: rejected because first-slice adoption needs staged rollout and explicit absence handling.
|
||||
|
||||
## Decision 6: Use a tenant-context resource plus a workspace overview page for first-slice visibility
|
||||
|
||||
- **Decision**: Provide a tenant-scoped evidence snapshot list/detail surface and a workspace-scoped overview that summarizes evidence completeness across authorized tenants.
|
||||
- **Rationale**: The spec requires both immutable capture and pre-report completeness visibility. Existing patterns in `ReviewPackResource`, `TenantReviewPackCard`, and the canonical `AuditLog` page show how tenant detail and workspace-wide review can coexist safely.
|
||||
- **Implementation note**: The implemented canonical overview lives at `/admin/evidence/overview`, while the tenant-context Filament resource uses the evidence slug directly rather than a nested `/snapshots` segment. Optional overview prefilters are carried only for entitled tenant ids.
|
||||
- **Alternatives considered**:
|
||||
- No UI until downstream consumers exist: rejected because completeness visibility is a first-class user story, not a later polish layer.
|
||||
- Tenant-only UI with no workspace overview: rejected because MSP/workspace operators need safe aggregate visibility before tenant-by-tenant drill-down.
|
||||
169
specs/153-evidence-domain-foundation/spec.md
Normal file
169
specs/153-evidence-domain-foundation/spec.md
Normal file
@ -0,0 +1,169 @@
|
||||
# Feature Specification: Evidence Domain Foundation
|
||||
|
||||
**Feature Branch**: `153-evidence-domain-foundation`
|
||||
**Created**: 2026-03-19
|
||||
**Status**: Draft
|
||||
**Input**: User description: "erstell den spec aus spec candidate der strategisch am besten ist als nächstes"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace + tenant + canonical-view
|
||||
- **Primary Routes**:
|
||||
- `/admin/t/{tenant}/evidence` as the tenant-scoped evidence inventory and snapshot surface
|
||||
- `/admin/t/{tenant}/evidence/{snapshot}` as the canonical evidence snapshot inspection route
|
||||
- Workspace-scoped evidence overview surfaces that summarize snapshot coverage across managed tenants without exposing foreign-tenant detail
|
||||
- Existing downstream consumers such as review-pack generation and future readiness/reporting flows that consume evidence snapshots rather than assembling raw artifacts ad hoc
|
||||
- **Data Ownership**:
|
||||
- Tenant-owned: evidence snapshots, evidence completeness state, artifact references, generation metadata, and immutable historical evidence packages
|
||||
- Tenant-owned inputs: findings, stored reports, baseline posture summaries, operational health signals, and related tenant-governance artifacts that are curated into the evidence domain without changing their original systems of record
|
||||
- Workspace-owned: aggregate evidence overview filters, rollup summaries, and canonical-view presentation state that never persist foreign-tenant evidence as workspace-owned domain records
|
||||
- Downstream exports and stakeholder-facing reports remain separate consumers and are not redefined by this feature
|
||||
- **RBAC**:
|
||||
- Workspace membership remains the prerequisite for any evidence-domain access
|
||||
- Tenant entitlement remains required to inspect tenant-scoped evidence detail
|
||||
- `evidence.view` permits listing and inspecting evidence snapshots within authorized scope
|
||||
- `evidence.manage` permits creating, refreshing, and expiring evidence snapshots
|
||||
- Non-members or users outside the relevant tenant scope remain deny-as-not-found; in-scope members lacking the required capability remain forbidden
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Tenant evidence lists and detail routes default to the routed tenant only. Workspace evidence overview surfaces may carry a tenant prefilter only when the operator explicitly navigates from tenant context, and that prefilter is limited to entitled tenants and remains removable from the shared canonical view.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Evidence queries, completeness summaries, filter options, snapshot labels, and artifact references must always be assembled after workspace and tenant entitlement checks. Unauthorized users must not learn whether another tenant has evidence gaps, artifacts, or snapshots.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Create an immutable evidence snapshot (Priority: P1)
|
||||
|
||||
As a governance operator, I want to capture the current evidence state for a tenant into one immutable snapshot, so that later exports, reviews, and audit conversations refer to a stable package instead of a moving collection of live records.
|
||||
|
||||
**Why this priority**: This is the foundation of the entire capability. Without immutable snapshots, every downstream review artifact remains ad hoc and non-reproducible.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating a tenant evidence snapshot from existing findings, stored reports, and governance summaries, then changing the live source records and confirming the captured snapshot remains unchanged.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has findings, stored reports, and baseline-related governance signals, **When** an authorized operator creates an evidence snapshot, **Then** the system produces one immutable snapshot that records what evidence items were included and their completeness state at capture time.
|
||||
2. **Given** a snapshot has already been created, **When** live findings or stored reports change later, **Then** the original snapshot remains unchanged and a new snapshot is required to reflect the newer evidence state.
|
||||
3. **Given** required evidence inputs are partially missing, **When** a snapshot is created, **Then** the snapshot still completes and records explicit completeness gaps rather than pretending the evidence set is complete.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Understand evidence completeness before downstream reporting (Priority: P1)
|
||||
|
||||
As a governance operator, I want to see which evidence dimensions are present, stale, or missing before I generate review outputs, so that downstream reports are based on known coverage instead of hidden gaps.
|
||||
|
||||
**Why this priority**: The evidence domain is only valuable if it exposes completeness clearly. Otherwise it becomes another opaque storage layer.
|
||||
|
||||
**Independent Test**: Can be fully tested by preparing tenants with complete, partial, and stale evidence inputs and confirming that the evidence surface reports the correct completeness and freshness state for each dimension.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has current findings and stored reports for all in-scope evidence dimensions, **When** the operator inspects evidence status, **Then** the tenant shows a complete evidence state for those dimensions.
|
||||
2. **Given** one or more evidence dimensions are missing or stale, **When** the operator inspects evidence status or a snapshot detail view, **Then** the missing or stale dimensions are clearly identified with no false claim of readiness.
|
||||
3. **Given** an operator opens a specific snapshot, **When** they inspect it, **Then** they can see which evidence dimensions were included, omitted, or incomplete at the time of capture.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Reuse one evidence package across downstream consumers (Priority: P2)
|
||||
|
||||
As a product owner, I want review packs and future readiness/reporting surfaces to consume curated evidence snapshots instead of reassembling raw artifacts independently, so that evidence logic stays consistent across the product.
|
||||
|
||||
**Why this priority**: The strategic value is architectural reuse. If every downstream feature keeps rebuilding its own evidence assembly logic, the foundation does not actually exist.
|
||||
|
||||
**Independent Test**: Can be fully tested by generating a snapshot and verifying that downstream consumers resolve included evidence artifacts through the snapshot contract rather than directly reading live source artifacts for the same workflow.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a downstream export or readiness flow requests tenant evidence, **When** an eligible snapshot already exists, **Then** the downstream flow resolves evidence through the snapshot contract instead of rebuilding the same evidence set from scratch.
|
||||
2. **Given** the operator needs refreshed evidence, **When** they explicitly request a new snapshot, **Then** the newer snapshot becomes available alongside older snapshots rather than mutating them in place.
|
||||
3. **Given** two downstream consumers use the same snapshot, **When** they inspect included evidence dimensions, **Then** both consumers see the same inclusion and completeness truth.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant has some evidence dimensions available and others never generated; the snapshot must be captured as partial rather than blocked indefinitely.
|
||||
- A stored report or findings source is stale but still readable; the foundation must preserve freshness metadata instead of silently treating stale evidence as current.
|
||||
- A source artifact referenced by an older snapshot is later deleted or superseded; the older snapshot must remain intelligible from stored snapshot metadata.
|
||||
- An operator triggers snapshot creation twice in quick succession with unchanged inputs; the system must avoid creating duplicate immutable artifacts when the evidence state is unchanged.
|
||||
- A workspace operator can view workspace-level evidence summaries but is not entitled to inspect every tenant represented in the workspace; unauthorized tenant detail must remain hidden.
|
||||
- A downstream consumer requests evidence for a tenant with no snapshot yet; the system must fail with a clear absence state rather than silently falling back to untracked live assembly.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature introduces a new evidence-domain data model, write behavior for immutable snapshot capture, and queued generation work that assembles evidence from existing internal artifacts only. It does not introduce new Microsoft Graph calls. The feature must define tenant isolation, completeness semantics, snapshot immutability, and run observability for snapshot generation. Because evidence capture is a high-value governance mutation, every snapshot creation and expiration path must be auditable and test-covered.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature creates a new `OperationRun` family for evidence snapshot generation and must comply with the Ops-UX 3-surface feedback contract. Start actions may show intent-only feedback. Progress belongs only in the active-ops widget and Monitoring run detail. Evidence snapshot detail surfaces may link to the canonical run detail but must not render a parallel progress surface. Terminal notification behavior remains service-owned through `OperationRunService`. Any summary counts recorded for evidence snapshot generation must use allowed operation summary keys and numeric-only values. Scheduled or system-initiated snapshot generation must not create initiator-only terminal database notifications.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature operates in the tenant/admin plane for tenant-scoped evidence surfaces and in workspace-admin canonical views for cross-tenant evidence overview. Cross-plane access remains deny-as-not-found. Non-members or users outside workspace or tenant scope receive `404`. In-scope users lacking `evidence.view` or `evidence.manage` receive `403` according to the action attempted. Authorization must be enforced server-side for snapshot generation, inspection, expiration, and any downstream evidence resolution entry point. The canonical capability registry remains the only capability source. Any destructive-like expiration action requires confirmation.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Evidence completeness, freshness, and snapshot status are status-like values and must use centralized badge semantics rather than local page-level color mapping. Tests must cover all newly introduced status values.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** The target object is the evidence snapshot. Operator-facing verbs are `Create snapshot`, `View snapshot`, `Refresh evidence`, and `Expire snapshot`. Source/domain disambiguation is needed only where a snapshot references specific evidence dimensions such as findings, permission posture, or baseline posture. The same evidence vocabulary must be preserved across action labels, modal titles, run titles, notifications, and audit prose. Implementation-first terms such as `materialize`, `denormalize`, or `projection rebuild` must not become primary operator-facing labels.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature adds a Filament evidence resource or page family for list and detail inspection plus snapshot-generation actions. The Action Surface Contract is satisfied if creation remains an explicit header action, list inspection uses a canonical inspect affordance, destructive expiration requires confirmation, and all mutations are authorization-gated and auditable.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Evidence list screens must provide search, sort, and filters for core dimensions such as tenant, snapshot status, completeness state, and capture date. Snapshot detail must use an Infolist-style inspection surface rather than a disabled edit form. Empty states must include a specific title, explanation, and exactly one CTA. If snapshot creation uses a modal instead of a dedicated create page, that is an explicit exemption because snapshot creation is an operation trigger, not freeform data entry.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-153-001**: The system MUST provide a first-class evidence domain that curates existing governance artifacts into immutable evidence snapshots.
|
||||
- **FR-153-002**: Each evidence snapshot MUST record the workspace, tenant, creation time, initiating actor class, included evidence dimensions, completeness state, and freshness metadata for each included dimension.
|
||||
- **FR-153-003**: The first implementation slice MUST support at least findings summaries, permission posture evidence, Entra admin-role evidence, baseline or drift posture summaries, and one explicit `operations_summary` dimension composed from recent tenant-scoped `OperationRun` rollups relevant to downstream review-pack and readiness evidence.
|
||||
- **FR-153-004**: Evidence snapshots MUST be immutable after successful creation. Refreshing evidence MUST create a new snapshot rather than mutating an existing one.
|
||||
- **FR-153-005**: The system MUST capture evidence completeness explicitly, including complete, partial, missing, and stale states for each evidence dimension.
|
||||
- **FR-153-006**: The system MUST allow snapshot creation to succeed when evidence is partial, provided the missing or stale dimensions are recorded clearly in snapshot metadata.
|
||||
- **FR-153-007**: Evidence snapshot generation MUST use existing internal evidence sources and MUST NOT trigger live Microsoft Graph collection during snapshot creation.
|
||||
- **FR-153-008**: The system MUST provide one canonical tenant-scoped evidence inventory surface where authorized operators can list snapshots and inspect their completeness and freshness state.
|
||||
- **FR-153-009**: The system MUST provide a canonical snapshot inspection surface that shows included evidence dimensions, completeness state, freshness metadata, source references, and downstream readiness suitability.
|
||||
- **FR-153-010**: Workspace-scoped evidence overview surfaces MUST summarize evidence availability and completeness across authorized tenants without revealing unauthorized tenant detail.
|
||||
- **FR-153-011**: The system MUST define a deduplication rule for unchanged evidence state so that repeated snapshot requests with identical included evidence do not create unnecessary duplicate immutable artifacts.
|
||||
- **FR-153-012**: The system MUST expose whether a downstream consumer is using a specific snapshot or whether no eligible snapshot exists for the requested evidence scope.
|
||||
- **FR-153-013**: Downstream consumers in scope for this foundation MUST be able to resolve evidence through the snapshot contract instead of rebuilding the same evidence set directly from live source records.
|
||||
- **FR-153-014**: The system MUST preserve enough snapshot metadata that an older snapshot remains intelligible even if one or more underlying source artifacts are later superseded, expired, or removed from active views.
|
||||
- **FR-153-015**: The feature MUST define retention and lifecycle semantics for evidence snapshots, including active, superseded, and expired states.
|
||||
- **FR-153-016**: Expiring an evidence snapshot MUST be an explicit, confirmed, auditable action and MUST NOT retroactively change what the snapshot contained.
|
||||
- **FR-153-017**: Evidence snapshot generation and expiration MUST be recorded in audit history with workspace scope, tenant scope, actor, action, and outcome.
|
||||
- **FR-153-018**: The feature MUST introduce at least one positive and one negative authorization test for evidence snapshot viewing and management.
|
||||
- **FR-153-019**: The feature MUST introduce regression tests proving snapshot immutability, completeness-state accuracy, downstream consumer reuse, and cross-tenant isolation.
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Evidence Snapshot List | Tenant-context evidence page or resource under `/admin/t/{tenant}/evidence` | `Create snapshot` (`evidence.manage`) | Clickable row to snapshot detail | `View snapshot`, `Expire snapshot` (`evidence.manage`, destructive) | None in v1 | `Create first snapshot` | None | N/A | Yes | Create uses an action modal as an operation trigger, not a CRUD create page |
|
||||
| Evidence Snapshot Detail | Canonical detail route under `/admin/t/{tenant}/evidence/{snapshot}` | `Refresh evidence` (`evidence.manage`) | N/A | None | None | N/A | `Refresh evidence`, `Expire snapshot` | N/A | Yes | Detail is an inspection surface and must use Infolist-style composition |
|
||||
| Workspace Evidence Overview | Workspace-admin canonical evidence overview | Contextual filter actions only | Row or linked-card drill-down to authorized tenant evidence view | `View tenant evidence` | None | `Clear filters` | None | N/A | No direct mutation | Must suppress unauthorized tenant rows and filter options |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Evidence Snapshot**: An immutable, timestamped evidence package for one tenant within one workspace, capturing which evidence dimensions were included and how complete they were at a specific point in time.
|
||||
- **Evidence Dimension**: A named evidence category such as findings summary, permission posture, baseline posture, or operational health summary that can be included, missing, stale, or partial in a snapshot.
|
||||
- **Evidence Source Reference**: A structured reference to the underlying artifact or summary that supplied a given evidence dimension at snapshot time.
|
||||
- **Evidence Completeness State**: The normalized assessment of whether the snapshot is complete, partial, missing required dimensions, or stale for downstream governance use.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: An authorized operator can create a tenant evidence snapshot and inspect its completeness state in under 2 minutes without leaving the product.
|
||||
- **SC-002**: 100% of evidence snapshots created in automated tests remain unchanged after their underlying live source artifacts are modified.
|
||||
- **SC-003**: Operators can identify missing or stale evidence dimensions for a tenant from the evidence surface in one inspection step, without manually cross-checking multiple source pages.
|
||||
- **SC-004**: Repeated snapshot requests with unchanged evidence state do not create duplicate artifacts in automated deduplication tests.
|
||||
- **SC-005**: Downstream consumers covered by the first rollout resolve evidence from the snapshot contract instead of rebuilding equivalent live evidence sets in automated integration tests.
|
||||
- **SC-006**: Negative authorization tests prove that non-members receive deny-as-not-found behavior and in-scope users without the proper capability cannot create or expire evidence snapshots.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Review pack export, permission posture, Entra admin-role evidence, findings workflow, and baseline or drift posture outputs are sufficiently mature to act as the first evidence-domain inputs.
|
||||
- The first rollout focuses on internal evidence curation and inspection, not on stakeholder-facing readiness presentation.
|
||||
- Evidence snapshot generation is DB-backed and can rely on existing stored or summarized artifacts rather than live recollection.
|
||||
- The downstream consumer contract begins with one or more existing export or reporting flows and expands later.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Building executive, customer-facing, or framework-oriented readiness presentations
|
||||
- Replacing review-pack export with a new export format
|
||||
- Acting as a generic BI warehouse or arbitrary reporting engine
|
||||
- Triggering new Microsoft Graph scans during snapshot creation
|
||||
- Defining formal compliance certification claims or legal attestations
|
||||
218
specs/153-evidence-domain-foundation/tasks.md
Normal file
218
specs/153-evidence-domain-foundation/tasks.md
Normal file
@ -0,0 +1,218 @@
|
||||
# Tasks: Evidence Domain Foundation
|
||||
|
||||
**Input**: Design documents from `/specs/153-evidence-domain-foundation/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime data modeling, queued operations, tenant/workspace authorization, downstream review-pack behavior, and Filament evidence surfaces in a Laravel/Pest codebase.
|
||||
**Operations**: This feature introduces a new queued `OperationRun` family for evidence snapshot generation. Tasks below keep `OperationRun.status` and `OperationRun.outcome` service-owned via `OperationRunService`, preserve initiator-only terminal notifications, keep live progress limited to the active-ops widget and Monitoring run detail, and ensure `summary_counts` remain flat numeric values using canonical keys only.
|
||||
**RBAC**: This feature changes authorization in tenant-context admin surfaces and a workspace-scoped Monitoring overview. Tasks below preserve `404` for non-members or non-entitled actors, `403` for in-scope capability denials, canonical capability-registry usage, confirmed destructive expiration actions, and explicit downstream resolver authorization.
|
||||
**UI Naming**: Operator-facing copy must stay aligned to `Create snapshot`, `Refresh evidence`, `View snapshot`, and `Expire snapshot` across actions, run titles, notifications, and audit prose.
|
||||
**Filament UI Action Surfaces**: This feature adds a tenant evidence resource and a workspace evidence overview page. Tasks below enforce clickable-row inspection, no lone View row action, confirmed destructive actions, empty-state CTA behavior, and Action Surface contract coverage.
|
||||
**Filament UI UX-001**: Snapshot detail must use an Infolist-style inspection surface. Snapshot creation remains a modal action exemption rather than a CRUD create page.
|
||||
**Badges**: Snapshot status and completeness state must use centralized badge semantics via `BadgeCatalog` and `BadgeRenderer`, with mapping tests for every introduced value.
|
||||
**Contract Artifacts**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/153-evidence-domain-foundation/contracts/evidence-domain.openapi.yaml` is an internal planning contract that must stay aligned with the implemented resource, page, and downstream resolver behavior.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Prepare the evidence-domain test targets and implementation scaffolding used across all stories.
|
||||
|
||||
- [X] T001 [P] Create the feature-test skeletons for evidence list/detail, overview, job behavior, and audit coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php`, and `tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
|
||||
- [X] T002 [P] Create the unit-test skeletons for fingerprinting, completeness, and resolver behavior in `tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php`, `tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php`, `tests/Unit/Evidence/EvidenceSnapshotResolverTest.php`, and `tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`
|
||||
- [X] T003 [P] Create the evidence-domain scaffolding stubs in `app/Models/EvidenceSnapshot.php`, `app/Models/EvidenceSnapshotItem.php`, `app/Jobs/GenerateEvidenceSnapshotJob.php`, `app/Services/Evidence/EvidenceSnapshotService.php`, and `app/Services/Evidence/EvidenceSnapshotResolver.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Establish the shared schema, domain types, resolver contracts, and operation/audit seams required before any user story work starts.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T004 Create the evidence snapshot schema and indexes in `database/migrations/2026_03_19_000000_create_evidence_snapshots_table.php` and `database/migrations/2026_03_19_000001_create_evidence_snapshot_items_table.php`
|
||||
- [X] T005 Implement the root and child snapshot models, relationships, casts, and tenant/workspace scopes in `app/Models/EvidenceSnapshot.php` and `app/Models/EvidenceSnapshotItem.php`
|
||||
- [X] T006 [P] Add evidence status and completeness domain types plus centralized badge mappings in `app/Support/Evidence/EvidenceSnapshotStatus.php`, `app/Support/Evidence/EvidenceCompletenessState.php`, `app/Support/Badges/Domains/EvidenceSnapshotStatusBadge.php`, and `app/Support/Badges/Domains/EvidenceCompletenessBadge.php`
|
||||
- [X] T007 [P] Register the new evidence capabilities, operation-run type, and audit action ids in `app/Support/Auth/Capabilities.php`, `app/Support/OperationRunType.php`, and `app/Support/Audit/AuditActionId.php`
|
||||
- [X] T008 [P] Implement the reusable fingerprinting, completeness-evaluation, and resolution DTO contracts in `app/Services/Evidence/EvidenceSnapshotFingerprint.php`, `app/Services/Evidence/EvidenceCompletenessEvaluator.php`, `app/Services/Evidence/EvidenceResolutionRequest.php`, and `app/Services/Evidence/EvidenceResolutionResult.php`
|
||||
- [X] T009 [P] Define the first-slice evidence-source contract and collector classes, including the explicit `operations_summary` rollup scope, in `app/Services/Evidence/Contracts/EvidenceSourceProvider.php`, `app/Services/Evidence/Sources/FindingsSummarySource.php`, `app/Services/Evidence/Sources/PermissionPostureSource.php`, `app/Services/Evidence/Sources/EntraAdminRolesSource.php`, `app/Services/Evidence/Sources/BaselineDriftPostureSource.php`, and `app/Services/Evidence/Sources/OperationsSummarySource.php`
|
||||
- [X] T010 Implement the service-owned orchestration seam for snapshot generation and expiration in `app/Services/Evidence/EvidenceSnapshotService.php` and `app/Jobs/GenerateEvidenceSnapshotJob.php`
|
||||
|
||||
**Checkpoint**: Foundation ready. The repo now has the evidence schema, domain types, source-collector seams, and queued-operation/audit entrypoints needed for all user stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Create an immutable evidence snapshot (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Let an authorized operator create one immutable, reproducible evidence snapshot for a tenant and inspect it later without live-source drift changing the captured truth.
|
||||
|
||||
**Independent Test**: Generate a snapshot from existing findings and reports, change the live source records afterward, and confirm the original snapshot remains unchanged while repeat requests reuse or supersede snapshots according to fingerprint state.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T011 [P] [US1] Add fingerprint reuse, supersede, and immutability coverage in `tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php` and `tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php`
|
||||
- [X] T012 [P] [US1] Add tenant evidence authorization and `404` versus `403` coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`
|
||||
- [X] T013 [P] [US1] Add action-surface and Ops-UX regression coverage for create, refresh, and expire flows in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||
- [X] T014 [P] [US1] Add audit-log regression coverage for snapshot create, refresh, and expire flows in `tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T015 [US1] Implement the first-slice collectors and curated snapshot-item payload assembly in `app/Services/Evidence/Sources/FindingsSummarySource.php`, `app/Services/Evidence/Sources/PermissionPostureSource.php`, `app/Services/Evidence/Sources/EntraAdminRolesSource.php`, `app/Services/Evidence/Sources/BaselineDriftPostureSource.php`, `app/Services/Evidence/Sources/OperationsSummarySource.php`, and `app/Jobs/GenerateEvidenceSnapshotJob.php`
|
||||
- [X] T016 [US1] Implement queued snapshot generation, fingerprint dedupe, supersede transitions, and flat `summary_counts` handling in `app/Services/Evidence/EvidenceSnapshotService.php` and `app/Jobs/GenerateEvidenceSnapshotJob.php`
|
||||
- [X] T017 [US1] Implement the tenant evidence Filament resource and list/view pages in `app/Filament/Resources/EvidenceSnapshotResource.php`, `app/Filament/Resources/EvidenceSnapshotResource/Pages/ListEvidenceSnapshots.php`, and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
- [X] T018 [US1] Implement confirmed create, refresh, and expire actions with canonical run links and audit entries in `app/Filament/Resources/EvidenceSnapshotResource.php` and `app/Services/Audit/WorkspaceAuditLogger.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when authorized operators can create, inspect, refresh, and expire tenant evidence snapshots without any snapshot mutating after it becomes active.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Understand evidence completeness before downstream reporting (Priority: P1)
|
||||
|
||||
**Goal**: Show operators which evidence dimensions are complete, partial, missing, or stale before they generate downstream outputs.
|
||||
|
||||
**Independent Test**: Prepare complete, partial, and stale evidence inputs for multiple tenants and confirm both the tenant detail surface and workspace overview display the correct completeness and freshness state without leaking unauthorized tenant detail.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T019 [P] [US2] Add completeness-precedence and badge-mapping coverage in `tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php` and `tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php`
|
||||
- [X] T020 [P] [US2] Add snapshot-detail completeness and freshness coverage in `tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php`
|
||||
- [X] T021 [P] [US2] Add workspace-overview authorization, entitled-tenant prefilter, and cross-tenant suppression coverage in `tests/Feature/Evidence/EvidenceOverviewPageTest.php` and `tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T022 [US2] Implement completeness precedence and snapshot summary aggregation in `app/Services/Evidence/EvidenceCompletenessEvaluator.php` and `app/Models/EvidenceSnapshot.php`
|
||||
- [X] T023 [US2] Implement evidence badge rendering and snapshot-detail infolist sections plus canonical run-detail links in `app/Support/Badges/Domains/EvidenceSnapshotStatusBadge.php`, `app/Support/Badges/Domains/EvidenceCompletenessBadge.php`, and `app/Filament/Resources/EvidenceSnapshotResource.php`
|
||||
- [X] T024 [US2] Implement the workspace evidence overview page, authorized tenant filtering, entitled-tenant prefilter carryover, and empty-state/filter behavior in `app/Filament/Pages/Monitoring/EvidenceOverview.php` and `resources/views/filament/pages/monitoring/evidence-overview.blade.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when operators can identify completeness and freshness gaps from a single tenant snapshot view or the workspace overview without checking multiple source pages.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Reuse one evidence package across downstream consumers (Priority: P2)
|
||||
|
||||
**Goal**: Make downstream consumers, starting with review packs, resolve curated evidence snapshots explicitly instead of rebuilding equivalent live evidence bundles.
|
||||
|
||||
**Independent Test**: Generate a snapshot, run review-pack generation, and verify the downstream flow resolves an eligible snapshot or fails explicitly with `missing_snapshot` or `snapshot_ineligible` instead of silently falling back to live assembly.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T025 [P] [US3] Add resolver result coverage for `resolved`, `missing_snapshot`, and `snapshot_ineligible` outcomes in `tests/Unit/Evidence/EvidenceSnapshotResolverTest.php` and `tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php`
|
||||
- [X] T026 [P] [US3] Add review-pack reuse and no-live-fallback coverage in `tests/Feature/ReviewPack/ReviewPackGenerationTest.php` and `tests/Feature/ReviewPack/ReviewPackResourceTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T027 [US3] Persist review-pack-to-snapshot linkage in `database/migrations/2026_03_19_000002_add_evidence_snapshot_id_to_review_packs_table.php` and `app/Models/ReviewPack.php`
|
||||
- [X] T028 [US3] Implement the explicit snapshot resolver contract in `app/Services/Evidence/EvidenceSnapshotResolver.php`, `app/Services/Evidence/EvidenceResolutionRequest.php`, and `app/Services/Evidence/EvidenceResolutionResult.php`
|
||||
- [X] T029 [US3] Refactor review-pack generation to resolve and consume evidence snapshots in `app/Services/ReviewPackService.php` and `app/Jobs/GenerateReviewPackJob.php`
|
||||
- [X] T030 [US3] Surface snapshot provenance and downstream eligibility details in `app/Filament/Resources/ReviewPackResource.php` and `app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when review packs and other first-slice consumers depend on explicit snapshot resolution instead of reconstructing evidence from live source records.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Finalize contract alignment, regression coverage, formatting, and manual validation across all stories.
|
||||
|
||||
- [X] T031 [P] Align the planning contract and design notes with the implementation in `specs/153-evidence-domain-foundation/contracts/evidence-domain.openapi.yaml` and `specs/153-evidence-domain-foundation/research.md`
|
||||
- [X] T032 [P] Align the manual validation flow and focused test commands in `specs/153-evidence-domain-foundation/quickstart.md` and `specs/153-evidence-domain-foundation/plan.md`
|
||||
- [X] T033 Run the focused Pest suite from `specs/153-evidence-domain-foundation/quickstart.md` covering `tests/Feature/Evidence`, `tests/Unit/Evidence`, and `tests/Feature/ReviewPack`
|
||||
- [X] T034 Run formatting with `vendor/bin/sail bin pint --dirty --format agent`
|
||||
- [X] T035 [P] Validate the manual smoke checklist in `specs/153-evidence-domain-foundation/quickstart.md` against `/admin/t/{tenant}/evidence`, `/admin/t/{tenant}/evidence/{snapshot}`, `/admin/evidence/overview`, and review-pack generation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1: Setup** has no dependencies and can start immediately.
|
||||
- **Phase 2: Foundational** depends on Phase 1 and blocks all user stories.
|
||||
- **Phase 3: User Story 1** depends on Phase 2 and delivers the MVP.
|
||||
- **Phase 4: User Story 2** depends on Phase 2 and can proceed after the foundational layer exists, though it benefits from US1 data-generation paths landing first.
|
||||
- **Phase 5: User Story 3** depends on Phase 2 and should follow US1 so real snapshots exist for downstream reuse.
|
||||
- **Phase 6: Polish** depends on all desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: No dependency on other stories. This is the recommended MVP slice.
|
||||
- **US2 (P1)**: Depends only on the foundational schema, badges, and snapshot generation contracts, but is easiest to validate after US1 produces real snapshots.
|
||||
- **US3 (P2)**: Depends on the foundational resolver contracts and on US1 snapshot generation being complete.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or extend tests first and confirm they fail before implementation.
|
||||
- Models, enums, and collector contracts must land before Filament surfaces or downstream consumer integration.
|
||||
- Service-owned `OperationRun` transitions and audit hooks must land before action-surface work is considered complete.
|
||||
- Workspace overview and downstream integration work should consume the same canonical snapshot/query layer rather than duplicating evidence assembly logic.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001`, `T002`, and `T003` can run in parallel.
|
||||
- `T006`, `T007`, `T008`, and `T009` can run in parallel after `T004` and `T005` define the schema and models.
|
||||
- `T011`, `T012`, `T013`, and `T014` can run in parallel within User Story 1.
|
||||
- `T019`, `T020`, and `T021` can run in parallel within User Story 2.
|
||||
- `T025` and `T026` can run in parallel within User Story 3.
|
||||
- `T031`, `T032`, and `T035` can run in parallel after implementation is complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the US1 regression additions together:
|
||||
Task: "Add fingerprint reuse, supersede, and immutability coverage in tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php and tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php"
|
||||
Task: "Add tenant evidence authorization and 404 versus 403 coverage in tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php"
|
||||
Task: "Add action-surface and Ops-UX regression coverage for create, refresh, and expire flows in tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and tests/Feature/Guards/ActionSurfaceContractTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Split completeness and overview validation:
|
||||
Task: "Add completeness-precedence and badge-mapping coverage in tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php and tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php"
|
||||
Task: "Add workspace-overview authorization and cross-tenant suppression coverage in tests/Feature/Evidence/EvidenceOverviewPageTest.php and tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php"
|
||||
Task: "Add snapshot-detail completeness and freshness coverage in tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php"
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Split resolver and downstream-consumer regression work:
|
||||
Task: "Add resolver result coverage for resolved, missing_snapshot, and snapshot_ineligible outcomes in tests/Unit/Evidence/EvidenceSnapshotResolverTest.php and tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php"
|
||||
Task: "Add review-pack reuse and no-live-fallback coverage in tests/Feature/ReviewPack/ReviewPackGenerationTest.php and tests/Feature/ReviewPack/ReviewPackResourceTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Stop and validate that immutable evidence snapshots can be created, reused, superseded, and inspected safely.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Land the evidence schema, domain types, collector contracts, and queued-operation seams.
|
||||
2. Deliver User Story 1 to establish immutable tenant evidence snapshots.
|
||||
3. Deliver User Story 2 to expose completeness and freshness truth on tenant and workspace surfaces.
|
||||
4. Deliver User Story 3 to make review packs and future consumers depend on explicit snapshot resolution.
|
||||
5. Finish with contract alignment, focused tests, formatting, and manual smoke validation.
|
||||
|
||||
### Team Strategy
|
||||
|
||||
1. One engineer lands the schema, models, badges, capabilities, and collector contracts in Phase 2.
|
||||
2. A second engineer can prepare the US1 and US2 regression tests in parallel once the foundational types are clear.
|
||||
3. Review-pack integration can proceed as a separate stream after snapshot generation is stable.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch separate files and can be executed in parallel.
|
||||
- US1 is the recommended MVP because it establishes the immutable evidence package the later stories depend on.
|
||||
- Global search remains disabled for the new evidence resource unless a later iteration explicitly adds a compliant View/Edit search target.
|
||||
- No new panel provider registration is expected; Laravel 11+/12 provider registration remains in `bootstrap/providers.php` if discovery changes become necessary later.
|
||||
90
tests/Feature/Evidence/EvidenceOverviewPageTest.php
Normal file
90
tests/Feature/Evidence/EvidenceOverviewPageTest.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows only authorized tenant rows on the workspace evidence overview', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$foreignWorkspaceTenant = Tenant::factory()->create();
|
||||
|
||||
foreach ([
|
||||
[$tenantA, EvidenceCompletenessState::Complete->value],
|
||||
[$tenantB, EvidenceCompletenessState::Partial->value],
|
||||
[$foreignWorkspaceTenant, EvidenceCompletenessState::Missing->value],
|
||||
] as [$tenant, $state]) {
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => $state,
|
||||
'summary' => [
|
||||
'missing_dimensions' => $state === EvidenceCompletenessState::Missing->value ? 2 : 0,
|
||||
'stale_dimensions' => 0,
|
||||
],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview'))
|
||||
->assertOk()
|
||||
->assertSee($tenantA->name)
|
||||
->assertSee($tenantB->name)
|
||||
->assertDontSee($foreignWorkspaceTenant->name);
|
||||
});
|
||||
|
||||
it('returns 404 for users without workspace membership on the evidence overview', function (): void {
|
||||
$workspaceTenant = Tenant::factory()->create();
|
||||
$user = App\Models\User::factory()->create();
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
|
||||
->get(route('admin.evidence.overview'))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('applies the entitled tenant prefilter on the workspace evidence overview', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
foreach ([[$tenantA, EvidenceCompletenessState::Complete->value], [$tenantB, EvidenceCompletenessState::Partial->value]] as [$tenant, $state]) {
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => $state,
|
||||
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview', ['tenant_id' => (int) $tenantB->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantB))
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA));
|
||||
});
|
||||
27
tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php
Normal file
27
tests/Feature/Evidence/EvidenceSnapshotAuditLogTest.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('records audit entries when a snapshot is queued and expired', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$snapshot = app(App\Services\Evidence\EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
|
||||
$snapshot->update([
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
]);
|
||||
|
||||
app(App\Services\Evidence\EvidenceSnapshotService::class)->expire($snapshot, $user);
|
||||
|
||||
expect(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotCreated->value)->exists())->toBeTrue()
|
||||
->and(AuditLog::query()->where('action', AuditActionId::EvidenceSnapshotExpired->value)->exists())->toBeTrue();
|
||||
});
|
||||
338
tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
Normal file
338
tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
Normal file
@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedEvidenceDomain(Tenant $tenant): void
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'fingerprint' => hash('sha256', 'permission-'.$tenant->getKey()),
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => hash('sha256', 'entra-'.$tenant->getKey()),
|
||||
]);
|
||||
|
||||
Finding::factory()->count(2)->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
}
|
||||
|
||||
it('renders the evidence list page for an authorized user', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('returns 404 for non members on the evidence list route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for tenant members without evidence view capability on the evidence list route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
Gate::define(Capabilities::EVIDENCE_VIEW, fn (): bool => false);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('disables create snapshot for readonly members', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->assertActionVisible('create_snapshot')
|
||||
->assertActionDisabled('create_snapshot');
|
||||
});
|
||||
|
||||
it('queues snapshot generation from the list header action', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
seedEvidenceDomain($tenant);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->callAction('create_snapshot')
|
||||
->assertNotified();
|
||||
|
||||
expect(EvidenceSnapshot::query()->count())->toBe(1);
|
||||
Queue::assertPushed(GenerateEvidenceSnapshotJob::class);
|
||||
});
|
||||
|
||||
it('renders the view page for an active snapshot', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->assertOk();
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('refresh_snapshot')
|
||||
->assertActionVisible('expire_snapshot');
|
||||
});
|
||||
|
||||
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => [
|
||||
'finding_count' => 3,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 1,
|
||||
],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
EvidenceSnapshotItem::query()->create([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'summary_payload' => [
|
||||
'count' => 3,
|
||||
'open_count' => 2,
|
||||
'severity_counts' => [
|
||||
'critical' => 1,
|
||||
'high' => 1,
|
||||
'medium' => 1,
|
||||
'low' => 0,
|
||||
],
|
||||
'entries' => [
|
||||
[
|
||||
'title' => 'Admin consent missing',
|
||||
'severity' => 'critical',
|
||||
'status' => 'open',
|
||||
],
|
||||
],
|
||||
],
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
EvidenceSnapshotItem::query()->create([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'dimension_key' => 'entra_admin_roles',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'stored_report',
|
||||
'summary_payload' => [
|
||||
'role_count' => 2,
|
||||
'roles' => [
|
||||
['display_name' => 'Global Administrator'],
|
||||
['display_name' => 'Security Administrator'],
|
||||
],
|
||||
],
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
|
||||
EvidenceSnapshotItem::query()->create([
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'dimension_key' => 'operations_summary',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'operation_rollup',
|
||||
'summary_payload' => [
|
||||
'operation_count' => 3,
|
||||
'failed_count' => 0,
|
||||
'partial_count' => 0,
|
||||
'entries' => [
|
||||
[
|
||||
'type' => 'tenant.evidence.snapshot.generate',
|
||||
'outcome' => 'pending',
|
||||
'status' => 'running',
|
||||
],
|
||||
[
|
||||
'type' => 'tenant.review_pack.generate',
|
||||
'outcome' => 'succeeded',
|
||||
'status' => 'completed',
|
||||
],
|
||||
[
|
||||
'type' => 'backup_schedule_purge',
|
||||
'outcome' => 'pending',
|
||||
'status' => 'queued',
|
||||
],
|
||||
],
|
||||
],
|
||||
'sort_order' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSeeText('3 findings, 2 open.')
|
||||
->assertSeeText('Open findings')
|
||||
->assertSeeText('Admin consent missing')
|
||||
->assertSeeText('2 privileged Entra roles captured.')
|
||||
->assertSeeText('Global Administrator')
|
||||
->assertSeeText('Evidence snapshot generation · Running')
|
||||
->assertSeeText('Review pack generation · Completed')
|
||||
->assertSeeText('Backup schedule purge · Queued')
|
||||
->assertDontSeeText('Tenant.evidence.snapshot.generate · Pending · Running')
|
||||
->assertSeeText('Copy JSON');
|
||||
});
|
||||
|
||||
it('hides expire actions for expired snapshots on list and detail surfaces', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Expired->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now()->subDay(),
|
||||
'expires_at' => now(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->assertCanSeeTableRecords([$snapshot])
|
||||
->assertTableActionHidden('expire', $snapshot);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionHidden('expire_snapshot');
|
||||
});
|
||||
|
||||
it('disables refresh and expire actions for readonly members on snapshot detail', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||
->assertActionVisible('refresh_snapshot')
|
||||
->assertActionDisabled('refresh_snapshot')
|
||||
->assertActionVisible('expire_snapshot')
|
||||
->assertActionDisabled('expire_snapshot');
|
||||
});
|
||||
|
||||
it('keeps evidence list actions within the declared action surface contract', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 2, 'missing_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$livewire = Livewire::actingAs($user)
|
||||
->test(ListEvidenceSnapshots::class)
|
||||
->assertCanSeeTableRecords([$snapshot]);
|
||||
|
||||
$table = $livewire->instance()->getTable();
|
||||
$rowActions = $table->getActions();
|
||||
|
||||
$primaryRowActionNames = collect($rowActions)
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action->getName())
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire'])
|
||||
->and($table->getBulkActions())->toBeEmpty()
|
||||
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
||||
});
|
||||
102
tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php
Normal file
102
tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateEvidenceSnapshotJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedSnapshotInputs(Tenant $tenant): void
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'fingerprint' => hash('sha256', 'perm-'.$tenant->getKey()),
|
||||
]);
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'fingerprint' => hash('sha256', 'entra-'.$tenant->getKey()),
|
||||
]);
|
||||
Finding::factory()->count(2)->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
}
|
||||
|
||||
it('generates an active evidence snapshot from seeded inputs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedSnapshotInputs($tenant);
|
||||
|
||||
$snapshot = app(EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
|
||||
$job = new GenerateEvidenceSnapshotJob(
|
||||
snapshotId: (int) $snapshot->getKey(),
|
||||
operationRunId: (int) $snapshot->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$snapshot->refresh();
|
||||
$operationRun = OperationRun::query()->findOrFail($snapshot->operation_run_id);
|
||||
|
||||
expect($snapshot->status)->toBe(EvidenceSnapshotStatus::Active->value)
|
||||
->and($snapshot->items)->toHaveCount(5)
|
||||
->and($snapshot->fingerprint)->toBeString()
|
||||
->and($operationRun->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($operationRun->outcome)->toBe(OperationRunOutcome::Succeeded->value);
|
||||
});
|
||||
|
||||
it('reuses an unchanged active snapshot fingerprint instead of creating a duplicate', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedSnapshotInputs($tenant);
|
||||
|
||||
$first = app(EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
app()->call([new GenerateEvidenceSnapshotJob((int) $first->getKey(), (int) $first->operation_run_id), 'handle']);
|
||||
$first->refresh();
|
||||
|
||||
$second = app(EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
|
||||
expect((int) $second->getKey())->toBe((int) $first->getKey())
|
||||
->and(EvidenceSnapshot::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('supersedes the previous active snapshot when the evidence fingerprint changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
seedSnapshotInputs($tenant);
|
||||
|
||||
$first = app(EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
app()->call([new GenerateEvidenceSnapshotJob((int) $first->getKey(), (int) $first->operation_run_id), 'handle']);
|
||||
$first->refresh();
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'fingerprint' => Str::random(64),
|
||||
]);
|
||||
|
||||
$second = app(EvidenceSnapshotService::class)->generate($tenant, $user);
|
||||
app()->call([new GenerateEvidenceSnapshotJob((int) $second->getKey(), (int) $second->operation_run_id), 'handle']);
|
||||
|
||||
$first->refresh();
|
||||
$second->refresh();
|
||||
|
||||
expect($first->status)->toBe(EvidenceSnapshotStatus::Superseded->value)
|
||||
->and($second->status)->toBe(EvidenceSnapshotStatus::Active->value)
|
||||
->and((string) $first->fingerprint)->not->toBe((string) $second->fingerprint);
|
||||
});
|
||||
@ -22,10 +22,15 @@
|
||||
->toContain(AuditActionId::FindingReopened->value);
|
||||
});
|
||||
|
||||
it('does not expose direct finding lifecycle mutators', function (): void {
|
||||
expect(method_exists(Finding::class, 'acknowledge'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'resolve'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'reopen'))->toBeFalse();
|
||||
it('keeps only legacy compatibility lifecycle helpers on the model', function (): void {
|
||||
expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue()
|
||||
->and(method_exists(Finding::class, 'resolve'))->toBeTrue()
|
||||
->and(method_exists(Finding::class, 'reopen'))->toBeTrue()
|
||||
->and(method_exists(Finding::class, 'triage'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'startProgress'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'assign'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'close'))->toBeFalse()
|
||||
->and(method_exists(Finding::class, 'riskAccept'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rolls back finding workflow persistence when audit logging fails', function (): void {
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
@ -18,9 +20,12 @@
|
||||
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
||||
@ -159,6 +164,7 @@
|
||||
BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(),
|
||||
BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(),
|
||||
EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(),
|
||||
EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(),
|
||||
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
|
||||
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
|
||||
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
|
||||
@ -273,6 +279,42 @@
|
||||
expect($bulkGroup?->getLabel())->toBe('More');
|
||||
});
|
||||
|
||||
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListEvidenceSnapshots::class)
|
||||
->assertTableEmptyStateActionsExistInOrder(['create_first_snapshot']);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['finding_count' => 1, 'missing_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$livewire = Livewire::test(ListEvidenceSnapshots::class)
|
||||
->assertCanSeeTableRecords([$snapshot]);
|
||||
|
||||
$table = $livewire->instance()->getTable();
|
||||
$rowActions = $table->getActions();
|
||||
$primaryRowActionNames = collect($rowActions)
|
||||
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||
->map(static fn ($action): ?string => $action->getName())
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']);
|
||||
expect($table->getBulkActions())->toBeEmpty();
|
||||
expect($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
|
||||
});
|
||||
|
||||
it('uses canonical tenantless View run links on representative operation links', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$run = OperationRun::factory()->create([
|
||||
|
||||
@ -2,11 +2,18 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
it('returns 403 for a member without managed-tenant manage capability when accessing edit', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
@ -37,3 +44,66 @@
|
||||
->get('/admin/w/'.$workspace->slug.'/managed-tenants')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 403 for an in-scope tenant member without evidence view capability on the evidence list', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
Gate::define(Capabilities::EVIDENCE_VIEW, fn (): bool => false);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('returns 404 for a non-member attempting to access an evidence snapshot detail route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => [],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('suppresses non-entitled tenants from the workspace evidence overview', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantDenied = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantDenied, user: $user, role: 'owner');
|
||||
|
||||
Gate::define(Capabilities::EVIDENCE_VIEW, fn (User $actor, Tenant $tenant): bool => (int) $tenant->getKey() === (int) $tenantA->getKey());
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenantA->getKey(),
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenantDenied->getKey(),
|
||||
'workspace_id' => (int) $tenantDenied->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Missing->value,
|
||||
'summary' => ['missing_dimensions' => 2, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||
->get(route('admin.evidence.overview'))
|
||||
->assertOk()
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantA))
|
||||
->assertDontSee(EvidenceSnapshotResource::getUrl('index', tenant: $tenantDenied));
|
||||
});
|
||||
|
||||
@ -35,6 +35,41 @@
|
||||
->and($finding->resolved_reason)->toBeNull();
|
||||
});
|
||||
|
||||
it('supports legacy model helper compatibility for resolve and reopen', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding->resolve('permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
|
||||
$finding->reopen([
|
||||
'display_name' => 'Recovered Permission',
|
||||
'permission_key' => 'User.Read.All',
|
||||
]);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_NEW)
|
||||
->and($finding->resolved_at)->toBeNull()
|
||||
->and($finding->resolved_reason)->toBeNull()
|
||||
->and($finding->evidence_jsonb)->toMatchArray([
|
||||
'display_name' => 'Recovered Permission',
|
||||
'permission_key' => 'User.Read.All',
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports legacy model helper compatibility for acknowledge', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding->acknowledge($user);
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED)
|
||||
->and($finding->acknowledged_at)->not->toBeNull()
|
||||
->and($finding->acknowledged_by_user_id)->toBe($user->getKey());
|
||||
});
|
||||
|
||||
it('exposes v2 open and terminal status helpers', function (): void {
|
||||
expect(Finding::openStatuses())->toBe([
|
||||
Finding::STATUS_NEW,
|
||||
|
||||
@ -68,6 +68,72 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('serves hashed Livewire update requests for canonical run pages without a selected workspace', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
preg_match_all('/wire:snapshot="([^"]+)"/', (string) $response->getContent(), $snapshotMatches);
|
||||
|
||||
expect($snapshotMatches[1] ?? [])->not->toBeEmpty('No Livewire snapshots found in canonical run viewer HTML');
|
||||
|
||||
$updateUri = '/'.collect(app('router')->getRoutes()->getRoutes())
|
||||
->first(fn ($route): bool => str_contains((string) $route->getName(), 'livewire.update'))
|
||||
?->uri();
|
||||
|
||||
expect($updateUri)->toBeString();
|
||||
|
||||
$failures = [];
|
||||
|
||||
foreach ($snapshotMatches[1] as $encodedSnapshot) {
|
||||
$snapshot = htmlspecialchars_decode($encodedSnapshot);
|
||||
$decodedSnapshot = json_decode($snapshot, true);
|
||||
$componentName = data_get($decodedSnapshot, 'memo.name', 'unknown');
|
||||
|
||||
$updateResponse = $this->actingAs($user)
|
||||
->withHeaders([
|
||||
'X-Livewire' => 'true',
|
||||
'referer' => route('admin.operations.view', ['run' => (int) $run->getKey()]),
|
||||
])
|
||||
->postJson($updateUri, [
|
||||
'components' => [[
|
||||
'snapshot' => $snapshot,
|
||||
'updates' => new stdClass,
|
||||
'calls' => [],
|
||||
]],
|
||||
]);
|
||||
|
||||
if (! $updateResponse->isSuccessful()) {
|
||||
$failures[] = [
|
||||
'component' => $componentName,
|
||||
'status' => $updateResponse->getStatusCode(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
expect($failures)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns 403 for members missing the required capability for the operation type', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
|
||||
@ -50,4 +50,5 @@
|
||||
|
||||
expect($contents)->toContain('new MutationObserver');
|
||||
expect($contents)->toContain('teardownObserver');
|
||||
expect($contents)->not->toContain('wire:poll.5s="refreshRuns"');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
@ -11,7 +13,9 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
@ -106,6 +110,51 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
Finding::factory()
|
||||
->count(3)
|
||||
->create(['tenant_id' => (int) $tenant->getKey()]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
}
|
||||
|
||||
function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->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'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
// ─── Happy Path ──────────────────────────────────────────────
|
||||
@ -114,6 +163,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
$snapshot = createEvidenceSnapshotForReviewPack($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
@ -144,8 +194,9 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
expect($pack->expires_at)->not->toBeNull();
|
||||
expect($pack->fingerprint)->toBeString()->not->toBeEmpty();
|
||||
expect($pack->summary)->toBeArray();
|
||||
expect($pack->summary['finding_count'])->toBe(3);
|
||||
expect($pack->summary['finding_count'])->toBe(4);
|
||||
expect($pack->summary['report_count'])->toBe(2);
|
||||
expect($pack->evidence_snapshot_id)->toBe((int) $snapshot->getKey());
|
||||
|
||||
// File exists on disk
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
@ -164,6 +215,9 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
it('marks pack as failed when generation throws an exception', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
@ -205,27 +259,45 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
|
||||
// ─── Empty Reports ──────────────────────────────────────────────
|
||||
|
||||
it('succeeds with empty reports and findings', function (): void {
|
||||
it('fails explicitly when no evidence snapshot exists', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
expect(fn (): ReviewPack => $service->generate($tenant, $user))
|
||||
->toThrow(ReviewPackEvidenceResolutionException::class, 'No eligible evidence snapshot is available');
|
||||
});
|
||||
|
||||
$pack->refresh();
|
||||
it('fails explicitly when the latest evidence snapshot is ineligible', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value);
|
||||
expect($pack->summary['finding_count'])->toBe(0);
|
||||
expect($pack->summary['report_count'])->toBe(0);
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => 'missing',
|
||||
'summary' => [],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => 'missing',
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => Finding::class,
|
||||
'summary_payload' => [],
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
|
||||
expect(fn (): ReviewPack => $service->generate($tenant, $user))
|
||||
->toThrow(ReviewPackEvidenceResolutionException::class, 'latest evidence snapshot is not eligible');
|
||||
});
|
||||
|
||||
// ─── PII Redaction ──────────────────────────────────────────────
|
||||
@ -234,6 +306,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
@ -287,6 +360,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
Notification::fake();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
@ -339,6 +413,8 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -354,6 +430,8 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
Notification::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -368,6 +446,8 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -385,6 +465,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -403,6 +484,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -420,6 +502,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
$snapshot = createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -435,6 +518,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
$pack1->update([
|
||||
'status' => ReviewPackStatus::Ready->value,
|
||||
'fingerprint' => $fingerprint,
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
@ -450,6 +534,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
seedTenantWithData($tenant);
|
||||
$snapshot = createEvidenceSnapshotForReviewPack($tenant);
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
@ -463,6 +548,7 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
@ -472,3 +558,37 @@ function seedTenantWithData(Tenant $tenant): void
|
||||
expect(ReviewPack::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(2);
|
||||
expect($newPack->status)->toBe(ReviewPackStatus::Queued->value);
|
||||
});
|
||||
|
||||
it('builds the review pack from snapshot payloads instead of live records', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
seedTenantWithData($tenant);
|
||||
$snapshot = createEvidenceSnapshotForReviewPack($tenant);
|
||||
Notification::fake();
|
||||
|
||||
StoredReport::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
||||
Finding::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
||||
OperationRun::query()->where('tenant_id', (int) $tenant->getKey())->delete();
|
||||
|
||||
/** @var ReviewPackService $service */
|
||||
$service = app(ReviewPackService::class);
|
||||
$pack = $service->generate($tenant, $user, [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
]);
|
||||
|
||||
$job = new GenerateReviewPackJob(
|
||||
reviewPackId: (int) $pack->getKey(),
|
||||
operationRunId: (int) $pack->operation_run_id,
|
||||
);
|
||||
app()->call([$job, 'handle']);
|
||||
|
||||
$pack->refresh();
|
||||
|
||||
expect($pack->status)->toBe(ReviewPackStatus::Ready->value)
|
||||
->and($pack->evidence_snapshot_id)->toBe((int) $snapshot->getKey())
|
||||
->and($pack->summary['finding_count'])->toBe((int) $snapshot->summary['finding_count'])
|
||||
->and($pack->summary['report_count'])->toBe((int) $snapshot->summary['report_count']);
|
||||
|
||||
Storage::disk('exports')->assertExists($pack->file_path);
|
||||
});
|
||||
|
||||
@ -3,8 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\GenerateReviewPackJob;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\StoredReport;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -15,6 +20,66 @@
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
function createRedactionReviewPackSnapshot($tenant): EvidenceSnapshot
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => [
|
||||
'roles' => [
|
||||
['displayName' => 'Global Administrator'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->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'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
it('redacts protected report fields while preserving safe configuration evidence in review-pack exports', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
@ -27,6 +92,8 @@
|
||||
],
|
||||
]);
|
||||
|
||||
createRedactionReviewPackSnapshot($tenant);
|
||||
|
||||
Notification::fake();
|
||||
|
||||
$pack = app(ReviewPackService::class)->generate($tenant, $user, [
|
||||
|
||||
@ -5,11 +5,16 @@
|
||||
use App\Filament\Resources\ReviewPackResource;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
||||
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -23,6 +28,78 @@
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'required_count' => 1,
|
||||
'granted_count' => 1,
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => [
|
||||
'roles' => [
|
||||
['displayName' => 'Global Administrator'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->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'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
// ─── List Page ───────────────────────────────────────────────
|
||||
|
||||
it('renders the list page for an authorized user', function (): void {
|
||||
@ -103,6 +180,7 @@
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedReviewPackEvidence($tenant);
|
||||
|
||||
$fingerprint = app(ReviewPackService::class)->computeFingerprint($tenant, [
|
||||
'include_pii' => true,
|
||||
@ -113,6 +191,7 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'expires_at' => now()->addDay(),
|
||||
]);
|
||||
@ -137,6 +216,27 @@
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('does not queue generation when no eligible evidence snapshot exists', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ListReviewPacks::class)
|
||||
->callAction('generate_pack', [
|
||||
'include_pii' => true,
|
||||
'include_operations' => true,
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect(ReviewPack::query()->count())->toBe(0);
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
// ─── Table Row Actions ───────────────────────────────────────
|
||||
|
||||
it('shows the download action for a ready pack', function (): void {
|
||||
@ -194,11 +294,13 @@
|
||||
it('renders the view page for a ready pack', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedReviewPackEvidence($tenant);
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'summary' => [
|
||||
'finding_count' => 5,
|
||||
'report_count' => 2,
|
||||
@ -209,13 +311,20 @@
|
||||
'findings' => now()->toIso8601String(),
|
||||
'hardening' => now()->toIso8601String(),
|
||||
],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
'snapshot_fingerprint' => (string) $snapshot->fingerprint,
|
||||
],
|
||||
],
|
||||
'options' => ['include_pii' => true, 'include_operations' => true],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||
->assertOk();
|
||||
->assertOk()
|
||||
->assertSee('#'.$snapshot->getKey())
|
||||
->assertSee('resolved');
|
||||
});
|
||||
|
||||
it('shows download header action on view page for a ready pack', function (): void {
|
||||
|
||||
@ -3,8 +3,14 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -16,6 +22,68 @@
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => ['required_count' => 1, 'granted_count' => 1],
|
||||
]);
|
||||
|
||||
StoredReport::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
]);
|
||||
|
||||
Finding::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
]);
|
||||
|
||||
OperationRun::factory()->forTenant($tenant)->create();
|
||||
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->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'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
// ─── No Pack State ───────────────────────────────────────────
|
||||
|
||||
it('shows the generate CTA when no pack exists', function (): void {
|
||||
@ -148,6 +216,7 @@
|
||||
it('can trigger generatePack Livewire action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
seedWidgetReviewPackSnapshot($tenant);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
29
tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php
Normal file
29
tests/Unit/Evidence/EvidenceCompletenessEvaluatorTest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Evidence\EvidenceCompletenessEvaluator;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
it('applies missing stale partial precedence in order', function (array $items, string $expected): void {
|
||||
$result = app(EvidenceCompletenessEvaluator::class)->evaluate($items);
|
||||
|
||||
expect($result->value)->toBe($expected);
|
||||
})->with([
|
||||
'missing wins' => [[
|
||||
['state' => EvidenceCompletenessState::Complete->value, 'required' => true],
|
||||
['state' => EvidenceCompletenessState::Missing->value, 'required' => true],
|
||||
], EvidenceCompletenessState::Missing->value],
|
||||
'stale beats partial' => [[
|
||||
['state' => EvidenceCompletenessState::Partial->value, 'required' => true],
|
||||
['state' => EvidenceCompletenessState::Stale->value, 'required' => true],
|
||||
], EvidenceCompletenessState::Stale->value],
|
||||
'partial beats complete' => [[
|
||||
['state' => EvidenceCompletenessState::Partial->value, 'required' => true],
|
||||
['state' => EvidenceCompletenessState::Complete->value, 'required' => true],
|
||||
], EvidenceCompletenessState::Partial->value],
|
||||
'all complete' => [[
|
||||
['state' => EvidenceCompletenessState::Complete->value, 'required' => true],
|
||||
['state' => EvidenceCompletenessState::Complete->value, 'required' => true],
|
||||
], EvidenceCompletenessState::Complete->value],
|
||||
]);
|
||||
24
tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php
Normal file
24
tests/Unit/Evidence/EvidenceSnapshotBadgeTest.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
|
||||
it('maps all evidence snapshot statuses and completeness states to known badge specs', function (BadgeDomain $domain, string $value): void {
|
||||
$spec = BadgeCatalog::spec($domain, $value);
|
||||
|
||||
expect($spec->label)->not->toBe('Unknown')
|
||||
->and($spec->icon)->not->toBeNull();
|
||||
})->with([
|
||||
'queued status' => [BadgeDomain::EvidenceSnapshotStatus, 'queued'],
|
||||
'generating status' => [BadgeDomain::EvidenceSnapshotStatus, 'generating'],
|
||||
'active status' => [BadgeDomain::EvidenceSnapshotStatus, 'active'],
|
||||
'superseded status' => [BadgeDomain::EvidenceSnapshotStatus, 'superseded'],
|
||||
'expired status' => [BadgeDomain::EvidenceSnapshotStatus, 'expired'],
|
||||
'failed status' => [BadgeDomain::EvidenceSnapshotStatus, 'failed'],
|
||||
'complete completeness' => [BadgeDomain::EvidenceCompleteness, 'complete'],
|
||||
'partial completeness' => [BadgeDomain::EvidenceCompleteness, 'partial'],
|
||||
'missing completeness' => [BadgeDomain::EvidenceCompleteness, 'missing'],
|
||||
'stale completeness' => [BadgeDomain::EvidenceCompleteness, 'stale'],
|
||||
]);
|
||||
19
tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php
Normal file
19
tests/Unit/Evidence/EvidenceSnapshotFingerprintTest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Evidence\EvidenceSnapshotFingerprint;
|
||||
|
||||
it('creates stable fingerprints regardless of array key order', function (): void {
|
||||
$first = EvidenceSnapshotFingerprint::hash([
|
||||
'b' => ['z' => 1, 'a' => 2],
|
||||
'a' => 3,
|
||||
]);
|
||||
|
||||
$second = EvidenceSnapshotFingerprint::hash([
|
||||
'a' => 3,
|
||||
'b' => ['a' => 2, 'z' => 1],
|
||||
]);
|
||||
|
||||
expect($first)->toBe($second);
|
||||
});
|
||||
87
tests/Unit/Evidence/EvidenceSnapshotResolverTest.php
Normal file
87
tests/Unit/Evidence/EvidenceSnapshotResolverTest.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns missing snapshot when no active snapshot exists', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$result = app(EvidenceSnapshotResolver::class)->resolve(new EvidenceResolutionRequest(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
requiredDimensions: ['findings_summary'],
|
||||
));
|
||||
|
||||
expect($result->outcome)->toBe('missing_snapshot');
|
||||
});
|
||||
|
||||
it('resolves an eligible active snapshot', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => [],
|
||||
]);
|
||||
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => 'finding',
|
||||
'summary_payload' => [],
|
||||
]);
|
||||
|
||||
$result = app(EvidenceSnapshotResolver::class)->resolve(new EvidenceResolutionRequest(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
requiredDimensions: ['findings_summary'],
|
||||
));
|
||||
|
||||
expect($result->isResolved())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns snapshot ineligible when a required dimension is stale', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Stale->value,
|
||||
'summary' => [],
|
||||
]);
|
||||
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => 'findings_summary',
|
||||
'state' => EvidenceCompletenessState::Stale->value,
|
||||
'required' => true,
|
||||
'source_kind' => 'model_summary',
|
||||
'source_record_type' => 'finding',
|
||||
'summary_payload' => [],
|
||||
]);
|
||||
|
||||
$result = app(EvidenceSnapshotResolver::class)->resolve(new EvidenceResolutionRequest(
|
||||
workspaceId: (int) $tenant->workspace_id,
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
requiredDimensions: ['findings_summary'],
|
||||
));
|
||||
|
||||
expect($result->outcome)->toBe('snapshot_ineligible');
|
||||
});
|
||||
@ -14,7 +14,13 @@
|
||||
'external_id' => 'b0091e5d-944f-4a34-bcd9-12cbfb7b75cf',
|
||||
]);
|
||||
|
||||
$request = Request::create('/livewire/update', 'POST');
|
||||
$updateUri = '/'.collect(app('router')->getRoutes()->getRoutes())
|
||||
->first(fn ($route): bool => str_contains((string) $route->getName(), 'livewire.update'))
|
||||
?->uri();
|
||||
|
||||
expect($updateUri)->toBeString();
|
||||
|
||||
$request = Request::create($updateUri, 'POST');
|
||||
$request->headers->set('x-livewire', '1');
|
||||
$request->headers->set('referer', "http://localhost/admin/tenants/{$tenant->external_id}/provider-connections/1/edit");
|
||||
app()->instance('request', $request);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user