Compare commits
3 Commits
204-platfo
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4699f13a72 | |||
| bb72a54e84 | |||
| ad16eee591 |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -184,6 +184,8 @@ ## Active Technologies
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, `operation_runs`, findings, and baseline scope JSON; no new top-level tables planned (203-baseline-compare-strategy)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces (204-platform-core-vocabulary-hardening)
|
||||
- PostgreSQL via existing `operation_runs.type`, `operation_runs.context`, `baseline_profiles.scope_jsonb`, `baseline_snapshot_items`, findings, evidence payloads, and current config-backed registries; no new top-level tables planned (204-platform-core-vocabulary-hardening)
|
||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services (205-compare-job-cleanup)
|
||||
- PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned (205-compare-job-cleanup)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -218,8 +220,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 205-compare-job-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
|
||||
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -20,14 +20,25 @@
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class EvidenceOverview extends Page
|
||||
class EvidenceOverview extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -45,7 +56,12 @@ class EvidenceOverview extends Page
|
||||
*/
|
||||
public array $rows = [];
|
||||
|
||||
public ?int $tenantFilter = null;
|
||||
/**
|
||||
* @var array<int, Tenant>|null
|
||||
*/
|
||||
private ?array $accessibleTenants = null;
|
||||
|
||||
private ?Collection $cachedSnapshots = null;
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
@ -58,6 +74,134 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorizeWorkspaceAccess();
|
||||
$this->seedTableStateFromQuery();
|
||||
$this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all();
|
||||
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('tenant_name')
|
||||
->defaultPaginationPageOption(25)
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->searchable()
|
||||
->searchPlaceholder('Search tenant or next step')
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$rows = $this->rowsForState($filters, $search);
|
||||
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
|
||||
|
||||
return $this->paginateRows($rows, $page, $recordsPerPage);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('tenant_id')
|
||||
->label('Tenant')
|
||||
->options(fn (): array => $this->tenantFilterOptions())
|
||||
->searchable(),
|
||||
])
|
||||
->columns([
|
||||
TextColumn::make('tenant_name')
|
||||
->label('Tenant')
|
||||
->sortable(),
|
||||
TextColumn::make('artifact_truth_label')
|
||||
->label('Artifact truth')
|
||||
->badge()
|
||||
->color(fn (array $record): string => (string) ($record['artifact_truth_color'] ?? 'gray'))
|
||||
->icon(fn (array $record): ?string => is_string($record['artifact_truth_icon'] ?? null) ? $record['artifact_truth_icon'] : null)
|
||||
->description(fn (array $record): ?string => is_string($record['artifact_truth_explanation'] ?? null) ? $record['artifact_truth_explanation'] : null)
|
||||
->sortable()
|
||||
->wrap(),
|
||||
TextColumn::make('freshness_label')
|
||||
->label('Freshness')
|
||||
->badge()
|
||||
->color(fn (array $record): string => (string) ($record['freshness_color'] ?? 'gray'))
|
||||
->icon(fn (array $record): ?string => is_string($record['freshness_icon'] ?? null) ? $record['freshness_icon'] : null)
|
||||
->sortable(),
|
||||
TextColumn::make('generated_at')
|
||||
->label('Generated')
|
||||
->placeholder('—')
|
||||
->sortable(),
|
||||
TextColumn::make('missing_dimensions')
|
||||
->label('Not collected yet')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('stale_dimensions')
|
||||
->label('Refresh recommended')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('next_step')
|
||||
->label('Next step')
|
||||
->wrap(),
|
||||
])
|
||||
->recordUrl(fn ($record): ?string => is_array($record) ? (is_string($record['view_url'] ?? null) ? $record['view_url'] : null) : null)
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No evidence snapshots in this scope')
|
||||
->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters()
|
||||
? 'Clear the current filters to return to the full workspace evidence overview.'
|
||||
: 'Adjust filters or create a tenant snapshot to populate the workspace overview.')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveOverviewFilters())
|
||||
->action(fn (): mixed => $this->clearOverviewFilters()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActiveOverviewFilters())
|
||||
->action(fn (): mixed => $this->clearOverviewFilters()),
|
||||
];
|
||||
}
|
||||
|
||||
public function clearOverviewFilters(): void
|
||||
{
|
||||
$this->tableFilters = [
|
||||
'tenant_id' => ['value' => null],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
$this->tableSearch = '';
|
||||
$this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all();
|
||||
|
||||
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
|
||||
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
|
||||
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
|
||||
return $fresh
|
||||
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||
: $presenter->forEvidenceSnapshot($snapshot);
|
||||
}
|
||||
|
||||
private function authorizeWorkspaceAccess(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -65,35 +209,127 @@ public function mount(): void
|
||||
throw new AuthenticationException;
|
||||
}
|
||||
|
||||
$workspaceContext = app(WorkspaceContext::class);
|
||||
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request());
|
||||
$workspaceId = (int) $workspace->getKey();
|
||||
app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
|
||||
}
|
||||
|
||||
$accessibleTenants = $user->tenants()
|
||||
/**
|
||||
* @return array<int, Tenant>
|
||||
*/
|
||||
private function accessibleTenants(): array
|
||||
{
|
||||
if (is_array($this->accessibleTenants)) {
|
||||
return $this->accessibleTenants;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return $this->accessibleTenants = [];
|
||||
}
|
||||
|
||||
$workspaceId = $this->workspaceId();
|
||||
|
||||
return $this->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();
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
$this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null;
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function tenantFilterOptions(): array
|
||||
{
|
||||
return collect($this->accessibleTenants())
|
||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
||||
(string) $tenant->getKey() => $tenant->name,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
$tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all();
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function rowsForState(array $filters = [], ?string $search = null): Collection
|
||||
{
|
||||
$rows = $this->baseRows();
|
||||
$tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value'));
|
||||
$normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch)));
|
||||
|
||||
if ($tenantFilter !== null) {
|
||||
$rows = $rows->where('tenant_id', $tenantFilter);
|
||||
}
|
||||
|
||||
if ($normalizedSearch === '') {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
return $rows->filter(function (array $row) use ($normalizedSearch): bool {
|
||||
$haystack = implode(' ', [
|
||||
(string) ($row['tenant_name'] ?? ''),
|
||||
(string) ($row['artifact_truth_label'] ?? ''),
|
||||
(string) ($row['artifact_truth_explanation'] ?? ''),
|
||||
(string) ($row['freshness_label'] ?? ''),
|
||||
(string) ($row['next_step'] ?? ''),
|
||||
]);
|
||||
|
||||
return str_contains(Str::lower($haystack), $normalizedSearch);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function baseRows(): Collection
|
||||
{
|
||||
$snapshots = $this->latestAccessibleSnapshots();
|
||||
$currentReviewTenantIds = $this->currentReviewTenantIds($snapshots);
|
||||
|
||||
return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
||||
return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, EvidenceSnapshot>
|
||||
*/
|
||||
private function latestAccessibleSnapshots(): Collection
|
||||
{
|
||||
if ($this->cachedSnapshots instanceof Collection) {
|
||||
return $this->cachedSnapshots;
|
||||
}
|
||||
|
||||
$tenantIds = collect($this->accessibleTenants())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
$query = EvidenceSnapshot::query()
|
||||
->with('tenant')
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('tenant_id', $tenantIds)
|
||||
->where('workspace_id', $this->workspaceId())
|
||||
->where('status', 'active')
|
||||
->latest('generated_at');
|
||||
|
||||
if ($this->tenantFilter !== null) {
|
||||
$query->where('tenant_id', $this->tenantFilter);
|
||||
if ($tenantIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->whereIn('tenant_id', $tenantIds);
|
||||
}
|
||||
|
||||
$snapshots = $query->get()->unique('tenant_id')->values();
|
||||
$currentReviewTenantIds = TenantReview::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EvidenceSnapshot> $snapshots
|
||||
* @return array<int, bool>
|
||||
*/
|
||||
private function currentReviewTenantIds(Collection $snapshots): array
|
||||
{
|
||||
return TenantReview::query()
|
||||
->where('workspace_id', $this->workspaceId())
|
||||
->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
|
||||
->whereIn('status', [
|
||||
TenantReviewStatus::Draft->value,
|
||||
@ -103,8 +339,14 @@ public function mount(): void
|
||||
->pluck('tenant_id')
|
||||
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
|
||||
->all();
|
||||
}
|
||||
|
||||
$this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array {
|
||||
/**
|
||||
* @param array<int, bool> $currentReviewTenantIds
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array
|
||||
{
|
||||
$truth = $this->snapshotTruth($snapshot);
|
||||
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
|
||||
$tenantId = (int) $snapshot->tenant_id;
|
||||
@ -117,16 +359,22 @@ public function mount(): void
|
||||
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
|
||||
'tenant_id' => $tenantId,
|
||||
'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)),
|
||||
'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
|
||||
'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0),
|
||||
'artifact_truth_label' => $truth->primaryLabel,
|
||||
'artifact_truth_color' => $truth->primaryBadgeSpec()->color,
|
||||
'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon,
|
||||
'artifact_truth_explanation' => $truth->primaryExplanation,
|
||||
'artifact_truth' => [
|
||||
'label' => $truth->primaryLabel,
|
||||
'color' => $truth->primaryBadgeSpec()->color,
|
||||
'icon' => $truth->primaryBadgeSpec()->icon,
|
||||
'explanation' => $truth->primaryExplanation,
|
||||
],
|
||||
'freshness_label' => $freshnessSpec->label,
|
||||
'freshness_color' => $freshnessSpec->color,
|
||||
'freshness_icon' => $freshnessSpec->icon,
|
||||
'freshness' => [
|
||||
'label' => $freshnessSpec->label,
|
||||
'color' => $freshnessSpec->color,
|
||||
@ -137,29 +385,105 @@ public function mount(): void
|
||||
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
|
||||
: null,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
return [
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->tenantFilter !== null)
|
||||
->url(route('admin.evidence.overview')),
|
||||
];
|
||||
$sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
|
||||
? $sortColumn
|
||||
: 'tenant_name';
|
||||
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
||||
$comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true)
|
||||
? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0))
|
||||
: strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? ''));
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? ''));
|
||||
}
|
||||
|
||||
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
||||
{
|
||||
$presenter = app(ArtifactTruthPresenter::class);
|
||||
return $descending ? ($comparison * -1) : $comparison;
|
||||
});
|
||||
|
||||
return $fresh
|
||||
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
||||
: $presenter->forEvidenceSnapshot($snapshot);
|
||||
return collect($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
return new LengthAwarePaginator(
|
||||
items: $rows->forPage($page, $recordsPerPage),
|
||||
total: $rows->count(),
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
|
||||
private function seedTableStateFromQuery(): void
|
||||
{
|
||||
$query = request()->query();
|
||||
|
||||
if (array_key_exists('search', $query)) {
|
||||
$this->tableSearch = trim((string) request()->query('search', ''));
|
||||
}
|
||||
|
||||
if (! array_key_exists('tenant_id', $query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
|
||||
|
||||
if ($tenantFilter === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tableFilters = [
|
||||
'tenant_id' => ['value' => (string) $tenantFilter],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
}
|
||||
|
||||
private function normalizeTenantFilter(mixed $value): ?int
|
||||
{
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$requestedTenantId = (int) $value;
|
||||
$allowedTenantIds = collect($this->accessibleTenants())
|
||||
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||
->all();
|
||||
|
||||
return in_array($requestedTenantId, $allowedTenantIds, true)
|
||||
? $requestedTenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
private function hasActiveOverviewFilters(): bool
|
||||
{
|
||||
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|
||||
|| trim((string) $this->tableSearch) !== '';
|
||||
}
|
||||
|
||||
private function workspaceId(): int
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new AuthenticationException;
|
||||
}
|
||||
|
||||
return (int) app(WorkspaceContext::class)
|
||||
->currentWorkspaceForMemberOrFail($user, request())
|
||||
->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,16 +10,30 @@
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TenantRequiredPermissions extends Page
|
||||
class TenantRequiredPermissions extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
@ -40,25 +54,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.');
|
||||
}
|
||||
|
||||
public string $status = 'missing';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $features = [];
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $viewModel = [];
|
||||
|
||||
#[Locked]
|
||||
public ?int $scopedTenantId = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $cachedViewModel = null;
|
||||
|
||||
private ?string $cachedViewModelStateKey = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return static::hasScopedTenantAccess(static::resolveScopedTenant());
|
||||
@ -69,9 +74,9 @@ public function currentTenant(): ?Tenant
|
||||
return $this->trustedScopedTenant();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
public function mount(Tenant|string|null $tenant = null): void
|
||||
{
|
||||
$tenant = static::resolveScopedTenant();
|
||||
$tenant = static::resolveScopedTenant($tenant);
|
||||
|
||||
if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
|
||||
abort(404);
|
||||
@ -81,109 +86,120 @@ public function mount(): void
|
||||
$this->heading = $tenant->getFilamentName();
|
||||
$this->subheading = 'Required permissions';
|
||||
|
||||
$queryFeatures = request()->query('features', $this->features);
|
||||
$this->seedTableStateFromQuery();
|
||||
$this->mountInteractsWithTable();
|
||||
}
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => request()->query('status', $this->status),
|
||||
'type' => request()->query('type', $this->type),
|
||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||
'search' => request()->query('search', $this->search),
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('sort_priority')
|
||||
->defaultPaginationPageOption(25)
|
||||
->paginated(TablePaginationProfiles::customPage())
|
||||
->persistFiltersInSession()
|
||||
->persistSearchInSession()
|
||||
->persistSortInSession()
|
||||
->searchable()
|
||||
->searchPlaceholder('Search permission key or description…')
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$state = $this->filterState(filters: $filters, search: $search);
|
||||
$rows = $this->permissionRowsForState($state);
|
||||
$rows = $this->sortPermissionRows($rows, $sortColumn, $sortDirection);
|
||||
|
||||
return $this->paginatePermissionRows($rows, $page, $recordsPerPage);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label('Status')
|
||||
->default('missing')
|
||||
->options([
|
||||
'missing' => 'Missing',
|
||||
'present' => 'Present',
|
||||
'all' => 'All',
|
||||
]),
|
||||
SelectFilter::make('type')
|
||||
->label('Type')
|
||||
->default('all')
|
||||
->options([
|
||||
'all' => 'All',
|
||||
'application' => 'Application',
|
||||
'delegated' => 'Delegated',
|
||||
]),
|
||||
SelectFilter::make('features')
|
||||
->label('Features')
|
||||
->multiple()
|
||||
->options(fn (): array => $this->featureFilterOptions())
|
||||
->searchable(),
|
||||
])
|
||||
->columns([
|
||||
TextColumn::make('key')
|
||||
->label('Permission')
|
||||
->description(fn (array $record): ?string => is_string($record['description'] ?? null) ? $record['description'] : null)
|
||||
->wrap()
|
||||
->sortable(),
|
||||
TextColumn::make('type_label')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus))
|
||||
->sortable(),
|
||||
TextColumn::make('features_label')
|
||||
->label('Features')
|
||||
->wrap()
|
||||
->toggleable(),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading(fn (): string => $this->permissionsEmptyStateHeading())
|
||||
->emptyStateDescription(fn (): string => $this->permissionsEmptyStateDescription())
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
->label('Clear filters')
|
||||
->icon('heroicon-o-x-mark')
|
||||
->color('gray')
|
||||
->visible(fn (): bool => $this->hasActivePermissionFilters())
|
||||
->action(fn (): mixed => $this->clearPermissionFilters()),
|
||||
]);
|
||||
|
||||
$this->status = $state['status'];
|
||||
$this->type = $state['type'];
|
||||
$this->features = $state['features'];
|
||||
$this->search = $state['search'];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function updatedStatus(): void
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function viewModel(): array
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
return $this->viewModelForState($this->filterState());
|
||||
}
|
||||
|
||||
public function updatedType(): void
|
||||
public function clearPermissionFilters(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
$this->tableFilters = [
|
||||
'status' => ['value' => 'missing'],
|
||||
'type' => ['value' => 'all'],
|
||||
'features' => ['values' => []],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
$this->tableSearch = '';
|
||||
$this->cachedViewModel = null;
|
||||
$this->cachedViewModelStateKey = null;
|
||||
|
||||
public function updatedFeatures(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
|
||||
session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function applyFeatureFilter(string $feature): void
|
||||
{
|
||||
$feature = trim($feature);
|
||||
|
||||
if ($feature === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($feature, $this->features, true)) {
|
||||
$this->features = array_values(array_filter(
|
||||
$this->features,
|
||||
static fn (string $value): bool => $value !== $feature,
|
||||
));
|
||||
} else {
|
||||
$this->features[] = $feature;
|
||||
}
|
||||
|
||||
$this->features = array_values(array_unique($this->features));
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function clearFeatureFilter(): void
|
||||
{
|
||||
$this->features = [];
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->status = 'missing';
|
||||
$this->type = 'all';
|
||||
$this->features = [];
|
||||
$this->search = '';
|
||||
|
||||
$this->refreshViewModel();
|
||||
}
|
||||
|
||||
private function refreshViewModel(): void
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$this->viewModel = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||
|
||||
$this->viewModel = $builder->build($tenant, [
|
||||
'status' => $this->status,
|
||||
'type' => $this->type,
|
||||
'features' => $this->features,
|
||||
'search' => $this->search,
|
||||
]);
|
||||
|
||||
$filters = $this->viewModel['filters'] ?? null;
|
||||
|
||||
if (is_array($filters)) {
|
||||
$this->status = (string) ($filters['status'] ?? $this->status);
|
||||
$this->type = (string) ($filters['type'] ?? $this->type);
|
||||
$this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features;
|
||||
$this->search = (string) ($filters['search'] ?? $this->search);
|
||||
}
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function reRunVerificationUrl(): string
|
||||
@ -208,8 +224,18 @@ public function manageProviderConnectionUrl(): ?string
|
||||
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin');
|
||||
}
|
||||
|
||||
protected static function resolveScopedTenant(): ?Tenant
|
||||
protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant
|
||||
{
|
||||
if ($tenant instanceof Tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
if (is_string($tenant) && $tenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $tenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
$routeTenant = request()->route('tenant');
|
||||
|
||||
if ($routeTenant instanceof Tenant) {
|
||||
@ -222,6 +248,14 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
->first();
|
||||
}
|
||||
|
||||
$queryTenant = request()->query('tenant');
|
||||
|
||||
if (is_string($queryTenant) && $queryTenant !== '') {
|
||||
return Tenant::query()
|
||||
->where('external_id', $queryTenant)
|
||||
->first();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -293,4 +327,216 @@ private function trustedScopedTenant(): ?Tenant
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string}
|
||||
*/
|
||||
private function filterState(array $filters = [], ?string $search = null): array
|
||||
{
|
||||
return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'),
|
||||
'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'),
|
||||
'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []),
|
||||
'search' => $search ?? $this->tableSearch,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array<int, string>,search:string} $state
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function viewModelForState(array $state): array
|
||||
{
|
||||
$tenant = $this->trustedScopedTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stateKey = json_encode([$tenant->getKey(), $state]);
|
||||
|
||||
if ($this->cachedViewModelStateKey === $stateKey && is_array($this->cachedViewModel)) {
|
||||
return $this->cachedViewModel;
|
||||
}
|
||||
|
||||
$builder = app(TenantRequiredPermissionsViewModelBuilder::class);
|
||||
|
||||
$this->cachedViewModelStateKey = $stateKey ?: null;
|
||||
$this->cachedViewModel = $builder->build($tenant, $state);
|
||||
|
||||
return $this->cachedViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function permissionRowsForState(array $state): Collection
|
||||
{
|
||||
return collect($this->viewModelForState($state)['permissions'] ?? [])
|
||||
->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null))
|
||||
->mapWithKeys(function (array $row): array {
|
||||
$key = (string) $row['key'];
|
||||
|
||||
return [
|
||||
$key => [
|
||||
'key' => $key,
|
||||
'description' => is_string($row['description'] ?? null) ? $row['description'] : null,
|
||||
'type' => (string) ($row['type'] ?? 'application'),
|
||||
'type_label' => ($row['type'] ?? 'application') === 'delegated' ? 'Delegated' : 'Application',
|
||||
'status' => (string) ($row['status'] ?? 'missing'),
|
||||
'features_label' => implode(', ', array_filter((array) ($row['features'] ?? []), 'is_string')),
|
||||
'sort_priority' => $this->statusSortWeight((string) ($row['status'] ?? 'missing')),
|
||||
],
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function sortPermissionRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
$sortColumn = in_array($sortColumn, ['sort_priority', 'key', 'type_label', 'status', 'features_label'], true)
|
||||
? $sortColumn
|
||||
: 'sort_priority';
|
||||
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
||||
$comparison = match ($sortColumn) {
|
||||
'sort_priority' => ((int) ($left['sort_priority'] ?? 0)) <=> ((int) ($right['sort_priority'] ?? 0)),
|
||||
default => strnatcasecmp(
|
||||
(string) ($left[$sortColumn] ?? ''),
|
||||
(string) ($right[$sortColumn] ?? ''),
|
||||
),
|
||||
};
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left['key'] ?? ''),
|
||||
(string) ($right['key'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return $descending ? ($comparison * -1) : $comparison;
|
||||
});
|
||||
|
||||
return collect($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginatePermissionRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
return new LengthAwarePaginator(
|
||||
items: $rows->forPage($page, $recordsPerPage),
|
||||
total: $rows->count(),
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function featureFilterOptions(): array
|
||||
{
|
||||
return collect(data_get($this->viewModelForState([
|
||||
'status' => 'all',
|
||||
'type' => 'all',
|
||||
'features' => [],
|
||||
'search' => '',
|
||||
]), 'overview.feature_impacts', []))
|
||||
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
|
||||
->mapWithKeys(fn (array $impact): array => [
|
||||
(string) $impact['feature'] => (string) $impact['feature'],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function permissionsEmptyStateHeading(): string
|
||||
{
|
||||
$viewModel = $this->viewModel();
|
||||
$counts = is_array(data_get($viewModel, 'overview.counts')) ? data_get($viewModel, 'overview.counts') : [];
|
||||
$state = $this->filterState();
|
||||
$allPermissions = data_get($this->viewModelForState([
|
||||
'status' => 'all',
|
||||
'type' => 'all',
|
||||
'features' => [],
|
||||
'search' => '',
|
||||
]), 'permissions', []);
|
||||
|
||||
$missingTotal = (int) ($counts['missing_application'] ?? 0)
|
||||
+ (int) ($counts['missing_delegated'] ?? 0)
|
||||
+ (int) ($counts['error'] ?? 0);
|
||||
$requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0);
|
||||
|
||||
if (! is_array($allPermissions) || $allPermissions === []) {
|
||||
return 'No permissions configured';
|
||||
}
|
||||
|
||||
if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') {
|
||||
return 'All required permissions are present';
|
||||
}
|
||||
|
||||
return 'No matches';
|
||||
}
|
||||
|
||||
private function permissionsEmptyStateDescription(): string
|
||||
{
|
||||
return match ($this->permissionsEmptyStateHeading()) {
|
||||
'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.',
|
||||
'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.',
|
||||
default => 'No permissions match the current filters.',
|
||||
};
|
||||
}
|
||||
|
||||
private function hasActivePermissionFilters(): bool
|
||||
{
|
||||
$state = $this->filterState();
|
||||
|
||||
return $state['status'] !== 'missing'
|
||||
|| $state['type'] !== 'all'
|
||||
|| $state['features'] !== []
|
||||
|| trim($state['search']) !== '';
|
||||
}
|
||||
|
||||
private function seedTableStateFromQuery(): void
|
||||
{
|
||||
$query = request()->query();
|
||||
|
||||
if (! array_key_exists('status', $query) && ! array_key_exists('type', $query) && ! array_key_exists('features', $query) && ! array_key_exists('search', $query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryFeatures = request()->query('features', []);
|
||||
|
||||
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([
|
||||
'status' => request()->query('status', 'missing'),
|
||||
'type' => request()->query('type', 'all'),
|
||||
'features' => is_array($queryFeatures) ? $queryFeatures : [],
|
||||
'search' => request()->query('search', ''),
|
||||
]);
|
||||
|
||||
$this->tableFilters = [
|
||||
'status' => ['value' => $state['status']],
|
||||
'type' => ['value' => $state['type']],
|
||||
'features' => ['values' => $state['features']],
|
||||
];
|
||||
$this->tableDeferredFilters = $this->tableFilters;
|
||||
$this->tableSearch = $state['search'];
|
||||
}
|
||||
|
||||
private function statusSortWeight(string $status): int
|
||||
{
|
||||
return match ($status) {
|
||||
'missing' => 0,
|
||||
'error' => 1,
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,14 +10,11 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
@ -179,29 +176,6 @@ public static function infolist(Schema $schema): Schema
|
||||
ViewEntry::make('dependencies')
|
||||
->label('')
|
||||
->view('filament.components.dependency-edges')
|
||||
->state(function (InventoryItem $record) {
|
||||
$direction = request()->query('direction', 'all');
|
||||
$relationshipType = request()->query('relationship_type', 'all');
|
||||
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
|
||||
|
||||
$relationshipType = $relationshipType === 'all'
|
||||
? null
|
||||
: RelationshipType::tryFrom($relationshipType)?->value;
|
||||
|
||||
$service = app(DependencyQueryService::class);
|
||||
$resolver = app(DependencyTargetResolver::class);
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
|
||||
}
|
||||
if ($direction === 'outbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
|
||||
}
|
||||
|
||||
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -21,19 +20,13 @@
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
|
||||
use App\Services\Baselines\Evidence\ContentEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||
use App\Services\Baselines\Evidence\MetaEvidenceProvider;
|
||||
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
@ -76,11 +69,6 @@ class CompareBaselineToTenantJob implements ShouldQueue
|
||||
|
||||
public bool $failOnTimeout = true;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $baselineContentHashCache = [];
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
@ -825,7 +813,7 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
|
||||
* captured_versions?: array<string, array{
|
||||
* policy_type: string,
|
||||
* subject_external_id: string,
|
||||
* version: PolicyVersion,
|
||||
* version: \App\Models\PolicyVersion,
|
||||
* observed_at: string,
|
||||
* observed_operation_run_id: ?int
|
||||
* }>
|
||||
@ -855,7 +843,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
|
||||
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
|
||||
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : null;
|
||||
|
||||
if (! $version instanceof PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
|
||||
if (! $version instanceof \App\Models\PolicyVersion || $subjectExternalId === '' || ! is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -870,6 +858,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
|
||||
private function completeWithCoverageWarning(
|
||||
OperationRunService $operationRunService,
|
||||
AuditLogger $auditLogger,
|
||||
@ -1423,750 +1412,6 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
|
||||
return $effectiveScope->allTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare baseline items vs current inventory and produce drift results.
|
||||
*
|
||||
* @param array<string, array{subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, meta_jsonb: array<string, mixed>}> $baselineItems
|
||||
* @param array<string, array{subject_external_id: string, subject_key: string, policy_type: string, meta_jsonb: array<string, mixed>}> $currentItems
|
||||
* @param array<string, ResolvedEvidence|null> $resolvedCurrentEvidence
|
||||
* @param array<string, string> $severityMapping
|
||||
* @return array{
|
||||
* drift: array<int, array{change_type: string, severity: string, evidence_fidelity: string, subject_type: string, subject_external_id: string, subject_key: string, policy_type: string, baseline_hash: string, current_hash: string, evidence: array<string, mixed>}>,
|
||||
* evidence_gaps: array<string, int>,
|
||||
* rbac_role_definitions: array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
* }
|
||||
*/
|
||||
private function computeDrift(
|
||||
Tenant $tenant,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
array $baselineItems,
|
||||
array $currentItems,
|
||||
array $resolvedCurrentEvidence,
|
||||
array $severityMapping,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): array {
|
||||
$drift = [];
|
||||
$evidenceGaps = [];
|
||||
$evidenceGapSubjects = [];
|
||||
$rbacRoleDefinitionSummary = $this->emptyRbacRoleDefinitionSummary();
|
||||
$roleDefinitionNormalizer = app(IntuneRoleDefinitionNormalizer::class);
|
||||
|
||||
$baselinePlaceholderProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: null,
|
||||
);
|
||||
|
||||
$currentMissingProvenance = EvidenceProvenance::build(
|
||||
fidelity: EvidenceProvenance::FidelityMeta,
|
||||
source: EvidenceProvenance::SourceInventory,
|
||||
observedAt: null,
|
||||
observedOperationRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
foreach ($baselineItems as $key => $baselineItem) {
|
||||
$currentItem = $currentItems[$key] ?? null;
|
||||
|
||||
$policyType = (string) ($baselineItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($baselineItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$baselineProvenance = $this->baselineProvenanceFromMetaJsonb($baselineItem['meta_jsonb'] ?? []);
|
||||
$baselinePolicyVersionId = $this->resolveBaselinePolicyVersionId(
|
||||
tenant: $tenant,
|
||||
baselineItem: $baselineItem,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
||||
);
|
||||
$baselineComparableHash = $this->effectiveBaselineHash(
|
||||
tenant: $tenant,
|
||||
baselineItem: $baselineItem,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
contentEvidenceProvider: $contentEvidenceProvider,
|
||||
);
|
||||
|
||||
if (! is_array($currentItem)) {
|
||||
if ($isRbacRoleDefinition && $baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayName = $baselineItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'missing_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: null,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentMissingProvenance,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: null,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: [],
|
||||
diffKind: 'missing',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['missing']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'missing_policy',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_HIGH
|
||||
: $this->severityForChangeType($severityMapping, 'missing_policy'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $baselineItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => '',
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($baselineComparableHash !== $currentEvidence->hash) {
|
||||
$displayName = $currentItem['meta_jsonb']['display_name']
|
||||
?? ($baselineItem['meta_jsonb']['display_name'] ?? null);
|
||||
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
$roleDefinitionDiff = null;
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_baseline_version_reference'] = ($evidenceGaps['missing_role_definition_baseline_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_baseline_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$roleDefinitionDiff = $this->resolveRoleDefinitionDiff(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
normalizer: $roleDefinitionNormalizer,
|
||||
);
|
||||
|
||||
if ($roleDefinitionDiff === null) {
|
||||
$evidenceGaps['missing_role_definition_compare_surface'] = ($evidenceGaps['missing_role_definition_compare_surface'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_compare_surface'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$summaryKind = $isRbacRoleDefinition
|
||||
? 'rbac_role_definition'
|
||||
: $this->selectSummaryKind(
|
||||
tenant: $tenant,
|
||||
policyType: $policyType,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
hasher: $hasher,
|
||||
settingsNormalizer: $settingsNormalizer,
|
||||
assignmentsNormalizer: $assignmentsNormalizer,
|
||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
||||
);
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'different_version',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: $baselineComparableHash,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselineProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: $summaryKind,
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition && is_array($roleDefinitionDiff)) {
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: $baselinePolicyVersionId,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: (string) $roleDefinitionDiff['diff_kind'],
|
||||
roleDefinitionDiff: $roleDefinitionDiff,
|
||||
);
|
||||
$rbacRoleDefinitionSummary['modified']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'different_version',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? $this->severityForRoleDefinitionDiff($roleDefinitionDiff)
|
||||
: $this->severityForChangeType($severityMapping, 'different_version'),
|
||||
'subject_type' => $baselineItem['subject_type'],
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => $baselineComparableHash,
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unchanged']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($currentItems as $key => $currentItem) {
|
||||
if (! array_key_exists($key, $baselineItems)) {
|
||||
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||
|
||||
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||
$evidenceGaps['missing_current'] = ($evidenceGaps['missing_current'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_current'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($currentItem['policy_type'] ?? '');
|
||||
$subjectKey = (string) ($currentItem['subject_key'] ?? '');
|
||||
$isRbacRoleDefinition = $policyType === 'intuneRoleDefinition';
|
||||
|
||||
$displayName = $currentItem['meta_jsonb']['display_name'] ?? null;
|
||||
$displayName = is_string($displayName) ? (string) $displayName : null;
|
||||
|
||||
$currentPolicyVersionId = $this->currentPolicyVersionIdFromEvidence($currentEvidence);
|
||||
|
||||
if ($isRbacRoleDefinition && $currentPolicyVersionId === null) {
|
||||
$evidenceGaps['missing_role_definition_current_version_reference'] = ($evidenceGaps['missing_role_definition_current_version_reference'] ?? 0) + 1;
|
||||
$evidenceGapSubjects['missing_role_definition_current_version_reference'][] = $key;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$evidence = $this->buildDriftEvidenceContract(
|
||||
changeType: 'unexpected_policy',
|
||||
policyType: $policyType,
|
||||
subjectKey: $subjectKey,
|
||||
displayName: $displayName,
|
||||
baselineHash: null,
|
||||
currentHash: (string) $currentEvidence->hash,
|
||||
baselineProvenance: $baselinePlaceholderProvenance,
|
||||
currentProvenance: $currentEvidence->tenantProvenance(),
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
summaryKind: 'policy_snapshot',
|
||||
baselineProfileId: $baselineProfileId,
|
||||
baselineSnapshotId: $baselineSnapshotId,
|
||||
compareOperationRunId: $compareOperationRunId,
|
||||
inventorySyncRunId: $inventorySyncRunId,
|
||||
);
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$evidence['summary']['kind'] = 'rbac_role_definition';
|
||||
$evidence['rbac_role_definition'] = $this->buildRoleDefinitionEvidencePayload(
|
||||
tenant: $tenant,
|
||||
baselinePolicyVersionId: null,
|
||||
currentPolicyVersionId: $currentPolicyVersionId,
|
||||
baselineMeta: [],
|
||||
currentMeta: is_array($currentEvidence->meta ?? null) ? $currentEvidence->meta : (is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : []),
|
||||
diffKind: 'unexpected',
|
||||
);
|
||||
}
|
||||
|
||||
if ($isRbacRoleDefinition) {
|
||||
$rbacRoleDefinitionSummary['unexpected']++;
|
||||
$rbacRoleDefinitionSummary['total_compared']++;
|
||||
}
|
||||
|
||||
$drift[] = [
|
||||
'change_type' => 'unexpected_policy',
|
||||
'severity' => $isRbacRoleDefinition
|
||||
? Finding::SEVERITY_MEDIUM
|
||||
: $this->severityForChangeType($severityMapping, 'unexpected_policy'),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $currentItem['subject_external_id'],
|
||||
'subject_key' => $subjectKey,
|
||||
'policy_type' => $policyType,
|
||||
'evidence_fidelity' => (string) ($evidence['fidelity'] ?? EvidenceProvenance::FidelityMeta),
|
||||
'baseline_hash' => '',
|
||||
'current_hash' => $currentEvidence->hash,
|
||||
'evidence' => $evidence,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'drift' => $drift,
|
||||
'evidence_gaps' => $evidenceGaps,
|
||||
'evidence_gap_subjects' => $evidenceGapSubjects,
|
||||
'rbac_role_definitions' => $rbacRoleDefinitionSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{subject_external_id: string, baseline_hash: string} $baselineItem
|
||||
*/
|
||||
private function effectiveBaselineHash(
|
||||
Tenant $tenant,
|
||||
array $baselineItem,
|
||||
?int $baselinePolicyVersionId,
|
||||
ContentEvidenceProvider $contentEvidenceProvider,
|
||||
): string {
|
||||
$storedHash = (string) ($baselineItem['baseline_hash'] ?? '');
|
||||
|
||||
if ($baselinePolicyVersionId === null) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
if (array_key_exists($baselinePolicyVersionId, $this->baselineContentHashCache)) {
|
||||
return $this->baselineContentHashCache[$baselinePolicyVersionId];
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion) {
|
||||
return $storedHash;
|
||||
}
|
||||
|
||||
$hash = $contentEvidenceProvider->fromPolicyVersion(
|
||||
version: $baselineVersion,
|
||||
subjectExternalId: (string) ($baselineItem['subject_external_id'] ?? ''),
|
||||
)->hash;
|
||||
|
||||
$this->baselineContentHashCache[$baselinePolicyVersionId] = $hash;
|
||||
|
||||
return $hash;
|
||||
}
|
||||
|
||||
private function resolveBaselinePolicyVersionId(
|
||||
Tenant $tenant,
|
||||
array $baselineItem,
|
||||
array $baselineProvenance,
|
||||
BaselinePolicyVersionResolver $baselinePolicyVersionResolver,
|
||||
): ?int {
|
||||
$metaJsonb = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
||||
$versionReferenceId = data_get($metaJsonb, 'version_reference.policy_version_id');
|
||||
|
||||
if (is_numeric($versionReferenceId)) {
|
||||
return (int) $versionReferenceId;
|
||||
}
|
||||
|
||||
$baselineFidelity = (string) ($baselineProvenance['fidelity'] ?? EvidenceProvenance::FidelityMeta);
|
||||
$baselineSource = (string) ($baselineProvenance['source'] ?? EvidenceProvenance::SourceInventory);
|
||||
|
||||
if ($baselineFidelity !== EvidenceProvenance::FidelityContent || $baselineSource !== EvidenceProvenance::SourcePolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$observedAt = $baselineProvenance['observed_at'] ?? null;
|
||||
$observedAt = is_string($observedAt) ? trim($observedAt) : null;
|
||||
|
||||
if (! is_string($observedAt) || $observedAt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $baselinePolicyVersionResolver->resolve(
|
||||
tenant: $tenant,
|
||||
policyType: (string) ($baselineItem['policy_type'] ?? ''),
|
||||
subjectKey: (string) ($baselineItem['subject_key'] ?? ''),
|
||||
observedAt: $observedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private function currentPolicyVersionIdFromEvidence(ResolvedEvidence $evidence): ?int
|
||||
{
|
||||
$policyVersionId = $evidence->meta['policy_version_id'] ?? null;
|
||||
|
||||
return is_numeric($policyVersionId) ? (int) $policyVersionId : null;
|
||||
}
|
||||
|
||||
private function selectSummaryKind(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
DriftHasher $hasher,
|
||||
SettingsNormalizer $settingsNormalizer,
|
||||
AssignmentsNormalizer $assignmentsNormalizer,
|
||||
ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
): string {
|
||||
if ($baselinePolicyVersionId === null || $currentPolicyVersionId === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($baselinePolicyVersionId);
|
||||
|
||||
$currentVersion = PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$platform = is_string($baselineVersion->platform ?? null)
|
||||
? (string) $baselineVersion->platform
|
||||
: (is_string($currentVersion->platform ?? null) ? (string) $currentVersion->platform : null);
|
||||
|
||||
$baselineSnapshot = is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [];
|
||||
$currentSnapshot = is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [];
|
||||
|
||||
$baselineNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
$currentNormalized = $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $currentSnapshot,
|
||||
policyType: $policyType,
|
||||
platform: $platform,
|
||||
);
|
||||
|
||||
$baselineSnapshotHash = $hasher->hashNormalized([
|
||||
'settings' => $baselineNormalized,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'snapshot'),
|
||||
]);
|
||||
$currentSnapshotHash = $hasher->hashNormalized([
|
||||
'settings' => $currentNormalized,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'snapshot'),
|
||||
]);
|
||||
|
||||
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||
|
||||
$baselineAssignmentsHash = $hasher->hashNormalized([
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineAssignments),
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'assignments'),
|
||||
]);
|
||||
$currentAssignmentsHash = $hasher->hashNormalized([
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($currentAssignments),
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'assignments'),
|
||||
]);
|
||||
|
||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||
return 'policy_assignments';
|
||||
}
|
||||
|
||||
$baselineScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
||||
$currentScopeTagIds = $scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
||||
|
||||
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
$baselineScopeTagsHash = $hasher->hashNormalized([
|
||||
'scope_tag_ids' => $baselineScopeTagIds,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($baselineVersion, 'scope_tags'),
|
||||
]);
|
||||
$currentScopeTagsHash = $hasher->hashNormalized([
|
||||
'scope_tag_ids' => $currentScopeTagIds,
|
||||
'secret_fingerprints' => $this->fingerprintBucket($currentVersion, 'scope_tags'),
|
||||
]);
|
||||
|
||||
if ($baselineScopeTagsHash !== $currentScopeTagsHash) {
|
||||
return 'policy_scope_tags';
|
||||
}
|
||||
|
||||
return 'policy_snapshot';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function fingerprintBucket(PolicyVersion $version, string $bucket): array
|
||||
{
|
||||
$secretFingerprints = is_array($version->secret_fingerprints) ? $version->secret_fingerprints : [];
|
||||
$bucketFingerprints = $secretFingerprints[$bucket] ?? [];
|
||||
|
||||
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{fidelity: string, source: string, observed_at: ?string, observed_operation_run_id: ?int} $baselineProvenance
|
||||
* @param array<string, mixed> $currentProvenance
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildDriftEvidenceContract(
|
||||
string $changeType,
|
||||
string $policyType,
|
||||
string $subjectKey,
|
||||
?string $displayName,
|
||||
?string $baselineHash,
|
||||
?string $currentHash,
|
||||
array $baselineProvenance,
|
||||
array $currentProvenance,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
string $summaryKind,
|
||||
int $baselineProfileId,
|
||||
int $baselineSnapshotId,
|
||||
int $compareOperationRunId,
|
||||
int $inventorySyncRunId,
|
||||
): array {
|
||||
$fidelity = $this->fidelityFromPolicyVersionRefs($baselinePolicyVersionId, $currentPolicyVersionId);
|
||||
|
||||
return [
|
||||
'change_type' => $changeType,
|
||||
'policy_type' => $policyType,
|
||||
'subject_key' => $subjectKey,
|
||||
'display_name' => $displayName,
|
||||
'summary' => [
|
||||
'kind' => $summaryKind,
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_version_id' => $baselinePolicyVersionId,
|
||||
'hash' => $baselineHash,
|
||||
'provenance' => $baselineProvenance,
|
||||
],
|
||||
'current' => [
|
||||
'policy_version_id' => $currentPolicyVersionId,
|
||||
'hash' => $currentHash,
|
||||
'provenance' => $currentProvenance,
|
||||
],
|
||||
'fidelity' => $fidelity,
|
||||
'provenance' => [
|
||||
'baseline_profile_id' => $baselineProfileId,
|
||||
'baseline_snapshot_id' => $baselineSnapshotId,
|
||||
'compare_operation_run_id' => $compareOperationRunId,
|
||||
'inventory_sync_run_id' => $inventorySyncRunId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineMeta
|
||||
* @param array<string, mixed> $currentMeta
|
||||
* @param array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null $roleDefinitionDiff
|
||||
* @return array{
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* baseline: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed},
|
||||
* current: array{normalized: array<string, mixed>, is_built_in: mixed, role_permission_count: mixed}
|
||||
* }
|
||||
*/
|
||||
private function buildRoleDefinitionEvidencePayload(
|
||||
Tenant $tenant,
|
||||
?int $baselinePolicyVersionId,
|
||||
?int $currentPolicyVersionId,
|
||||
array $baselineMeta,
|
||||
array $currentMeta,
|
||||
string $diffKind,
|
||||
?array $roleDefinitionDiff = null,
|
||||
): array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
$baselineNormalized = is_array($roleDefinitionDiff['baseline'] ?? null)
|
||||
? $roleDefinitionDiff['baseline']
|
||||
: $this->fallbackRoleDefinitionNormalized($baselineVersion, $baselineMeta);
|
||||
$currentNormalized = is_array($roleDefinitionDiff['current'] ?? null)
|
||||
? $roleDefinitionDiff['current']
|
||||
: $this->fallbackRoleDefinitionNormalized($currentVersion, $currentMeta);
|
||||
|
||||
$changedKeys = is_array($roleDefinitionDiff['changed_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['changed_keys'], 'is_string'))
|
||||
: $this->roleDefinitionChangedKeys($baselineNormalized, $currentNormalized);
|
||||
$metadataKeys = is_array($roleDefinitionDiff['metadata_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['metadata_keys'], 'is_string'))
|
||||
: array_values(array_diff($changedKeys, $this->roleDefinitionPermissionKeys($changedKeys)));
|
||||
$permissionKeys = is_array($roleDefinitionDiff['permission_keys'] ?? null)
|
||||
? array_values(array_filter($roleDefinitionDiff['permission_keys'], 'is_string'))
|
||||
: $this->roleDefinitionPermissionKeys($changedKeys);
|
||||
|
||||
$resolvedDiffKind = is_string($roleDefinitionDiff['diff_kind'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_kind']
|
||||
: $diffKind;
|
||||
$diffFingerprint = is_string($roleDefinitionDiff['diff_fingerprint'] ?? null)
|
||||
? (string) $roleDefinitionDiff['diff_fingerprint']
|
||||
: hash(
|
||||
'sha256',
|
||||
json_encode([
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'changed_keys' => $changedKeys,
|
||||
'baseline' => $baselineNormalized,
|
||||
'current' => $currentNormalized,
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
|
||||
return [
|
||||
'diff_kind' => $resolvedDiffKind,
|
||||
'diff_fingerprint' => $diffFingerprint,
|
||||
'changed_keys' => $changedKeys,
|
||||
'metadata_keys' => $metadataKeys,
|
||||
'permission_keys' => $permissionKeys,
|
||||
'baseline' => [
|
||||
'normalized' => $baselineNormalized,
|
||||
'is_built_in' => data_get($baselineMeta, 'rbac.is_built_in', data_get($baselineMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($baselineMeta, 'rbac.role_permission_count', data_get($baselineMeta, 'role_permission_count')),
|
||||
],
|
||||
'current' => [
|
||||
'normalized' => $currentNormalized,
|
||||
'is_built_in' => data_get($currentMeta, 'rbac.is_built_in', data_get($currentMeta, 'is_built_in')),
|
||||
'role_permission_count' => data_get($currentMeta, 'rbac.role_permission_count', data_get($currentMeta, 'role_permission_count')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersionId): ?PolicyVersion
|
||||
{
|
||||
if ($policyVersionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->find($policyVersionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
||||
{
|
||||
if ($version instanceof PolicyVersion) {
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
|
||||
is_array($version->snapshot) ? $version->snapshot : [],
|
||||
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
$displayName = $meta['display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && trim($displayName) !== '') {
|
||||
$normalized['Role definition > Display name'] = trim($displayName);
|
||||
}
|
||||
|
||||
$isBuiltIn = data_get($meta, 'rbac.is_built_in', data_get($meta, 'is_built_in'));
|
||||
if (is_bool($isBuiltIn)) {
|
||||
$normalized['Role definition > Role source'] = $isBuiltIn ? 'Built-in' : 'Custom';
|
||||
}
|
||||
|
||||
$rolePermissionCount = data_get($meta, 'rbac.role_permission_count', data_get($meta, 'role_permission_count'));
|
||||
if (is_numeric($rolePermissionCount)) {
|
||||
$normalized['Role definition > Permission blocks'] = (int) $rolePermissionCount;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baselineNormalized
|
||||
* @param array<string, mixed> $currentNormalized
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionChangedKeys(array $baselineNormalized, array $currentNormalized): array
|
||||
{
|
||||
$keys = array_values(array_unique(array_merge(array_keys($baselineNormalized), array_keys($currentNormalized))));
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_filter($keys, fn (string $key): bool => ($baselineNormalized[$key] ?? null) !== ($currentNormalized[$key] ?? null)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roleDefinitionPermissionKeys(array $keys): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$keys,
|
||||
fn (string $key): bool => str_starts_with($key, 'Permission block ')
|
||||
));
|
||||
}
|
||||
|
||||
private function fidelityFromPolicyVersionRefs(?int $baselinePolicyVersionId, ?int $currentPolicyVersionId): string
|
||||
{
|
||||
if ($baselinePolicyVersionId !== null && $currentPolicyVersionId !== null) {
|
||||
return 'content';
|
||||
}
|
||||
|
||||
if ($baselinePolicyVersionId !== null || $currentPolicyVersionId !== null) {
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
return 'meta';
|
||||
}
|
||||
|
||||
private function normalizeSubjectKey(
|
||||
string $policyType,
|
||||
?string $storedSubjectKey = null,
|
||||
@ -2182,50 +1427,6 @@ private function normalizeSubjectKey(
|
||||
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* baseline: array<string, mixed>,
|
||||
* current: array<string, mixed>,
|
||||
* changed_keys: list<string>,
|
||||
* metadata_keys: list<string>,
|
||||
* permission_keys: list<string>,
|
||||
* diff_kind: string,
|
||||
* diff_fingerprint: string
|
||||
* }|null
|
||||
*/
|
||||
private function resolveRoleDefinitionDiff(
|
||||
Tenant $tenant,
|
||||
int $baselinePolicyVersionId,
|
||||
int $currentPolicyVersionId,
|
||||
IntuneRoleDefinitionNormalizer $normalizer,
|
||||
): ?array {
|
||||
$baselineVersion = $this->resolveRoleDefinitionVersion($tenant, $baselinePolicyVersionId);
|
||||
$currentVersion = $this->resolveRoleDefinitionVersion($tenant, $currentPolicyVersionId);
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalizer->classifyDiff(
|
||||
baselineSnapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
||||
currentSnapshot: is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [],
|
||||
platform: is_string($currentVersion->platform ?? null)
|
||||
? (string) $currentVersion->platform
|
||||
: (is_string($baselineVersion->platform ?? null) ? (string) $baselineVersion->platform : null),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{diff_kind?: string}|null $roleDefinitionDiff
|
||||
*/
|
||||
private function severityForRoleDefinitionDiff(?array $roleDefinitionDiff): string
|
||||
{
|
||||
return match ($roleDefinitionDiff['diff_kind'] ?? null) {
|
||||
'metadata_only' => Finding::SEVERITY_LOW,
|
||||
default => Finding::SEVERITY_HIGH,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
|
||||
*/
|
||||
|
||||
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal file
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class InventoryItemDependencyEdgesTable extends TableComponent
|
||||
{
|
||||
public int $inventoryItemId;
|
||||
|
||||
private ?InventoryItem $cachedInventoryItem = null;
|
||||
|
||||
public function mount(int $inventoryItemId): void
|
||||
{
|
||||
$this->inventoryItemId = $inventoryItemId;
|
||||
|
||||
$this->resolveInventoryItem();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
|
||||
->defaultSort('relationship_label')
|
||||
->defaultPaginationPageOption(10)
|
||||
->paginated(TablePaginationProfiles::picker())
|
||||
->striped()
|
||||
->deferLoading(! app()->runningUnitTests())
|
||||
->records(function (
|
||||
?string $sortColumn,
|
||||
?string $sortDirection,
|
||||
?string $search,
|
||||
array $filters,
|
||||
int $page,
|
||||
int $recordsPerPage
|
||||
): LengthAwarePaginator {
|
||||
$rows = $this->dependencyRows(
|
||||
direction: (string) ($filters['direction']['value'] ?? 'all'),
|
||||
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
|
||||
);
|
||||
|
||||
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
|
||||
|
||||
return $this->paginateRows($rows, $page, $recordsPerPage);
|
||||
})
|
||||
->filters([
|
||||
SelectFilter::make('direction')
|
||||
->label('Direction')
|
||||
->default('all')
|
||||
->options([
|
||||
'all' => 'All',
|
||||
'inbound' => 'Inbound',
|
||||
'outbound' => 'Outbound',
|
||||
]),
|
||||
SelectFilter::make('relationship_type')
|
||||
->label('Relationship')
|
||||
->options([
|
||||
'all' => 'All',
|
||||
...RelationshipType::options(),
|
||||
])
|
||||
->default('all')
|
||||
->searchable(),
|
||||
])
|
||||
->columns([
|
||||
TextColumn::make('relationship_label')
|
||||
->label('Relationship')
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('target_label')
|
||||
->label('Target')
|
||||
->badge()
|
||||
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
|
||||
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
|
||||
->wrap(),
|
||||
TextColumn::make('missing_state')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->placeholder('—')
|
||||
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
|
||||
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
|
||||
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
|
||||
->wrap(),
|
||||
])
|
||||
->actions([])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No dependencies found')
|
||||
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.inventory-item-dependency-edges-table');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function dependencyRows(string $direction, ?string $relationshipType): Collection
|
||||
{
|
||||
$inventoryItem = $this->resolveInventoryItem();
|
||||
$tenant = $this->resolveCurrentTenant();
|
||||
$service = app(DependencyQueryService::class);
|
||||
$resolver = app(DependencyTargetResolver::class);
|
||||
|
||||
$edges = collect();
|
||||
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
|
||||
}
|
||||
|
||||
if ($direction === 'outbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
|
||||
}
|
||||
|
||||
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
|
||||
->map(function (array $edge): array {
|
||||
$targetId = $edge['target_id'] ?? null;
|
||||
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
|
||||
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
|
||||
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
|
||||
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
|
||||
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
||||
|
||||
$missingHint = null;
|
||||
|
||||
if ($isMissing) {
|
||||
$missingHint = 'Missing target';
|
||||
|
||||
if (filled($lastKnownName)) {
|
||||
$missingHint .= ". Last known: {$lastKnownName}";
|
||||
}
|
||||
|
||||
$rawRef = data_get($edge, 'metadata.raw_ref');
|
||||
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
|
||||
|
||||
if (is_string($encodedRef) && $encodedRef !== '') {
|
||||
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
|
||||
}
|
||||
}
|
||||
|
||||
$fallbackLabel = null;
|
||||
|
||||
if (filled($lastKnownName)) {
|
||||
$fallbackLabel = $lastKnownName;
|
||||
} elseif (is_string($targetId) && $targetId !== '') {
|
||||
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
|
||||
} else {
|
||||
$fallbackLabel = 'External reference';
|
||||
}
|
||||
|
||||
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
|
||||
|
||||
return [
|
||||
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
|
||||
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
|
||||
'target_label' => $badgeText ?? $fallbackLabel,
|
||||
'target_url' => $linkUrl,
|
||||
'target_tooltip' => is_string($targetId) ? $targetId : null,
|
||||
'missing_state' => $isMissing ? 'Missing' : null,
|
||||
'missing_hint' => $missingHint,
|
||||
];
|
||||
})
|
||||
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
* @return Collection<string, array<string, mixed>>
|
||||
*/
|
||||
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||
{
|
||||
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
|
||||
? $sortColumn
|
||||
: 'relationship_label';
|
||||
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
||||
|
||||
$records = $rows->all();
|
||||
|
||||
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left[$sortColumn] ?? ''),
|
||||
(string) ($right[$sortColumn] ?? ''),
|
||||
);
|
||||
|
||||
if ($comparison === 0) {
|
||||
$comparison = strnatcasecmp(
|
||||
(string) ($left['target_label'] ?? ''),
|
||||
(string) ($right['target_label'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return $descending ? ($comparison * -1) : $comparison;
|
||||
});
|
||||
|
||||
return collect($records);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, array<string, mixed>> $rows
|
||||
*/
|
||||
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||
{
|
||||
return new LengthAwarePaginator(
|
||||
items: $rows->forPage($page, $recordsPerPage),
|
||||
total: $rows->count(),
|
||||
perPage: $recordsPerPage,
|
||||
currentPage: $page,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveInventoryItem(): InventoryItem
|
||||
{
|
||||
if ($this->cachedInventoryItem instanceof InventoryItem) {
|
||||
return $this->cachedInventoryItem;
|
||||
}
|
||||
|
||||
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
|
||||
$tenant = $this->resolveCurrentTenant();
|
||||
|
||||
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $this->cachedInventoryItem = $inventoryItem;
|
||||
}
|
||||
|
||||
private function resolveCurrentTenant(): Tenant
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function normalizeRelationshipType(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || $value === '' || $value === 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RelationshipType::tryFrom($value)?->value;
|
||||
}
|
||||
}
|
||||
@ -53,6 +53,8 @@ public function build(Tenant $tenant, array $filters = []): array
|
||||
$filteredPermissions = self::applyFilterState($allPermissions, $state);
|
||||
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
|
||||
|
||||
$summaryPermissions = $filteredPermissions;
|
||||
|
||||
return [
|
||||
'tenant' => [
|
||||
'id' => (int) $tenant->getKey(),
|
||||
@ -60,9 +62,9 @@ public function build(Tenant $tenant, array $filters = []): array
|
||||
'name' => (string) $tenant->name,
|
||||
],
|
||||
'overview' => [
|
||||
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
||||
'counts' => self::deriveCounts($allPermissions),
|
||||
'feature_impacts' => self::deriveFeatureImpacts($allPermissions),
|
||||
'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
|
||||
'counts' => self::deriveCounts($summaryPermissions),
|
||||
'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
|
||||
'freshness' => $freshness,
|
||||
],
|
||||
'permissions' => $filteredPermissions,
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
|
||||
final class GovernanceSubjectTaxonomyRegistry
|
||||
class GovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
|
||||
@ -1,85 +1,6 @@
|
||||
@php /** @var callable $getState */ @endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<form method="GET" class="flex items-center gap-2">
|
||||
<label for="direction" class="text-sm text-gray-600">Direction</label>
|
||||
<select id="direction" name="direction" class="fi-input fi-select">
|
||||
<option value="all" {{ request('direction', 'all') === 'all' ? 'selected' : '' }}>All</option>
|
||||
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
|
||||
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
|
||||
</select>
|
||||
|
||||
<label for="relationship_type" class="text-sm text-gray-600">Relationship</label>
|
||||
<select id="relationship_type" name="relationship_type" class="fi-input fi-select">
|
||||
<option value="all" {{ request('relationship_type', 'all') === 'all' ? 'selected' : '' }}>All</option>
|
||||
@foreach (\App\Support\Enums\RelationshipType::options() as $value => $label)
|
||||
<option value="{{ $value }}" {{ request('relationship_type') === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="fi-btn">Apply</button>
|
||||
</form>
|
||||
|
||||
@php
|
||||
$raw = $getState();
|
||||
$edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw);
|
||||
@endphp
|
||||
|
||||
@if ($edges->isEmpty())
|
||||
<div class="text-sm text-gray-500">No dependencies found</div>
|
||||
@else
|
||||
<div class="divide-y">
|
||||
@foreach ($edges->groupBy('relationship_type') as $type => $group)
|
||||
<div class="py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-600 mb-2">{{ str_replace('_', ' ', $type) }}</div>
|
||||
<ul class="space-y-1">
|
||||
@foreach ($group as $edge)
|
||||
@php
|
||||
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
||||
$targetId = $edge['target_id'] ?? null;
|
||||
$rendered = $edge['rendered_target'] ?? [];
|
||||
$badgeText = is_array($rendered) ? ($rendered['badge_text'] ?? null) : null;
|
||||
$linkUrl = is_array($rendered) ? ($rendered['link_url'] ?? null) : null;
|
||||
|
||||
$missingTitle = 'Missing target';
|
||||
$lastKnownName = $edge['metadata']['last_known_name'] ?? null;
|
||||
if (is_string($lastKnownName) && $lastKnownName !== '') {
|
||||
$missingTitle .= ". Last known: {$lastKnownName}";
|
||||
}
|
||||
$rawRef = $edge['metadata']['raw_ref'] ?? null;
|
||||
if ($rawRef !== null) {
|
||||
$encodedRef = json_encode($rawRef);
|
||||
if (is_string($encodedRef) && $encodedRef !== '') {
|
||||
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
|
||||
}
|
||||
}
|
||||
|
||||
$fallbackDisplay = null;
|
||||
if (is_string($lastKnownName) && $lastKnownName !== '') {
|
||||
$fallbackDisplay = $lastKnownName;
|
||||
} elseif (is_string($targetId) && $targetId !== '') {
|
||||
$fallbackDisplay = 'ID: '.substr($targetId, 0, 6).'…';
|
||||
} else {
|
||||
$fallbackDisplay = 'External reference';
|
||||
}
|
||||
@endphp
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
@if (is_string($badgeText) && $badgeText !== '')
|
||||
@if (is_string($linkUrl) && $linkUrl !== '')
|
||||
<a class="fi-badge" href="{{ $linkUrl }}" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</a>
|
||||
@else
|
||||
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $fallbackDisplay }}</span>
|
||||
@endif
|
||||
@if ($isMissing)
|
||||
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<livewire:inventory-item-dependency-edges-table
|
||||
:inventory-item-id="(int) $getRecord()->getKey()"
|
||||
:key="'inventory-item-dependency-edges-'.$getRecord()->getKey()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,59 +1,5 @@
|
||||
<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">Artifact truth</th>
|
||||
<th class="px-4 py-3 font-medium">Freshness</th>
|
||||
<th class="px-4 py-3 font-medium">Generated</th>
|
||||
<th class="px-4 py-3 font-medium">Not collected yet</th>
|
||||
<th class="px-4 py-3 font-medium">Refresh recommended</th>
|
||||
<th class="px-4 py-3 font-medium">Next step</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">
|
||||
<x-filament::badge :color="data_get($row, 'artifact_truth.color', 'gray')" :icon="data_get($row, 'artifact_truth.icon')" size="sm">
|
||||
{{ data_get($row, 'artifact_truth.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
@if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '')
|
||||
<div class="mt-1 text-xs text-gray-500">{{ data_get($row, 'artifact_truth.explanation') }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="data_get($row, 'freshness.color', 'gray')" :icon="data_get($row, 'freshness.icon')" size="sm">
|
||||
{{ data_get($row, 'freshness.label', 'Unknown') }}
|
||||
</x-filament::badge>
|
||||
</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">{{ $row['next_step'] ?? 'No action needed' }}</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
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
$tenant = $this->currentTenant();
|
||||
|
||||
$vm = is_array($viewModel ?? null) ? $viewModel : [];
|
||||
$vm = $this->viewModel();
|
||||
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
|
||||
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
|
||||
@ -14,20 +14,6 @@
|
||||
|
||||
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
|
||||
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : [];
|
||||
$selectedStatus = (string) ($filters['status'] ?? 'missing');
|
||||
$selectedType = (string) ($filters['type'] ?? 'all');
|
||||
$searchTerm = (string) ($filters['search'] ?? '');
|
||||
|
||||
$featureOptions = collect($featureImpacts)
|
||||
->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null))
|
||||
->map(fn (array $impact): string => (string) $impact['feature'])
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : [];
|
||||
|
||||
$overall = $overview['overall'] ?? null;
|
||||
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null;
|
||||
@ -226,10 +212,8 @@ class="text-primary-600 hover:underline dark:text-primary-400"
|
||||
$selected = in_array($featureKey, $selectedFeatures, true);
|
||||
@endphp
|
||||
|
||||
<button
|
||||
type="button"
|
||||
wire:click="applyFeatureFilter(@js($featureKey))"
|
||||
class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/40 {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
|
||||
<div
|
||||
class="rounded-xl border p-4 text-left {{ $selected ? 'border-primary-300 bg-primary-50 dark:border-primary-700 dark:bg-primary-950/40' : 'border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
@ -245,17 +229,9 @@ class="rounded-xl border p-4 text-left transition hover:bg-gray-50 dark:hover:bg
|
||||
{{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($selectedFeatures !== [])
|
||||
<div>
|
||||
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
|
||||
Clear feature filter
|
||||
</x-filament::button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div
|
||||
@ -475,182 +451,14 @@ class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-semibold text-gray-950 dark:text-white">Native permission matrix</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Search doesn’t affect copy actions. Feature filters do.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::button color="gray" size="sm" wire:click="resetFilters">
|
||||
Reset
|
||||
</x-filament::button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</label>
|
||||
<select wire:model.live="status" class="fi-input fi-select w-full">
|
||||
<option value="missing">Missing</option>
|
||||
<option value="present">Present</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Type</label>
|
||||
<select wire:model.live="type" class="fi-input fi-select w-full">
|
||||
<option value="all">All</option>
|
||||
<option value="application">Application</option>
|
||||
<option value="delegated">Delegated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 sm:col-span-2">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Search</label>
|
||||
<input
|
||||
type="search"
|
||||
wire:model.live.debounce.500ms="search"
|
||||
class="fi-input w-full"
|
||||
placeholder="Search permission key or description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if ($featureOptions !== [])
|
||||
<div class="space-y-1 sm:col-span-4">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-300">Features</label>
|
||||
<select wire:model.live="features" class="fi-input fi-select w-full" multiple>
|
||||
@foreach ($featureOptions as $feature)
|
||||
<option value="{{ $feature }}">{{ $feature }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($requiredTotal === 0)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
<div class="font-semibold text-gray-950 dark:text-white">No permissions configured</div>
|
||||
<div class="mt-1">
|
||||
No required permissions are currently configured in <code class="font-mono text-xs">config/intune_permissions.php</code>.
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($permissions === [])
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
@if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '')
|
||||
<div class="font-semibold text-gray-950 dark:text-white">All required permissions are present</div>
|
||||
<div class="mt-1">
|
||||
Switch Status to “All” if you want to review the full matrix.
|
||||
</div>
|
||||
@else
|
||||
<div class="font-semibold text-gray-950 dark:text-white">No matches</div>
|
||||
<div class="mt-1">
|
||||
No permissions match the current filters.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$featuresToRender = $featureImpacts;
|
||||
|
||||
if ($selectedFeatures !== []) {
|
||||
$featuresToRender = collect($featureImpacts)
|
||||
->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@endphp
|
||||
|
||||
@foreach ($featuresToRender as $impact)
|
||||
@php
|
||||
$featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null;
|
||||
$featureKey = is_string($featureKey) ? $featureKey : null;
|
||||
|
||||
if ($featureKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = collect($permissions)
|
||||
->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($rows === []) {
|
||||
continue;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $featureKey }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Permission
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-950">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$key = is_array($row) ? (string) ($row['key'] ?? '') : '';
|
||||
$type = is_array($row) ? (string) ($row['type'] ?? '') : '';
|
||||
$status = is_array($row) ? (string) ($row['status'] ?? '') : '';
|
||||
$description = is_array($row) ? ($row['description'] ?? null) : null;
|
||||
$description = is_string($description) ? $description : null;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status);
|
||||
@endphp
|
||||
|
||||
<tr
|
||||
class="align-top"
|
||||
data-permission-key="{{ $key }}"
|
||||
data-permission-type="{{ $type }}"
|
||||
data-permission-status="{{ $status }}"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-950 dark:text-white">
|
||||
{{ $key }}
|
||||
</div>
|
||||
@if ($description)
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ $description }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $type === 'delegated' ? 'Delegated' : 'Application' }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
<div>
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@ -138,6 +138,13 @@
|
||||
$opService,
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.matched_scope_entries.0.domain_key'))->toBe('intune')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.execution_diagnostics.rbac_role_definitions.total_compared'))->toBe(0);
|
||||
|
||||
$finding = Finding::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('subject_external_id', (string) $policy->external_id)
|
||||
|
||||
@ -130,6 +130,9 @@
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('succeeded');
|
||||
expect(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.state_counts.drift'))->toBe(3);
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;
|
||||
|
||||
@ -123,6 +123,8 @@
|
||||
$run->refresh();
|
||||
|
||||
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull()
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_record_missing'))->toBe(1)
|
||||
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);
|
||||
|
||||
|
||||
@ -85,7 +85,9 @@
|
||||
);
|
||||
|
||||
$compareRun->refresh();
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(0)
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported');
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
|
||||
});
|
||||
|
||||
@ -200,7 +202,10 @@
|
||||
|
||||
$compareRun->refresh();
|
||||
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.subjects_total'))->toBe(1)
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.key'))->toBe('intune_policy')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||
->and(data_get($compareRun->context, 'baseline_compare.strategy.state_counts.no_drift'))->toBe(1);
|
||||
expect(data_get($compareRun->context, 'result.findings_total'))->toBe(0);
|
||||
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
|
||||
});
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
use App\Services\Baselines\BaselineCaptureService;
|
||||
use App\Services\Baselines\BaselineCompareService;
|
||||
use App\Support\Baselines\BaselineCaptureMode;
|
||||
use App\Support\Baselines\BaselineReasonCodes;
|
||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
@ -85,7 +87,7 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
Bus::assertDispatched(CompareBaselineToTenantJob::class);
|
||||
});
|
||||
|
||||
it('persists the same truthful scope capability decisions before dispatching capture work', function (): void {
|
||||
it('blocks capture work when the scope still contains unsupported types, while preserving truthful capability context', function (): void {
|
||||
Bus::fake();
|
||||
appendBrokenFoundationSupportConfig();
|
||||
|
||||
@ -102,10 +104,13 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
|
||||
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
|
||||
|
||||
expect($result['ok'])->toBeTrue();
|
||||
$scope = $profile->normalizedScope()->toEffectiveScopeContext(
|
||||
app(BaselineSupportCapabilityGuard::class),
|
||||
'capture',
|
||||
);
|
||||
|
||||
$run = $result['run'];
|
||||
$scope = data_get($run->context, 'effective_scope');
|
||||
expect($result['ok'])->toBeFalse()
|
||||
->and($result['reason_code'] ?? null)->toBe(BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE);
|
||||
|
||||
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
|
||||
->and(data_get($scope, 'limited_types'))->toBe(['roleScopeTag'])
|
||||
@ -117,5 +122,5 @@ function appendBrokenFoundationSupportConfig(): void
|
||||
->and(data_get($scope, 'capabilities.brokenFoundation.support_mode'))->toBe('invalid_support_config')
|
||||
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
|
||||
|
||||
Bus::assertDispatched(CaptureBaselineSnapshotJob::class);
|
||||
Bus::assertNotDispatched(CaptureBaselineSnapshotJob::class);
|
||||
});
|
||||
|
||||
@ -362,18 +362,11 @@ public function compare(
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeGovernanceSubjectTaxonomyRegistry
|
||||
final class FakeGovernanceSubjectTaxonomyRegistry extends GovernanceSubjectTaxonomyRegistry
|
||||
{
|
||||
private readonly GovernanceSubjectTaxonomyRegistry $inner;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->inner = new GovernanceSubjectTaxonomyRegistry;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_values(array_merge($this->inner->all(), [
|
||||
return array_values(array_merge(parent::all(), [
|
||||
new GovernanceSubjectType(
|
||||
domainKey: GovernanceDomainKey::Entra,
|
||||
subjectClass: GovernanceSubjectClass::Control,
|
||||
@ -389,66 +382,4 @@ public function all(): array
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
public function active(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->all(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->active,
|
||||
));
|
||||
}
|
||||
|
||||
public function activeLegacyBucketKeys(string $legacyBucket): array
|
||||
{
|
||||
$subjectTypes = array_filter(
|
||||
$this->active(),
|
||||
static fn (GovernanceSubjectType $subjectType): bool => $subjectType->legacyBucket === $legacyBucket,
|
||||
);
|
||||
|
||||
$keys = array_map(
|
||||
static fn (GovernanceSubjectType $subjectType): string => $subjectType->subjectTypeKey,
|
||||
$subjectTypes,
|
||||
);
|
||||
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubjectType
|
||||
{
|
||||
foreach ($this->all() as $subjectType) {
|
||||
if ($subjectType->domainKey->value !== trim($domainKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($subjectType->subjectTypeKey !== trim($subjectTypeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $subjectType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function isKnownDomain(string $domainKey): bool
|
||||
{
|
||||
return $this->inner->isKnownDomain($domainKey);
|
||||
}
|
||||
|
||||
public function allowsSubjectClass(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->allowsSubjectClass($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function supportsFilters(string $domainKey, string $subjectClass): bool
|
||||
{
|
||||
return $this->inner->supportsFilters($domainKey, $subjectClass);
|
||||
}
|
||||
|
||||
public function groupLabel(string $domainKey, string $subjectClass): string
|
||||
{
|
||||
return $this->inner->groupLabel($domainKey, $subjectClass);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Monitoring\EvidenceOverview;
|
||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Tenant;
|
||||
@ -10,6 +11,7 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||
|
||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||
@ -122,3 +124,56 @@
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
|
||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false);
|
||||
});
|
||||
|
||||
it('seeds the native entitled-tenant prefilter once and clears it through the page action', 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');
|
||||
|
||||
$snapshotA = 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(),
|
||||
]);
|
||||
|
||||
$snapshotB = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenantB->getKey(),
|
||||
'workspace_id' => (int) $tenantB->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Partial->value,
|
||||
'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
Filament::setTenant(null, true);
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant_id' => (string) $tenantB->getKey(),
|
||||
'search' => $tenantB->name,
|
||||
])->test(EvidenceOverview::class);
|
||||
|
||||
$component
|
||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
||||
->assertSet('tableSearch', $tenantB->name)
|
||||
->assertCanSeeTableRecords([(string) $snapshotB->getKey()])
|
||||
->assertCanNotSeeTableRecords([(string) $snapshotA->getKey()]);
|
||||
|
||||
$component
|
||||
->callAction('clear_filters')
|
||||
->assertSet('tableFilters.tenant_id.value', null)
|
||||
->assertSet('tableSearch', '')
|
||||
->assertCanSeeTableRecords([
|
||||
(string) $snapshotA->getKey(),
|
||||
(string) $snapshotB->getKey(),
|
||||
]);
|
||||
});
|
||||
|
||||
@ -40,6 +40,8 @@
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(EvidenceOverview::class)
|
||||
->assertCountTableRecords(1)
|
||||
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
|
||||
->assertSee($tenant->name)
|
||||
->assertSee('Artifact truth');
|
||||
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Livewire\InventoryItemDependencyEdgesTable;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function dependencyEdgesTableComponent(User $user, Tenant $tenant, InventoryItem $item)
|
||||
{
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
test()->actingAs($user);
|
||||
|
||||
return Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
|
||||
'inventoryItemId' => (int) $item->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('renders dependency rows through native table filters and preserves missing-target hints', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$assigned = InventoryLink::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => [
|
||||
'last_known_name' => 'Assigned Target',
|
||||
'raw_ref' => ['example' => 'assigned'],
|
||||
],
|
||||
]);
|
||||
|
||||
$scoped = InventoryLink::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'scoped_by',
|
||||
'metadata' => [
|
||||
'last_known_name' => 'Scoped Target',
|
||||
'raw_ref' => ['example' => 'scoped'],
|
||||
],
|
||||
]);
|
||||
|
||||
$inbound = InventoryLink::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) Str::uuid(),
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
]);
|
||||
|
||||
$component = dependencyEdgesTableComponent($user, $tenant, $item)
|
||||
->assertTableFilterExists('direction')
|
||||
->assertTableFilterExists('relationship_type')
|
||||
->assertCanSeeTableRecords([
|
||||
(string) $assigned->getKey(),
|
||||
(string) $scoped->getKey(),
|
||||
(string) $inbound->getKey(),
|
||||
])
|
||||
->assertSee('Assigned Target')
|
||||
->assertSee('Scoped Target')
|
||||
->assertSee('Missing');
|
||||
|
||||
$component
|
||||
->filterTable('direction', 'outbound')
|
||||
->assertCanSeeTableRecords([
|
||||
(string) $assigned->getKey(),
|
||||
(string) $scoped->getKey(),
|
||||
])
|
||||
->assertCanNotSeeTableRecords([(string) $inbound->getKey()])
|
||||
->removeTableFilters()
|
||||
->filterTable('direction', 'inbound')
|
||||
->assertCanSeeTableRecords([(string) $inbound->getKey()])
|
||||
->assertCanNotSeeTableRecords([
|
||||
(string) $assigned->getKey(),
|
||||
(string) $scoped->getKey(),
|
||||
])
|
||||
->removeTableFilters()
|
||||
->filterTable('relationship_type', 'scoped_by')
|
||||
->assertCanSeeTableRecords([(string) $scoped->getKey()])
|
||||
->assertCanNotSeeTableRecords([
|
||||
(string) $assigned->getKey(),
|
||||
(string) $inbound->getKey(),
|
||||
])
|
||||
->removeTableFilters()
|
||||
->filterTable('direction', 'outbound')
|
||||
->filterTable('relationship_type', 'depends_on')
|
||||
->assertCountTableRecords(0)
|
||||
->assertSee('No dependencies found');
|
||||
});
|
||||
|
||||
it('returns deny-as-not-found when mounted for an item outside the current tenant scope', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$foreignItem = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) Tenant::factory()->create()->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [
|
||||
'inventoryItemId' => (int) $foreignItem->getKey(),
|
||||
]);
|
||||
|
||||
$component->assertSee('Not Found');
|
||||
|
||||
expect($component->instance())->toBeNull();
|
||||
});
|
||||
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedTenantRequiredPermissionsFixture(Tenant $tenant): void
|
||||
{
|
||||
config()->set('intune_permissions.permissions', [
|
||||
[
|
||||
'key' => 'DeviceManagementApps.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Backup application permission',
|
||||
'features' => ['backup'],
|
||||
],
|
||||
[
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'delegated',
|
||||
'description' => 'Backup delegated permission',
|
||||
'features' => ['backup'],
|
||||
],
|
||||
[
|
||||
'key' => 'Reports.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Reporting permission',
|
||||
'features' => ['reporting'],
|
||||
],
|
||||
]);
|
||||
config()->set('entra_permissions.permissions', []);
|
||||
|
||||
TenantPermission::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Group.Read.All',
|
||||
'status' => 'missing',
|
||||
'details' => ['source' => 'fixture'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
TenantPermission::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Reports.Read.All',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'fixture'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function tenantRequiredPermissionsComponent(User $user, Tenant $tenant, array $query = [])
|
||||
{
|
||||
test()->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$query = array_merge([
|
||||
'tenant' => (string) $tenant->external_id,
|
||||
], $query);
|
||||
|
||||
return Livewire::withQueryParams($query)->test(TenantRequiredPermissions::class);
|
||||
}
|
||||
|
||||
it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
seedTenantRequiredPermissionsFixture($tenant);
|
||||
|
||||
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||
->assertTableFilterExists('status')
|
||||
->assertTableFilterExists('type')
|
||||
->assertTableFilterExists('features')
|
||||
->assertCanSeeTableRecords([
|
||||
'DeviceManagementApps.Read.All',
|
||||
'Group.Read.All',
|
||||
])
|
||||
->assertCanNotSeeTableRecords(['Reports.Read.All'])
|
||||
->assertSee('Missing application permissions')
|
||||
->assertSee('Guidance');
|
||||
|
||||
$component
|
||||
->filterTable('status', 'present')
|
||||
->filterTable('type', 'application')
|
||||
->searchTable('Reports')
|
||||
->assertCountTableRecords(1)
|
||||
->assertCanSeeTableRecords(['Reports.Read.All'])
|
||||
->assertCanNotSeeTableRecords([
|
||||
'DeviceManagementApps.Read.All',
|
||||
'Group.Read.All',
|
||||
]);
|
||||
|
||||
$viewModel = $component->instance()->viewModel();
|
||||
|
||||
expect($viewModel['overview']['counts'])->toBe([
|
||||
'missing_application' => 0,
|
||||
'missing_delegated' => 0,
|
||||
'present' => 1,
|
||||
'error' => 0,
|
||||
])
|
||||
->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All'])
|
||||
->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All');
|
||||
});
|
||||
|
||||
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
seedTenantRequiredPermissionsFixture($tenant);
|
||||
|
||||
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||
->set('tableFilters.features.values', ['backup'])
|
||||
->assertSet('tableFilters.features.values', ['backup']);
|
||||
|
||||
$viewModel = $component->instance()->viewModel();
|
||||
|
||||
expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All')
|
||||
->and($viewModel['copy']['delegated'])->toBe('Group.Read.All');
|
||||
|
||||
$component
|
||||
->searchTable('no-such-permission')
|
||||
->assertCountTableRecords(0)
|
||||
->assertSee('No matches')
|
||||
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
|
||||
});
|
||||
@ -29,7 +29,9 @@
|
||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
||||
'app/Filament/Pages/InventoryCoverage.php',
|
||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
||||
'app/Filament/System/Pages/Directory/Tenants.php',
|
||||
'app/Filament/System/Pages/Directory/Workspaces.php',
|
||||
'app/Filament/System/Pages/Ops/Runs.php',
|
||||
@ -39,6 +41,7 @@
|
||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
||||
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
||||
'app/Livewire/InventoryItemDependencyEdgesTable.php',
|
||||
'app/Livewire/BackupSetPolicyPickerTable.php',
|
||||
'app/Livewire/EntraGroupCachePickerTable.php',
|
||||
'app/Livewire/SettingsCatalogSettingsTable.php',
|
||||
@ -81,7 +84,9 @@
|
||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Pages/TenantRequiredPermissions.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Pages/InventoryCoverage.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
|
||||
@ -91,6 +96,7 @@
|
||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
|
||||
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
|
||||
'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('],
|
||||
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
|
||||
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
|
||||
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
|
||||
@ -134,6 +140,8 @@
|
||||
'app/Filament/Resources/EntraGroupResource.php',
|
||||
'app/Filament/Resources/OperationRunResource.php',
|
||||
'app/Filament/Resources/BaselineSnapshotResource.php',
|
||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||
];
|
||||
|
||||
@ -310,7 +318,9 @@
|
||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
||||
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
|
||||
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
|
||||
'app/Filament/Pages/TenantRequiredPermissions.php',
|
||||
'app/Filament/Pages/InventoryCoverage.php',
|
||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
|
||||
'app/Filament/System/Pages/Directory/Tenants.php',
|
||||
'app/Filament/System/Pages/Directory/Workspaces.php',
|
||||
'app/Filament/System/Pages/Ops/Runs.php',
|
||||
@ -320,6 +330,7 @@
|
||||
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
|
||||
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
|
||||
'app/Filament/Widgets/Dashboard/RecentOperations.php',
|
||||
'app/Livewire/InventoryItemDependencyEdgesTable.php',
|
||||
'app/Livewire/BackupSetPolicyPickerTable.php',
|
||||
'app/Livewire/EntraGroupCachePickerTable.php',
|
||||
'app/Livewire/SettingsCatalogSettingsTable.php',
|
||||
@ -337,6 +348,85 @@
|
||||
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
it('keeps spec 196 surfaces on native table contracts without faux controls or hand-built primary tables', function (): void {
|
||||
$requiredPatterns = [
|
||||
'app/Filament/Pages/TenantRequiredPermissions.php' => [
|
||||
'implements HasTable',
|
||||
'InteractsWithTable',
|
||||
],
|
||||
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => [
|
||||
'implements HasTable',
|
||||
'InteractsWithTable',
|
||||
],
|
||||
'app/Livewire/InventoryItemDependencyEdgesTable.php' => [
|
||||
'extends TableComponent',
|
||||
],
|
||||
'resources/views/filament/components/dependency-edges.blade.php' => [
|
||||
'inventory-item-dependency-edges-table',
|
||||
],
|
||||
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
|
||||
'$this->table',
|
||||
'data-testid="technical-details"',
|
||||
],
|
||||
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
|
||||
'$this->table',
|
||||
],
|
||||
];
|
||||
|
||||
$forbiddenPatterns = [
|
||||
'resources/views/filament/components/dependency-edges.blade.php' => [
|
||||
'<form method="GET"',
|
||||
'request(',
|
||||
],
|
||||
'resources/views/filament/pages/tenant-required-permissions.blade.php' => [
|
||||
'wire:model.live="status"',
|
||||
'wire:model.live="type"',
|
||||
'wire:model.live="features"',
|
||||
'wire:model.live.debounce.500ms="search"',
|
||||
'<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">',
|
||||
],
|
||||
'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [
|
||||
'<table class="min-w-full divide-y divide-gray-200 text-sm">',
|
||||
],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
$unexpected = [];
|
||||
|
||||
foreach ($requiredPatterns as $relativePath => $patterns) {
|
||||
$contents = file_get_contents(base_path($relativePath));
|
||||
|
||||
if (! is_string($contents)) {
|
||||
$missing[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (! str_contains($contents, $pattern)) {
|
||||
$missing[] = "{$relativePath} ({$pattern})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($forbiddenPatterns as $relativePath => $patterns) {
|
||||
$contents = file_get_contents(base_path($relativePath));
|
||||
|
||||
if (! is_string($contents)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_contains($contents, $pattern)) {
|
||||
$unexpected[] = "{$relativePath} ({$pattern})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($missing)->toBeEmpty('Missing native table contract patterns: '.implode(', ', $missing))
|
||||
->and($unexpected)->toBeEmpty('Unexpected faux-control or hand-built table patterns remain: '.implode(', ', $unexpected));
|
||||
});
|
||||
|
||||
it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
|
||||
$patternByPath = [
|
||||
'app/Filament/Resources/TenantResource.php' => [
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->select(');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
|
||||
expect($compareJob)->toContain('$strategy->compare(');
|
||||
expect($compareJob)->not->toContain('computeDrift(');
|
||||
expect($compareJob)->not->toContain('->fingerprint(');
|
||||
expect($compareJob)->not->toContain('::fingerprint(');
|
||||
|
||||
|
||||
@ -7,6 +7,24 @@
|
||||
'PolicyNormalizer',
|
||||
'VersionDiff',
|
||||
'flattenForDiff',
|
||||
'computeDrift(',
|
||||
'effectiveBaselineHash(',
|
||||
'resolveBaselinePolicyVersionId(',
|
||||
'selectSummaryKind(',
|
||||
'buildDriftEvidenceContract(',
|
||||
'buildRoleDefinitionEvidencePayload(',
|
||||
'resolveRoleDefinitionVersion(',
|
||||
'fallbackRoleDefinitionNormalized(',
|
||||
'roleDefinitionChangedKeys(',
|
||||
'roleDefinitionPermissionKeys(',
|
||||
'resolveRoleDefinitionDiff(',
|
||||
'severityForRoleDefinitionDiff(',
|
||||
'BaselinePolicyVersionResolver',
|
||||
'DriftHasher',
|
||||
'SettingsNormalizer',
|
||||
'AssignmentsNormalizer',
|
||||
'ScopeTagsNormalizer',
|
||||
'IntuneRoleDefinitionNormalizer',
|
||||
];
|
||||
|
||||
$captureForbiddenTokens = [
|
||||
@ -20,6 +38,9 @@
|
||||
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
|
||||
expect($compareJob)->toBeString();
|
||||
expect($compareJob)->toContain('CurrentStateHashResolver');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->select(');
|
||||
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
|
||||
expect($compareJob)->toContain('$strategy->compare(');
|
||||
|
||||
foreach ($compareForbiddenTokens as $token) {
|
||||
expect($compareJob)->not->toContain($token);
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
->assertSee('Last known: Ghost Target');
|
||||
});
|
||||
|
||||
it('direction filter limits to outbound or inbound', function () {
|
||||
it('renders native dependency controls in place instead of a GET apply workflow', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -51,34 +51,48 @@
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$inboundSource = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
'display_name' => 'Inbound Source',
|
||||
]);
|
||||
|
||||
// Outbound only
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => [
|
||||
'last_known_name' => 'Assigned Target',
|
||||
],
|
||||
]);
|
||||
|
||||
// Inbound only
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) Str::uuid(),
|
||||
'source_id' => $inboundSource->external_id,
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
]);
|
||||
|
||||
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
||||
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
||||
|
||||
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound';
|
||||
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('Direction')
|
||||
->assertSee('Inbound')
|
||||
->assertSee('Outbound')
|
||||
->assertSee('Relationship')
|
||||
->assertSee('Assigned Target')
|
||||
->assertDontSee('No dependencies found');
|
||||
});
|
||||
|
||||
it('relationship filter limits edges by type', function () {
|
||||
it('ignores legacy relationship query state while preserving visible target safety', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
@ -115,7 +129,7 @@
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('Scoped Target')
|
||||
->assertDontSee('Assigned Target');
|
||||
->assertSee('Assigned Target');
|
||||
});
|
||||
|
||||
it('does not show edges from other tenants (tenant isolation)', function () {
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantRequiredPermissions;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPermission;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
@ -54,7 +56,7 @@
|
||||
|
||||
$response
|
||||
->assertSee($tenant->getFilamentName())
|
||||
->assertSee('data-permission-key="Tenant.Read.All"', false);
|
||||
->assertSee('Tenant.Read.All');
|
||||
});
|
||||
|
||||
it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void {
|
||||
@ -86,3 +88,52 @@
|
||||
->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('seeds native table state from deeplink filters without letting query values redefine the route tenant', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Ignored Query Tenant',
|
||||
'external_id' => 'ignored-query-tenant',
|
||||
]);
|
||||
|
||||
config()->set('intune_permissions.permissions', [
|
||||
[
|
||||
'key' => 'Tenant.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Tenant read permission',
|
||||
'features' => ['backup'],
|
||||
],
|
||||
]);
|
||||
config()->set('entra_permissions.permissions', []);
|
||||
|
||||
TenantPermission::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'permission_key' => 'Tenant.Read.All',
|
||||
'status' => 'granted',
|
||||
'details' => ['source' => 'db'],
|
||||
'last_checked_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'tenant' => $tenant->external_id,
|
||||
'tenant_id' => (string) $otherTenant->getKey(),
|
||||
'status' => 'present',
|
||||
'type' => 'application',
|
||||
'features' => ['backup'],
|
||||
'search' => 'Tenant',
|
||||
])->test(TenantRequiredPermissions::class);
|
||||
|
||||
$component
|
||||
->assertSet('tableFilters.status.value', 'present')
|
||||
->assertSet('tableFilters.type.value', 'application')
|
||||
->assertSet('tableFilters.features.values', ['backup'])
|
||||
->assertSet('tableSearch', 'Tenant');
|
||||
|
||||
expect($component->instance()->currentTenant()?->is($tenant))->toBeTrue();
|
||||
});
|
||||
|
||||
@ -377,11 +377,18 @@ x-spec-196-notes:
|
||||
- route tenant stays authoritative on required-permissions
|
||||
- evidence overview only exposes entitled tenant rows
|
||||
- dependency rendering remains tenant-isolated and DB-only
|
||||
- cleaned surfaces remain read-only and introduce no new remote runtime calls at render time
|
||||
- native Filament or Livewire state remains the primary contract, with no new wrapper or presenter layer introduced only to translate pseudo-native state
|
||||
- required-permissions summary counts, freshness, guidance visibility, and copy payload remain derived from the same normalized filter state as the visible rows
|
||||
- evidence overview preserves a meaningful empty state, clear-filter affordance when scoped, and one workspace-safe inspect path per authorized row
|
||||
- query values may seed initial state but not stay the primary contract
|
||||
nonGoals:
|
||||
- runtime API exposure
|
||||
- new persistence
|
||||
- new polling behavior
|
||||
- new provider or route families
|
||||
- new global or on-demand asset requirements
|
||||
- shared wrapper or presenter framework
|
||||
- global context shell redesign
|
||||
- monitoring page-state architecture rewrite
|
||||
- audit log selected-record or inspect duality cleanup
|
||||
|
||||
@ -64,7 +64,7 @@ ## Phase 0 Research
|
||||
- Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models.
|
||||
- Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page.
|
||||
- Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction.
|
||||
- No additional low-risk same-class hit is confirmed in planning; default implementation scope stays at the three named core surfaces unless implementation audit finds one trivial match that does not widen scope.
|
||||
- No additional same-class extra hit is confirmed in planning; default implementation scope stays fixed at the three named core surfaces unless the setup audit records a candidate that passes every FR-196-015 admission check without widening architecture or adding new persistence, routes, or shared abstractions.
|
||||
- Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer.
|
||||
|
||||
## Phase 1 Design
|
||||
@ -169,7 +169,8 @@ ### Phase 0.5 - Establish shared test and guard scaffolding
|
||||
Changes:
|
||||
|
||||
- Create the new focused test entry points for the dependency table component and required-permissions page table.
|
||||
- Extend shared guard coverage for new native page-table expectations and faux-control regressions.
|
||||
- Record the pre-implementation scope gate: unless the setup audit documents an FR-196-015 pass, scope is frozen to the three named core surfaces and no optional extra hit may begin.
|
||||
- Extend shared guard coverage for new native page-table expectations, faux-control regressions, and no-new-wrapper drift.
|
||||
- Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview.
|
||||
|
||||
Tests:
|
||||
@ -245,6 +246,7 @@ ### Phase D - Verification, guard alignment, and explicit scope stop
|
||||
- Run focused Sail verification for the modified feature, RBAC, and guard tests.
|
||||
- Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs.
|
||||
- Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed.
|
||||
- Verify the final implementation still satisfies the non-functional constraints: DB-only rendering, no new remote calls, no new persistence, and no asset or provider drift.
|
||||
- Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture.
|
||||
|
||||
Tests:
|
||||
@ -293,4 +295,4 @@ ## Implementation Order Recommendation
|
||||
2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes.
|
||||
3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes.
|
||||
4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands.
|
||||
5. Run the final focused verification pack, formatting, and release close-out last, and only then consider whether any optional same-class extra hit truly qualifies.
|
||||
5. Run the final focused verification pack, formatting, and release close-out last, and record whether the setup audit admitted any optional same-class extra hit. No new optional extra hit may enter scope after implementation has started.
|
||||
@ -19,6 +19,7 @@ ### 1. Prepare shared test and guard scaffolding
|
||||
Do:
|
||||
|
||||
- create the new focused surface-test entry points before story implementation starts
|
||||
- perform the setup audit that decides whether any optional same-class extra hit passes every FR-196-015 admission check before any extra hit begins; otherwise lock scope to the three named surfaces
|
||||
- add the shared guard expectations for new native page-table and faux-control regressions
|
||||
- add the shared mount-only query-seeding regression coverage that later story work depends on
|
||||
|
||||
@ -124,7 +125,7 @@ ### 7. Record the release close-out in this quickstart
|
||||
When implementation is complete, update this file with a short close-out note that records:
|
||||
|
||||
- which surfaces were actually cleaned
|
||||
- whether any optional same-class extra hit was included or explicitly rejected
|
||||
- whether any optional same-class extra hit was included or explicitly rejected, and if included, which FR-196-015 admission checks it satisfied
|
||||
- which related themes stayed out of scope and were deferred
|
||||
- which follow-up specs or artifacts were touched
|
||||
|
||||
@ -151,15 +152,26 @@ ## Suggested Test Pack
|
||||
|
||||
## Manual Smoke Checklist
|
||||
|
||||
1. Open an inventory item detail page and confirm dependency direction and relationship changes happen without a foreign apply-and-reload workflow.
|
||||
2. Open tenant required permissions and confirm the filter surface feels native, while summary counts, guidance, and copy flows remain correct.
|
||||
3. Open evidence overview and confirm the table behaves like a native Filament report with clear empty state and row inspect behavior.
|
||||
4. Confirm no cleaned surface leaks scope through query manipulation.
|
||||
5. Confirm no implementation expanded into monitoring-state, shell, or shared micro-UI redesign work.
|
||||
1. Open an inventory item detail page and confirm current record context stays visible, dependency direction and relationship changes happen in place without a foreign apply-and-reload workflow, missing-target markers remain visible where applicable, and empty-state copy still explains the no-results case.
|
||||
2. Open tenant required permissions and confirm current tenant scope and active filter state remain visible, the filter surface feels native, summary counts and freshness stay consistent with the visible rows, guidance remains available, and copy flows still use the same filtered state.
|
||||
3. Open evidence overview and confirm workspace scope and any active entitled-tenant filter remain visible, the table behaves like a native Filament report, artifact truth, freshness, and next-step context remain visible by default, `Clear filters` behaves correctly, and each authorized row still has one workspace-safe inspect path.
|
||||
4. Confirm no cleaned surface leaks scope, rows, counts, or drilldown targets through query manipulation.
|
||||
5. Confirm no implementation expanded into monitoring-state, shell, shared micro-UI redesign work, new wrapper layers, or new persistence created only to support the native controls.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- No migration is expected.
|
||||
- No polling change is expected.
|
||||
- No provider registration change is expected.
|
||||
- No new assets are expected.
|
||||
- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged.
|
||||
|
||||
## Release Close-Out
|
||||
|
||||
- Cleaned surfaces: inventory item dependency edges now run through the embedded `InventoryItemDependencyEdgesTable` component; tenant required permissions now uses one page-owned native table contract; evidence overview now uses one page-owned native workspace table.
|
||||
- Optional same-class extra hit decision: rejected. No additional nativity-bypass candidate passed every FR-196-015 admission check during setup, so scope remained frozen to the three named surfaces.
|
||||
- Deferred themes kept out of scope: monitoring page-state architecture, global shell or context redesign, shared micro-UI or wrapper abstractions, verification viewer families, and any new persistence or asset work.
|
||||
- Follow-up artifacts touched: this quickstart note, the Spec 196 task ledger, and the existing logical contract remained aligned without widening consumer scope.
|
||||
- Focused Sail verification pack: passed on 2026-04-14 with 45 tests and 177 assertions across the Spec 196 feature, guard, and unit coverage set.
|
||||
- Integrated-browser smoke sign-off: passed on `http://localhost` against tenant `19000000-0000-4000-8000-000000000191`, including an inventory detail fixture (`inventory-items/383`) and evidence fixture (`evidence/20`). Verified in-place dependency filters with visible active filter chips and missing-target hints, native required-permissions search plus technical-details matrix continuity, and evidence overview tenant prefilter plus `Clear filters` behavior with workspace-safe drilldown links.
|
||||
- Browser-log note: the integrated-browser session still contains old historical 419 and aborted-request noise from prior sessions, but no new Spec 196 surface-specific JavaScript failure blocked the smoke flow above.
|
||||
@ -177,12 +177,19 @@ ### Functional Requirements
|
||||
- **FR-196-012**: Evidence overview MUST provide one consistent inspect or open model for authorized rows and MUST preserve the current workspace-safe drilldown into tenant evidence.
|
||||
- **FR-196-013**: Evidence overview MUST remove manual page-body query and Blade wiring that exists only because the report table is hand-built, while preserving entitled tenant prefilter behavior.
|
||||
- **FR-196-014**: Evidence overview MUST preserve workspace boundary enforcement, entitled-tenant filtering, and deny-as-not-found behavior for users outside the workspace boundary.
|
||||
- **FR-196-015**: Any additional cleanup hit included under this spec MUST share the same unnecessary nativity bypass, remain low to medium complexity, add no new product semantics, and avoid shared-family, shell, monitoring-state, and special-visualization work.
|
||||
- **FR-196-015**: Any additional cleanup hit included under this spec MUST pass all of the following admission checks before implementation starts on that hit: it removes the same confirmed nativity-bypass problem class as the three core surfaces (primary GET-form controls, request-driven page-body state, or a hand-built primary table imitating native Filament behavior); it stays read-only and preserves current scope semantics; it can be completed by modifying existing files only, or by adding at most one narrow sibling view or component file with no new route, persistence, enum, or shared abstraction; it does not enter shared-family, shell, monitoring-state, diff, verification-report, or special-visualization work; and it is explicitly recorded in release close-out. If any admission check fails, the candidate is out of scope.
|
||||
- **FR-196-016**: Any discovered related surface that crosses into shared detail micro-UI, monitoring state, context shell, verification report, diff or settings viewer, restore preview or result layouts, or other declared non-goal families MUST be documented and deferred instead of partially refactored here.
|
||||
- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, or cross-page UI abstraction whose main purpose is to hide the same non-native contract.
|
||||
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before, with no loss of empty-state meaning, next-step clarity, scope signals, or inspect navigation.
|
||||
- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, cross-page UI abstraction, or service whose main purpose is to translate bespoke pseudo-native page state into native Filament primitives. If native Filament or Livewire page state can express the behavior directly, direct state wins.
|
||||
- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before. This requirement is satisfied only when the cleaned surface preserves all of the following, in the same primary surface where the operator is already working: inventory dependencies still shows current record context, direction and relationship scope labels, missing-target markers, meaningful empty-state copy, and safe inspect links; tenant required permissions still shows current tenant scope, active filter state, overall counts, freshness, guidance visibility, and copy payload behavior derived from the same normalized filter state; evidence overview still shows workspace scope, active entitled-tenant filter state, artifact truth, freshness, next-step context, clear-filter affordance, and one workspace-safe inspect path for authorized rows.
|
||||
- **FR-196-019**: Release close-out MUST list which surfaces were actually cleaned, which optional same-class low-risk hits were included, which related themes remained out of scope, and which follow-up specs were touched.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-196-001**: Render-time behavior for inventory dependencies, tenant required permissions, and evidence overview MUST remain DB-only and MUST NOT introduce new Microsoft Graph calls, external HTTP requests, or other remote runtime dependencies.
|
||||
- **NFR-196-002**: This cleanup MUST NOT add new persistence artifacts, including tables, persisted UI-state mirrors, materialized helper projections, or helper models whose only purpose is to support the new native controls.
|
||||
- **NFR-196-003**: This cleanup MUST NOT add polling, provider registration changes, or new global or on-demand asset requirements. Existing `bootstrap/providers.php` registration and current `filament:assets` deployment handling remain unchanged.
|
||||
- **NFR-196-004**: Implementation MUST stay inside the current Filament v5 and Livewire v4 page layer and current derived services unless a touched existing service needs a narrow adapter to keep one authoritative normalized filter state.
|
||||
|
||||
## 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 |
|
||||
@ -208,6 +215,9 @@ ### Measurable Outcomes
|
||||
- **SC-196-004**: Release validation finds zero primary plain HTML control surfaces on the three core pages whose only purpose is to imitate native admin controls.
|
||||
- **SC-196-005**: Deeplink and prefilter behaviors continue to work for the targeted routes without allowing unauthorized tenant scope changes or cross-tenant row leakage.
|
||||
- **SC-196-006**: Final close-out documentation explicitly records completed surfaces, deferred related themes, and any optional extra hits that were admitted under the shared rule.
|
||||
- **SC-196-007**: Inventory dependencies preserves current-record context, direction and relationship labels, missing-target markers, safe inspect links, and meaningful empty-state copy after native state adoption.
|
||||
- **SC-196-008**: Tenant required permissions preserves visible active filter state, counts, freshness, guidance, and copy payload behavior that stay internally consistent for the same tenant and the same normalized filter state.
|
||||
- **SC-196-009**: Evidence overview preserves visible workspace scope signals, entitled-tenant clear-filter behavior, artifact truth, freshness, next-step context, and one workspace-safe inspect path for every authorized row.
|
||||
|
||||
## Assumptions
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ # Tasks: Hard Filament Nativity Cleanup
|
||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/filament-nativity-cleanup.logical.openapi.yaml`
|
||||
|
||||
**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces.
|
||||
**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces and their DB-only, no-wrapper, and scope-safety constraints.
|
||||
**Operations**: This cleanup does not introduce new queued work or `OperationRun` flows. Existing linked follow-up paths remain unchanged.
|
||||
**RBAC**: Tenant-context, route-tenant, workspace-membership, and entitled-tenant boundaries remain authoritative. Non-members stay `404`, and no new destructive action is added.
|
||||
**UI Naming**: Keep existing operator terms stable: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step`.
|
||||
@ -18,8 +18,8 @@ ## Phase 1: Setup (Shared Review Inputs)
|
||||
|
||||
**Purpose**: Confirm the exact implementation entry points, native reference patterns, and focused regression baselines before editing the three in-scope surfaces.
|
||||
|
||||
- [ ] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`
|
||||
- [ ] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [X] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`, and record whether any optional extra candidate passes every FR-196-015 admission check; otherwise freeze scope to the three named surfaces
|
||||
- [X] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
@ -29,11 +29,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [ ] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||
- [ ] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables and faux-control regressions in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [ ] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
- [X] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`
|
||||
- [X] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables, faux-control regressions, and no-new-wrapper drift in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [X] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`
|
||||
|
||||
**Checkpoint**: The shared Spec 196 test harness is in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
|
||||
**Checkpoint**: The shared Spec 196 test harness and scope gate are in place, and later surface work can prove native state ownership without reopening scope or guard assumptions.
|
||||
|
||||
---
|
||||
|
||||
@ -47,13 +47,13 @@ ### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety
|
||||
- [ ] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation
|
||||
- [X] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety
|
||||
- [X] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services
|
||||
- [ ] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints
|
||||
- [X] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services
|
||||
- [X] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when inventory detail keeps the same dependency meaning and target safety without switching operators into a foreign apply-and-reload workflow.
|
||||
|
||||
@ -69,14 +69,14 @@ ### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values
|
||||
- [ ] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states
|
||||
- [ ] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||
- [X] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values
|
||||
- [X] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states
|
||||
- [X] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
|
||||
- [ ] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state
|
||||
- [X] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding
|
||||
- [X] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when required permissions behaves like one native Filament page without losing tenant authority, summary clarity, or follow-up guidance.
|
||||
|
||||
@ -92,13 +92,13 @@ ### Tests for User Story 3
|
||||
|
||||
> **NOTE**: Write these tests first and confirm they fail before implementation.
|
||||
|
||||
- [ ] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, entitled-tenant seed and clear behavior, workspace-safe row drilldown, empty states, and deny-as-not-found enforcement
|
||||
- [ ] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract
|
||||
- [X] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, visible workspace scope and active entitled-tenant filter state, artifact-truth and freshness and next-step row fields, entitled-tenant seed and clear behavior, workspace-safe row drilldown, clear-filter behavior, empty states, and deny-as-not-found enforcement
|
||||
- [X] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks, native filter and search state, entitled-tenant query seeding, and one inspect model
|
||||
- [ ] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy
|
||||
- [X] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks that preserve visible workspace scope, active entitled-tenant filter state, artifact-truth, freshness, and next-step row context, native filter and search state, entitled-tenant query seeding, clear-filter behavior, and one inspect model
|
||||
- [X] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when evidence overview reads like one native workspace review table without leaking unauthorized tenant scope or losing the current drilldown path.
|
||||
|
||||
@ -108,11 +108,11 @@ ## Phase 6: Polish & Cross-Cutting Verification
|
||||
|
||||
**Purpose**: Run the focused verification pack, format the touched files, and record the final bounded scope outcome for Spec 196.
|
||||
|
||||
- [ ] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`
|
||||
- [ ] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/`
|
||||
- [ ] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces and capture any sign-off notes needed for release close-out
|
||||
- [ ] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs
|
||||
- [ ] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, and non-goals in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
|
||||
- [X] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and confirm the pack still proves DB-only rendering, no-wrapper drift protection, and scope-safety invariants
|
||||
- [X] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/`
|
||||
- [X] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces, explicitly verifying current-context visibility, active filter or scope signals, empty-state meaning, guidance or next-step clarity, and inspect navigation, and capture any sign-off notes needed for release close-out
|
||||
- [X] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs
|
||||
- [X] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, non-goals, and non-functional constraints in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope
|
||||
|
||||
---
|
||||
|
||||
|
||||
35
specs/205-compare-job-cleanup/checklists/requirements.md
Normal file
35
specs/205-compare-job-cleanup/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-14
|
||||
**Feature**: [spec.md](../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
|
||||
|
||||
- Validation passed on 2026-04-14 after the initial drafting pass.
|
||||
- The feature is an internal cleanup, so user value is expressed through architectural honesty, review speed, and regression safety rather than a new operator-facing workflow.
|
||||
@ -0,0 +1,273 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Compare Job Legacy Drift Cleanup Internal Contract
|
||||
version: 0.1.0
|
||||
summary: Internal logical contract for the unchanged baseline compare start and execution path after legacy drift deletion
|
||||
description: |
|
||||
This contract is an internal planning artifact for Spec 205. No new HTTP
|
||||
controllers or routes are introduced. The paths below identify logical
|
||||
service, job, and guard boundaries that must remain true after the dead
|
||||
pre-strategy drift path is removed from CompareBaselineToTenantJob.
|
||||
x-logical-artifact: true
|
||||
x-compare-job-cleanup-consumers:
|
||||
- surface: baseline.compare.start
|
||||
sourceFiles:
|
||||
- apps/platform/app/Services/Baselines/BaselineCompareService.php
|
||||
- apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
mustRemainTrue:
|
||||
- compare_start_remains_enqueue_only
|
||||
- deterministic_strategy_selection_recorded_in_run_context
|
||||
- no_legacy_compare_fallback_at_start
|
||||
- surface: baseline.compare.execution
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
- apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php
|
||||
- apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php
|
||||
mustConsume:
|
||||
- supported_strategy_selection
|
||||
- strategy_compare_result
|
||||
- normalized_strategy_subject_results
|
||||
- no_legacy_compute_drift_fallback
|
||||
- surface: baseline.compare.findings
|
||||
sourceFiles:
|
||||
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
mustRemainTrue:
|
||||
- finding_lifecycle_unchanged
|
||||
- summary_and_gap_counts_derived_from_strategy_results
|
||||
- warning_outcomes_unchanged
|
||||
- reason_translation_unchanged
|
||||
- operation_run_completion_semantics_unchanged
|
||||
- surface: baseline.compare.guard
|
||||
sourceFiles:
|
||||
- apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
- apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
mustEnforce:
|
||||
- removed_legacy_methods_stay_absent
|
||||
- orchestration_file_has_one_compare_engine
|
||||
- surface: baseline.compare.run-guards
|
||||
sourceFiles:
|
||||
- apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
- apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
- apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
- apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
mustEnforce:
|
||||
- baseline_compare_run_lifecycle_semantics_unchanged
|
||||
- summary_count_keys_remain_whitelisted
|
||||
- compare_run_context_updates_remain_valid
|
||||
paths:
|
||||
/internal/tenants/{tenant}/baseline-profiles/{profile}/compare:
|
||||
post:
|
||||
summary: Start baseline compare using the existing strategy-selected flow only
|
||||
operationId: startBaselineCompareWithoutLegacyFallback
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: profile
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchRequest'
|
||||
responses:
|
||||
'202':
|
||||
description: Compare accepted and queued with the strategy-owned execution path only
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-run+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareLaunchEnvelope'
|
||||
'422':
|
||||
description: Existing unsupported or mixed-scope preconditions prevented compare from starting
|
||||
'403':
|
||||
description: Actor is in scope but lacks compare-start capability
|
||||
'404':
|
||||
description: Tenant or baseline profile is outside actor scope
|
||||
/internal/operation-runs/{run}/baseline-compare/execute:
|
||||
post:
|
||||
summary: Execute baseline compare through strategy selection and strategy compare only
|
||||
operationId: executeBaselineCompareJobWithoutLegacyFallback
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing compare run completed through the strategy-owned path with no legacy drift fallback
|
||||
content:
|
||||
application/vnd.tenantpilot.baseline-compare-execution+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CompareExecutionEnvelope'
|
||||
'409':
|
||||
description: Existing snapshot, coverage, or strategy preconditions blocked execution
|
||||
/internal/guards/baseline-compare/no-legacy-drift:
|
||||
get:
|
||||
summary: Static invariant proving the orchestration file no longer retains the pre-strategy drift implementation
|
||||
operationId: assertNoLegacyBaselineCompareJobPath
|
||||
responses:
|
||||
'200':
|
||||
description: Guard passes because the removed legacy methods are absent from the compare job
|
||||
content:
|
||||
application/vnd.tenantpilot.compare-job-guard+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LegacyDriftGuardResult'
|
||||
components:
|
||||
schemas:
|
||||
CompareLaunchRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- baseline_snapshot_id
|
||||
- effective_scope
|
||||
properties:
|
||||
baseline_snapshot_id:
|
||||
type: integer
|
||||
effective_scope:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
origin:
|
||||
type: string
|
||||
enum:
|
||||
- tenant_profile
|
||||
- compare_matrix
|
||||
- other_existing_surface
|
||||
SupportedStrategySelection:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- selection_state
|
||||
- strategy_key
|
||||
- operator_reason
|
||||
properties:
|
||||
selection_state:
|
||||
type: string
|
||||
enum:
|
||||
- supported
|
||||
strategy_key:
|
||||
type: string
|
||||
example: intune_policy
|
||||
operator_reason:
|
||||
type: string
|
||||
diagnostics:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CompareLaunchEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- run_id
|
||||
- operation_type
|
||||
- execution_mode
|
||||
- selected_strategy
|
||||
- legacy_drift_path_present
|
||||
properties:
|
||||
run_id:
|
||||
type: integer
|
||||
operation_type:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_compare
|
||||
execution_mode:
|
||||
type: string
|
||||
enum:
|
||||
- queued
|
||||
selected_strategy:
|
||||
$ref: '#/components/schemas/SupportedStrategySelection'
|
||||
legacy_drift_path_present:
|
||||
type: boolean
|
||||
const: false
|
||||
CompareExecutionEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- run_id
|
||||
- compare_source
|
||||
- selected_strategy_key
|
||||
- no_legacy_compute_drift
|
||||
- persisted_truths
|
||||
properties:
|
||||
run_id:
|
||||
type: integer
|
||||
compare_source:
|
||||
type: string
|
||||
enum:
|
||||
- strategy_only
|
||||
selected_strategy_key:
|
||||
type: string
|
||||
example: intune_policy
|
||||
no_legacy_compute_drift:
|
||||
type: boolean
|
||||
const: true
|
||||
persisted_truths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- operation_runs
|
||||
- findings
|
||||
- baseline_compare.context
|
||||
outputs_preserved:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
finding_lifecycle:
|
||||
type: boolean
|
||||
const: true
|
||||
summary_counts:
|
||||
type: boolean
|
||||
const: true
|
||||
gap_handling:
|
||||
type: boolean
|
||||
const: true
|
||||
warning_outcomes:
|
||||
type: boolean
|
||||
const: true
|
||||
reason_translation:
|
||||
type: boolean
|
||||
const: true
|
||||
run_completion:
|
||||
type: boolean
|
||||
const: true
|
||||
LegacyDriftGuardResult:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- status
|
||||
- compare_job_path
|
||||
- forbidden_method_names
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- pass
|
||||
compare_job_path:
|
||||
type: string
|
||||
example: apps/platform/app/Jobs/CompareBaselineToTenantJob.php
|
||||
forbidden_method_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- computeDrift
|
||||
- effectiveBaselineHash
|
||||
- resolveBaselinePolicyVersionId
|
||||
- selectSummaryKind
|
||||
- buildDriftEvidenceContract
|
||||
- buildRoleDefinitionEvidencePayload
|
||||
- resolveRoleDefinitionVersion
|
||||
- fallbackRoleDefinitionNormalized
|
||||
- roleDefinitionChangedKeys
|
||||
- roleDefinitionPermissionKeys
|
||||
- resolveRoleDefinitionDiff
|
||||
- severityForRoleDefinitionDiff
|
||||
invariant:
|
||||
type: string
|
||||
example: compare orchestration retains one live strategy-driven execution path
|
||||
131
specs/205-compare-job-cleanup/data-model.md
Normal file
131
specs/205-compare-job-cleanup/data-model.md
Normal file
@ -0,0 +1,131 @@
|
||||
# Data Model: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new top-level persisted entity and no new runtime or product-facing contract. It removes an obsolete implementation branch from `CompareBaselineToTenantJob` and preserves the existing persisted truths and compare contracts that already drive the live strategy-based compare flow. The OpenAPI document in `contracts/` is a planning-only logical artifact that records invariants for this cleanup; it does not define a new runtime integration surface.
|
||||
|
||||
## Existing Persisted Truth Reused Without Change
|
||||
|
||||
### Workspace-owned baseline truth
|
||||
|
||||
- `baseline_profiles`
|
||||
- `baseline_snapshots`
|
||||
- `baseline_snapshot_items`
|
||||
- Canonical baseline scope payload already stored in profile and run context
|
||||
|
||||
These remain the baseline reference truth that compare reads.
|
||||
|
||||
### Tenant-owned current-state and operational truth
|
||||
|
||||
- `inventory_items`
|
||||
- `operation_runs` for `baseline_compare`
|
||||
- findings written by the baseline compare lifecycle
|
||||
- existing run-context JSON such as `baseline_compare`, `findings`, and `result`
|
||||
|
||||
These remain the long-lived operational truths written or consumed by compare.
|
||||
|
||||
### Existing evidence inputs reused without change
|
||||
|
||||
- policy-version content evidence
|
||||
- inventory meta evidence
|
||||
- current-state hash resolution
|
||||
- coverage and gap context already recorded in the compare run
|
||||
|
||||
Spec 205 changes none of these inputs; it only removes a dead alternate computation path.
|
||||
|
||||
## Existing Internal Contracts Preserved
|
||||
|
||||
### Compare orchestration path
|
||||
|
||||
The live orchestration path remains:
|
||||
|
||||
1. `CompareBaselineToTenantJob::handle()`
|
||||
2. `CompareStrategyRegistry::select(...)`
|
||||
3. `CompareStrategyRegistry::resolve(...)`
|
||||
4. `strategy->compare(...)`
|
||||
5. `normalizeStrategySubjectResults(...)`
|
||||
6. finding upsert, summary aggregation, gap handling, and run completion
|
||||
|
||||
No new branch, fallback path, or second engine is introduced.
|
||||
|
||||
### CompareStrategySelection
|
||||
|
||||
Existing selection metadata remains unchanged and continues to be written into the compare run context.
|
||||
|
||||
| Field | Purpose | Change in Spec 205 |
|
||||
|------|---------|--------------------|
|
||||
| `selection_state` | Supported vs unsupported strategy state | unchanged |
|
||||
| `strategy_key` | Active compare strategy family | unchanged |
|
||||
| `diagnostics` | Secondary strategy selection detail | unchanged |
|
||||
|
||||
### CompareOrchestrationContext
|
||||
|
||||
Existing strategy input context remains unchanged.
|
||||
|
||||
| Field | Purpose | Change in Spec 205 |
|
||||
|------|---------|--------------------|
|
||||
| `workspace_id` | Workspace scope for compare run | unchanged |
|
||||
| `tenant_id` | Tenant scope for compare run | unchanged |
|
||||
| `baseline_profile_id` | Baseline profile reference | unchanged |
|
||||
| `baseline_snapshot_id` | Snapshot reference | unchanged |
|
||||
| `operation_run_id` | Run identity | unchanged |
|
||||
| `normalized_scope` | Canonical scope payload | unchanged |
|
||||
| `coverage_context` | Coverage and unsupported-type context | unchanged |
|
||||
|
||||
### CompareSubjectResult and CompareFindingCandidate
|
||||
|
||||
Existing per-subject compare results and finding projection contracts remain unchanged.
|
||||
|
||||
| Contract | Purpose | Change in Spec 205 |
|
||||
|----------|---------|--------------------|
|
||||
| `CompareSubjectResult` | Strategy-owned per-subject compare outcome | unchanged |
|
||||
| `CompareFindingCandidate` | Strategy-neutral finding mutation payload | unchanged |
|
||||
|
||||
### OperationRun compare context
|
||||
|
||||
The compare run continues to record current strategy, evidence coverage, gap counts, fidelity, reason translation, and result summaries inside the existing context structure. Spec 205 does not add, remove, or rename run-context fields.
|
||||
|
||||
### Finding lifecycle output
|
||||
|
||||
Finding severity, change type, recurrence key, evidence fidelity, timestamps, reopen behavior, and auto-close behavior remain unchanged. Spec 205 only preserves the live path that already feeds these outputs.
|
||||
|
||||
## Deleted Internal Cluster
|
||||
|
||||
Current repository inspection confirms one dead implementation cluster anchored by `computeDrift()` inside `CompareBaselineToTenantJob`, plus exclusive helpers clustered beneath it. The current candidate delete set includes:
|
||||
|
||||
- `computeDrift()`
|
||||
- `effectiveBaselineHash()`
|
||||
- `resolveBaselinePolicyVersionId()`
|
||||
- `selectSummaryKind()`
|
||||
- `buildDriftEvidenceContract()`
|
||||
- `buildRoleDefinitionEvidencePayload()`
|
||||
- `resolveRoleDefinitionVersion()`
|
||||
- `fallbackRoleDefinitionNormalized()`
|
||||
- `roleDefinitionChangedKeys()`
|
||||
- `roleDefinitionPermissionKeys()`
|
||||
- `resolveRoleDefinitionDiff()`
|
||||
- `severityForRoleDefinitionDiff()`
|
||||
|
||||
The final delete list is confirmed by call-graph inspection during implementation. Any method still used by the live orchestration path remains out of scope.
|
||||
|
||||
## Relationships
|
||||
|
||||
- One `baseline_compare` run selects one supported strategy.
|
||||
- One selected strategy processes many compare subjects.
|
||||
- One `CompareSubjectResult` may yield zero or one `CompareFindingCandidate`.
|
||||
- Existing finding and summary writers consume the strategy result contracts directly.
|
||||
- The legacy drift cluster is not part of any required runtime relationship after Spec 203 and is therefore removed.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. `CompareBaselineToTenantJob::handle()` must not call `computeDrift()` or any helper used exclusively by that legacy path.
|
||||
2. Compare execution must continue to run through strategy selection, strategy resolution, and `strategy->compare(...)`.
|
||||
3. Existing `OperationRun` status, outcome, summary-count, and context semantics must remain unchanged.
|
||||
4. Existing finding lifecycle behavior must remain driven by normalized strategy subject results.
|
||||
5. No new persistence, contract, or state family may be introduced as part of the cleanup.
|
||||
|
||||
## State Transitions
|
||||
|
||||
No new state transition is introduced.
|
||||
|
||||
Existing compare run transitions such as queued -> running -> completed or blocked remain unchanged, and finding lifecycle transitions remain governed by the current writers and services.
|
||||
198
specs/205-compare-job-cleanup/plan.md
Normal file
198
specs/205-compare-job-cleanup/plan.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Implementation Plan: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Branch**: `205-compare-job-cleanup` | **Date**: 2026-04-14 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/spec.md`
|
||||
|
||||
**Note**: This plan treats Spec 205 as a mechanical closure cleanup. It removes only the dead pre-strategy drift-compute path from `CompareBaselineToTenantJob`, keeps the current strategy-driven compare execution unchanged, and uses focused regression plus guard coverage to prove no behavior drift.
|
||||
|
||||
## Summary
|
||||
|
||||
Delete `computeDrift()` and its exclusive helper cluster from `CompareBaselineToTenantJob`, preserve the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path, remove dead imports and misleading internal descriptions that survive only because of the retained legacy block, and verify unchanged behavior through focused compare execution, finding, and guard tests.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CurrentStateHashResolver`, and current finding lifecycle services
|
||||
**Storage**: PostgreSQL via existing baseline snapshots, baseline snapshot items, inventory items, `operation_runs`, findings, and current run-context JSON; no new storage planned
|
||||
**Testing**: Pest feature and guard tests run through Laravel Sail, with focused compare execution and file-content guard coverage
|
||||
**Target Platform**: Laravel web application under `apps/platform` with queue-backed compare execution in Sail/Docker
|
||||
**Project Type**: web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||
**Performance Goals**: Preserve current compare start latency and compare job throughput, add no new remote calls or DB writes, and keep operator-facing compare and monitoring surfaces behaviorally unchanged
|
||||
**Constraints**: No behavior change, no new abstraction or persistence, no operator-facing surface changes, no `OperationRun` lifecycle changes, and keep the PR mechanically small and reviewable
|
||||
**Scale/Scope**: One queued compare job, one compare start service boundary, one active strategy registry, one active Intune strategy, existing finding and run writers, and a small focused regression slice
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing because the feature removes code without altering scope, auth, persistence, or UI contracts.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Inventory-first / snapshots-second | PASS | PASS | Compare still reads existing workspace baseline snapshots and inventory-backed current state; no new compare truth is introduced. |
|
||||
| Read/write separation | PASS | PASS | Existing compare runs still write only current run, finding, and audit truth; the cleanup adds no new write path. |
|
||||
| Graph contract path | PASS | PASS | No new Microsoft Graph path or contract is introduced. |
|
||||
| Deterministic capabilities | PASS | PASS | Existing strategy selection and capability behavior remain unchanged because the registry and strategy classes stay intact. |
|
||||
| Workspace + tenant isolation | PASS | PASS | No workspace, tenant, or route-scope behavior changes are planned. |
|
||||
| RBAC-UX authorization semantics | PASS | PASS | No authorization rules, capability checks, or cross-plane behavior are changed. |
|
||||
| Run observability / Ops-UX | PASS | PASS | Existing `baseline_compare` run creation, summary counts, and completion semantics remain authoritative and unchanged. |
|
||||
| Data minimization | PASS | PASS | No new persisted diagnostics or helper truth is added; dead internal code is removed instead. |
|
||||
| Proportionality / anti-bloat | PASS | PASS | The feature deletes an obsolete path and introduces no new structure. |
|
||||
| No premature abstraction | PASS | PASS | No new factory, resolver, registry, strategy, or support layer is introduced. |
|
||||
| Persisted truth / behavioral state | PASS | PASS | No new table, stored artifact, status family, or reason family is added. |
|
||||
| UI semantics / few layers | PASS | PASS | No new presentation layer or surface behavior is introduced. |
|
||||
| Filament v5 / Livewire v4 compliance | PASS | PASS | No Filament or Livewire API changes are part of this cleanup. |
|
||||
| Provider registration location | PASS | PASS | No panel or provider change is required; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||
| Global search hard rule | PASS | PASS | No searchable resource or search behavior is touched. |
|
||||
| Destructive action safety | PASS | PASS | No destructive action is added or changed. |
|
||||
| Asset strategy | PASS | PASS | No new assets are introduced and existing `filament:assets` deployment behavior remains unchanged. |
|
||||
|
||||
## Filament-Specific Compliance Notes
|
||||
|
||||
- **Livewire v4.0+ compliance**: Unchanged. The feature touches no Filament surface or Livewire component and does not introduce legacy APIs.
|
||||
- **Provider registration location**: Unchanged. If any panel/provider review is needed later, Laravel 11+ still requires `bootstrap/providers.php`.
|
||||
- **Global search**: No globally searchable resource is added or changed.
|
||||
- **Destructive actions**: No destructive action is introduced; existing confirmation and authorization rules remain untouched.
|
||||
- **Asset strategy**: No new panel or shared assets are required. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
- **Testing plan**: Focus on the required compare cleanup pack only: compare execution and findings regressions, gap and reason-code coverage, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, existing `OperationRun` and summary-count guards, and the enqueue-path matrix action regression. No new page, widget, relation manager, or action surface coverage is required.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Treat the current `CompareStrategyRegistry` -> `IntuneCompareStrategy` execution path as the only supported compare engine.
|
||||
- Delete the dead `computeDrift()` cluster rather than retaining it as deprecated or archived code.
|
||||
- Preserve `CompareSubjectResult`, finding upsert, summary aggregation, gap handling, and run completion semantics exactly as they currently operate through the live strategy path.
|
||||
- Use one focused compare pack covering execution fidelity, finding lifecycle, gap and reason outcomes, `OperationRun` lifecycle guards, summary-count guards, and the no-legacy orchestration guard as the minimum reliable regression slice.
|
||||
- Keep the contract artifact logical and internal, documenting invariants of the unchanged execution boundary instead of inventing a new external API.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/205-compare-job-cleanup/`:
|
||||
|
||||
- `research.md`: cleanup decisions, rationale, and rejected alternatives
|
||||
- `data-model.md`: existing persisted truth and internal compare contracts preserved by the cleanup
|
||||
- `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`: logical internal contract for the unchanged compare start and execution boundaries plus the no-legacy guard invariant
|
||||
- `quickstart.md`: implementation and verification order for the cleanup
|
||||
|
||||
Design decisions:
|
||||
|
||||
- `CompareBaselineToTenantJob` remains the compare execution entry point, but only the live orchestration methods stay after cleanup.
|
||||
- `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareOrchestrationContext`, `CompareSubjectResult`, and `CompareFindingCandidate` remain reused unchanged.
|
||||
- Existing persisted truth in baseline snapshots, inventory, findings, and `operation_runs` remains authoritative; no migration or compatibility layer is added.
|
||||
- Guard coverage remains the explicit enforcement point preventing legacy drift computation from re-entering the orchestration file.
|
||||
- Existing `OperationRun` lifecycle and summary-count guards remain part of the required verification surface because the cleanup still edits the compare executor.
|
||||
- No route, UI, RBAC, or `OperationRun` design change is planned.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/205-compare-job-cleanup/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── spec.md
|
||||
├── contracts/
|
||||
│ └── compare-job-legacy-drift-cleanup.logical.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Jobs/
|
||||
│ │ └── CompareBaselineToTenantJob.php
|
||||
│ ├── Services/
|
||||
│ │ └── Baselines/
|
||||
│ │ ├── BaselineCompareService.php
|
||||
│ │ ├── CurrentStateHashResolver.php
|
||||
│ │ └── Evidence/
|
||||
│ └── Support/
|
||||
│ └── Baselines/
|
||||
│ └── Compare/
|
||||
│ ├── CompareStrategyRegistry.php
|
||||
│ ├── CompareStrategySelection.php
|
||||
│ ├── CompareSubjectResult.php
|
||||
│ ├── CompareFindingCandidate.php
|
||||
│ └── IntuneCompareStrategy.php
|
||||
└── tests/
|
||||
├── Feature/
|
||||
│ ├── BaselineDriftEngine/
|
||||
│ │ └── FindingFidelityTest.php
|
||||
│ ├── Baselines/
|
||||
│ │ ├── BaselineCompareFindingsTest.php
|
||||
│ │ ├── BaselineCompareGapClassificationTest.php
|
||||
│ │ ├── BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
│ │ └── BaselineCompareMatrixCompareAllActionTest.php
|
||||
│ └── Guards/
|
||||
│ ├── Spec116OneEngineGuardTest.php
|
||||
│ ├── Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
│ └── OperationLifecycleOpsUxGuardTest.php
|
||||
│ ├── Operations/
|
||||
│ │ └── BaselineOperationRunGuardTest.php
|
||||
│ └── OpsUx/
|
||||
│ ├── OperationSummaryKeysSpecTest.php
|
||||
│ └── SummaryCountsWhitelistTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the cleanup inside the existing compare orchestration file and current compare regression surfaces. No new namespace, support layer, or package structure is introduced.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution exception or complexity justification is required. Spec 205 removes an obsolete implementation branch and introduces no new persistence, abstraction, state family, or semantic framework.
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
Not triggered. This feature introduces no new enum or status family, DTO or presenter layer, persisted artifact, interface or registry, or cross-domain taxonomy.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Confirm dead call graph
|
||||
|
||||
- Confirm that `handle()` no longer reaches `computeDrift()` or its candidate helper cluster.
|
||||
- Confirm that the live path still runs through strategy selection, strategy resolution, `strategy->compare(...)`, normalization, finding upsert, and run completion.
|
||||
- Record the final helper delete list before editing to avoid removing shared orchestration methods.
|
||||
|
||||
### Phase B - Delete the legacy drift cluster
|
||||
|
||||
- Remove `computeDrift()` and every helper method used exclusively by that legacy path.
|
||||
- Remove only the imports, comments, and internal descriptions that become dead because of the delete.
|
||||
- Keep shared orchestration helpers such as evidence resolution, result normalization, summary aggregation, gap merging, and finding lifecycle methods untouched.
|
||||
|
||||
### Phase C - Preserve the live orchestration contract
|
||||
|
||||
- Leave `BaselineCompareService`, `CompareStrategyRegistry`, and `IntuneCompareStrategy` behavior unchanged unless a direct compile or test failure requires a minimal follow-up.
|
||||
- Preserve the existing `baseline_compare` run context shape, summary count rules, gap handling, reason translation, and finding lifecycle semantics.
|
||||
- Avoid any naming sweep, contract redesign, or opportunistic cleanup outside the dead cluster.
|
||||
|
||||
### Phase D - Guard and regression verification
|
||||
|
||||
- Keep or tighten the existing no-legacy guard so the removed path cannot silently re-enter the orchestration file.
|
||||
- Run focused compare execution, gap and reason-code regression, and `OperationRun` lifecycle and summary-count guard tests to prove the delete is mechanically safe.
|
||||
- Format the touched PHP files with Pint after the cleanup is implemented.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| A supposedly dead helper is still used by the live orchestration path | High | Medium | Confirm call sites before deletion and keep the initial regression pack focused on execution, findings, gap and reason outcomes, and run-lifecycle guards. |
|
||||
| The cleanup grows into a broader refactor while the job file is open | Medium | Medium | Constrain edits to dead methods, direct import fallout, and guard or test changes required by the delete. |
|
||||
| Existing guard tests are too weak or too token-specific to prevent reintroduction | Medium | Medium | Reuse `Spec118NoLegacyBaselineDriftGuardTest` and extend it with the removed method names only if the current assertions do not cover the dead-path cluster clearly enough. |
|
||||
| A regression test depends on deleted internal structure rather than behavior | Medium | Low | Update such tests to assert live compare outcomes and orchestration invariants rather than private helper presence. |
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Run `tests/Feature/BaselineDriftEngine/FindingFidelityTest.php` as the primary execution and evidence-fidelity regression slice.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareFindingsTest.php` to protect finding generation, recurrence, summary counts, and run completion outcomes.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php` to protect gap handling, warning outcomes, and reason translation behavior.
|
||||
- Run `tests/Feature/Guards/Spec116OneEngineGuardTest.php` to keep the one-engine orchestration invariant explicit while the dead fallback cluster is removed.
|
||||
- Run `tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `tests/Feature/OpsUx/SummaryCountsWhitelistTest.php` to keep `OperationRun` lifecycle and summary-count guarantees intact.
|
||||
- Run `tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` to lock the orchestration boundary against legacy drift helper re-entry.
|
||||
- Run `tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php` as the required enqueue-path regression slice for the focused cleanup pack.
|
||||
- Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the code change.
|
||||
101
specs/205-compare-job-cleanup/quickstart.md
Normal file
101
specs/205-compare-job-cleanup/quickstart.md
Normal file
@ -0,0 +1,101 @@
|
||||
# Quickstart: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the obsolete pre-strategy drift-compute cluster from `CompareBaselineToTenantJob` while keeping the current strategy-driven compare workflow, finding lifecycle, and run semantics unchanged.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Work on branch `205-compare-job-cleanup`.
|
||||
2. Ensure the platform containers are available:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
3. Keep Spec 203's strategy extraction artifacts available because the cleanup assumes that strategy-driven compare execution is already the live path.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
### 1. Confirm the live call graph before editing
|
||||
|
||||
Verify the current live path and the candidate legacy cluster:
|
||||
|
||||
```bash
|
||||
cd apps/platform && rg -n "compareStrategyRegistry->select|compareStrategyRegistry->resolve|strategy->compare" app/Jobs/CompareBaselineToTenantJob.php app/Services/Baselines/BaselineCompareService.php
|
||||
cd apps/platform && rg -n "computeDrift|effectiveBaselineHash|resolveBaselinePolicyVersionId|selectSummaryKind|buildDriftEvidenceContract|buildRoleDefinitionEvidencePayload|resolveRoleDefinitionVersion|fallbackRoleDefinitionNormalized|roleDefinitionChangedKeys|roleDefinitionPermissionKeys|resolveRoleDefinitionDiff|severityForRoleDefinitionDiff" app/Jobs/CompareBaselineToTenantJob.php
|
||||
```
|
||||
|
||||
If additional exclusive helpers are found adjacent to the dead cluster, add them to the delete list only after confirming they are not used by the live path.
|
||||
|
||||
### 2. Lock the current behavior with the focused regression slice
|
||||
|
||||
Run the minimum reliable compare pack before deleting anything:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
```
|
||||
|
||||
Run the enqueue-path slice as part of the required focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
```
|
||||
|
||||
### 3. Delete the legacy drift cluster only
|
||||
|
||||
Remove:
|
||||
|
||||
- `computeDrift()`
|
||||
- helper methods used exclusively by that path
|
||||
- imports and internal descriptions that only exist because of those methods
|
||||
|
||||
Do not redesign `CompareStrategyRegistry`, `IntuneCompareStrategy`, run-context shapes, or finding lifecycle behavior while the job file is open.
|
||||
|
||||
### 4. Tighten or preserve the no-legacy guard
|
||||
|
||||
If the current guard does not explicitly block the removed helper names, extend it minimally so CI fails if the legacy drift cluster reappears in `CompareBaselineToTenantJob`.
|
||||
|
||||
### 5. Re-run the focused regression slice
|
||||
|
||||
After the delete, re-run the same focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/FindingFidelityTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareGapClassificationTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec116OneEngineGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations/BaselineOperationRunGuardTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
```
|
||||
|
||||
Re-run the enqueue-path slice as part of the same focused pack:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php
|
||||
```
|
||||
|
||||
## Final Validation
|
||||
|
||||
1. Format touched PHP files:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
2. Re-check that the live compare path still flows through strategy selection and `strategy->compare(...)`.
|
||||
3. Confirm the compare run still completes with the same operator-visible outcome, gap and warning semantics, reason translation, and finding behavior as before.
|
||||
4. Keep the PR limited to dead-path deletion, direct fallout cleanup, and the minimal regression or guard updates required by the delete.
|
||||
41
specs/205-compare-job-cleanup/research.md
Normal file
41
specs/205-compare-job-cleanup/research.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Research: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
## Decision 1: Treat the strategy-driven compare path as the only authoritative execution engine
|
||||
|
||||
- **Decision**: Use the existing `CompareStrategyRegistry` -> `IntuneCompareStrategy` path as the sole supported compare execution boundary.
|
||||
- **Rationale**: Current code inspection shows `CompareBaselineToTenantJob::handle()` selecting a strategy, resolving it, and calling `strategy->compare(...)` before normalizing subject results and writing findings. No productive call path from `handle()` reaches the retained monolithic `computeDrift()` block.
|
||||
- **Alternatives considered**:
|
||||
- Keep the legacy block as a documented fallback. Rejected because it leaves the file structurally dishonest and suggests a second engine still exists.
|
||||
- Add a feature flag between strategy and legacy execution. Rejected because there is no legitimate second execution mode left to preserve.
|
||||
|
||||
## Decision 2: Delete the dead drift cluster instead of archiving or deprecating it
|
||||
|
||||
- **Decision**: Remove `computeDrift()` and its exclusive helper cluster directly from `CompareBaselineToTenantJob`.
|
||||
- **Rationale**: The retained cluster duplicates pre-strategy compare logic that has already been extracted into the active strategy implementation. Keeping it in place continues to mislead reviewers and inflates the orchestration file without operational value.
|
||||
- **Alternatives considered**:
|
||||
- Move the dead methods to a trait or archive class. Rejected because it preserves confusion and ownership cost without any runtime benefit.
|
||||
- Leave the methods in place with a deprecation comment. Rejected because dead code still obscures the real call graph even when labeled.
|
||||
|
||||
## Decision 3: Preserve existing compare contracts, findings, and run semantics unchanged
|
||||
|
||||
- **Decision**: Keep `CompareStrategyRegistry`, `IntuneCompareStrategy`, `CompareStrategySelection`, `CompareSubjectResult`, `CompareFindingCandidate`, existing finding writers, and existing run context semantics unchanged.
|
||||
- **Rationale**: Spec 205 is a closure cleanup, not a second strategy extraction spec. The safest path is deletion of dead code while leaving the live contracts and persisted truths untouched.
|
||||
- **Alternatives considered**:
|
||||
- Fold in additional compare refactors while editing the job. Rejected because that turns a narrow cleanup into a mixed review.
|
||||
- Rename or reframe current compare contracts for symmetry. Rejected because it is unrelated to dead-path removal.
|
||||
|
||||
## Decision 4: Use a focused compare plus run-guard pack as the minimum regression slice
|
||||
|
||||
- **Decision**: Validate the cleanup with `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, `Spec118NoLegacyBaselineDriftGuardTest`, and `BaselineCompareMatrixCompareAllActionTest` as the required focused regression pack.
|
||||
- **Rationale**: `FindingFidelityTest` exercises the compare execution path and evidence selection behavior, `BaselineCompareFindingsTest` protects finding lifecycle and summary outcomes, the gap and reason-code tests protect warning and reason semantics, `Spec116OneEngineGuardTest` keeps the one-engine orchestration invariant explicit, the `OperationRun` and summary-count guards protect lifecycle invariants, and the legacy guard keeps helper re-entry visible in CI. Together they provide high confidence for a mechanical delete without requiring a broad slow suite.
|
||||
- **Alternatives considered**:
|
||||
- Run the full baseline compare suite for every cleanup iteration. Rejected as optional rather than required for a small internal delete.
|
||||
- Skip targeted tests and rely only on formatting or static inspection. Rejected as insufficient confidence.
|
||||
|
||||
## Decision 5: Keep the planning artifacts logical and invariant-focused
|
||||
|
||||
- **Decision**: Document the cleanup through a logical internal contract and a no-new-entity data model rather than inventing cleanup-specific services, APIs, or persistence.
|
||||
- **Rationale**: The plan workflow still needs explicit design artifacts, but Spec 205 adds no new feature surface. The correct documentation shape is therefore an invariant record of the unchanged compare boundaries after dead-code deletion.
|
||||
- **Alternatives considered**:
|
||||
- Skip the contract artifact entirely because no new endpoint exists. Rejected because the planning workflow requires a contract deliverable.
|
||||
- Invent a cleanup-specific service or endpoint in the design docs. Rejected because it would introduce fake architecture not warranted by the spec.
|
||||
162
specs/205-compare-job-cleanup/spec.md
Normal file
162
specs/205-compare-job-cleanup/spec.md
Normal file
@ -0,0 +1,162 @@
|
||||
# Feature Specification: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Feature Branch**: `205-compare-job-cleanup`
|
||||
**Created**: 2026-04-14
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Compare Job Legacy Drift Path Cleanup"
|
||||
|
||||
- **Type**: Cleanup / closure hardening
|
||||
- **Priority**: Medium
|
||||
- **Depends on**: Spec 203 - Baseline Compare Engine Strategy Extraction
|
||||
- **Related to**: Spec 202 - Governance Subject Taxonomy and Baseline Scope V2; Spec 204 - Platform Core Vocabulary Hardening
|
||||
- **Recommended timing**: Immediate close-out before the next expansion-focused strand
|
||||
- **Blocks**: No strategic work
|
||||
- **Does not block**: Further platform work if completed as a short closure PR
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: The baseline compare orchestration unit still retains an obsolete pre-strategy drift computation block that no longer reflects how compare execution actually works.
|
||||
- **Today's failure**: Contributors and reviewers must spend time proving which compare path is live, while architecture audits still see false monolithic coupling that has already been structurally replaced.
|
||||
- **User-visible improvement**: No operator workflow changes, but the codebase becomes more trustworthy, easier to audit, and faster to maintain because the live compare architecture is no longer obscured by dead logic.
|
||||
- **Smallest enterprise-capable version**: Remove the dead legacy drift block and its exclusively related helpers, clean direct fallout such as unused dependencies and misleading internal descriptions, and confirm that the current strategy-driven compare behavior remains unchanged.
|
||||
- **Explicit non-goals**: No new abstraction, no naming sweep, no evidence-contract redesign, no schema change, no UI change, no new strategy, and no opportunistic follow-up refactor.
|
||||
- **Permanent complexity imported**: None beyond minimal regression coverage or comment cleanup needed to lock in the deletion.
|
||||
- **Why now**: Specs 202, 203, and 204 already established the current architecture. Leaving the old drift block behind keeps the pre-expansion foundation structurally dishonest even though the behavioral migration is complete.
|
||||
- **Why not local**: A narrower action than deletion would still leave the same dead-path ambiguity in place, so the architectural trust gap would remain.
|
||||
- **Approval class**: Cleanup
|
||||
- **Red flags triggered**: Scope-creep risk if broader naming or architecture work is mixed into the cleanup.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||
- **Decision**: approve
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace, tenant
|
||||
- **Primary Routes**:
|
||||
- No new or changed routes
|
||||
- Existing verification anchors remain:
|
||||
- `/admin/t/{tenant}/baseline-compare`
|
||||
- `/admin/operations`
|
||||
- `/admin/operations/{run}`
|
||||
- **Data Ownership**:
|
||||
- Existing tenant-owned compare runs, findings, summaries, and warnings remain authoritative.
|
||||
- No new persistence, ownership boundary, or data shape is introduced.
|
||||
- **RBAC**:
|
||||
- Existing compare, monitoring, and tenant access rules remain unchanged.
|
||||
- No new membership rule, capability, or operator-facing action is introduced.
|
||||
- No destructive action behavior changes are included.
|
||||
|
||||
## Assumptions & Dependencies
|
||||
|
||||
- Spec 203 already made strategy-driven compare execution the authoritative live path for the baseline compare orchestration unit.
|
||||
- The retained legacy drift block is not part of the productive call graph and can be removed without functional redesign.
|
||||
- Any test or inspection logic that still depends on deleted internal helper structure is considered stale and may be narrowed to current observable behavior.
|
||||
- Successful completion depends on focused regression coverage for strategy dispatch, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, and run completion.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1)
|
||||
|
||||
As a contributor reviewing baseline compare behavior, I want the orchestration unit to show only the active compare path so that I can understand current architecture without first disproving a retained legacy path.
|
||||
|
||||
**Why this priority**: This is the core value of the cleanup. If the dead path remains visible, the repository continues to teach the wrong architecture.
|
||||
|
||||
**Independent Test**: Inspect the orchestration unit after cleanup and confirm that only the active strategy-driven path remains while regression checks still pass.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the compare orchestration unit currently contains both live orchestration and retained legacy drift remnants, **When** a contributor reviews the file after cleanup, **Then** they can trace one active compare execution path without encountering a parallel legacy implementation.
|
||||
2. **Given** a reviewer follows the productive compare call graph after cleanup, **When** they inspect the orchestration flow, **Then** the repository no longer suggests that the removed pre-strategy drift logic is still active.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
|
||||
|
||||
As a product maintainer, I want the dead-code removal to leave compare behavior unchanged so that the cleanup can merge as a safe closure PR rather than another hidden refactor.
|
||||
|
||||
**Why this priority**: The cleanup is only valuable if it preserves the current compare lifecycle and does not force a second architecture review.
|
||||
|
||||
**Independent Test**: Run focused automated regression checks for the current compare flow and confirm that expected outcomes remain unchanged after the delete.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** existing baseline compare regression coverage, **When** the cleanup lands, **Then** strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, and run completion remain green.
|
||||
2. **Given** a compare run that already uses the current strategy infrastructure, **When** it executes after cleanup, **Then** it produces the same class of persisted results and operator-observable outcomes as before.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
|
||||
|
||||
As a reviewer, I want the cleanup diff to stay limited to dead-path deletion and its direct fallout so that I can approve it quickly without re-reviewing unrelated architecture decisions.
|
||||
|
||||
**Why this priority**: The main delivery risk is not deletion itself, but that the cleanup grows into an opportunistic mixed refactor.
|
||||
|
||||
**Independent Test**: Inspect the resulting PR scope and confirm that it is limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files that prove the delete is safe, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` if the implementation explicitly justifies it.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** nearby follow-up ideas exist, **When** the cleanup is implemented, **Then** the final diff touches `CompareBaselineToTenantJob`, the focused compare guard and regression files, and no other production file except a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
|
||||
2. **Given** adjacent imports, comments, or docblocks still imply the removed path exists, **When** the cleanup finishes, **Then** only those directly obsolete remnants are adjusted and no unrelated rename, schema, or UI work appears in the same PR.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A seemingly legacy helper still has an indirect productive call site or compatibility responsibility.
|
||||
- A regression test or inspection helper still asserts deleted internal structure instead of current compare behavior.
|
||||
- Summary, warning, or finding behavior depends on normalization that must remain preserved through the active path even after the dead block is removed.
|
||||
- Internal comments or docblocks still describe a fallback or alternate drift path that no longer exists.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce a new external integration, new write pathway, or new long-running workflow. It removes dead internal compare logic from an existing execution unit and keeps existing tenant isolation, run observability, and audit behavior unchanged. Regression coverage must prove there is no behavior change.
|
||||
|
||||
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The cleanup introduces no new persistence, abstraction, state family, or semantic layer. The narrowest correct implementation is deletion of the dead path and cleanup of its direct fallout.
|
||||
|
||||
**Constitution alignment (OPS-UX):** Existing run creation, lifecycle ownership, summary-count rules, and three-surface feedback behavior remain unchanged. Any touched regression coverage must continue to protect the current compare run lifecycle.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** No authorization behavior changes are part of this feature. Existing workspace and tenant access rules, including current `404` and `403` behavior, remain untouched.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Not applicable; no status or badge semantics change.
|
||||
|
||||
**Constitution alignment (UI-FIL-001):** Not applicable; no Filament or Blade surface changes are introduced.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing labels remain unchanged. Only misleading internal comments or docblocks may be corrected.
|
||||
|
||||
**Constitution alignment (DECIDE-001):** No new or changed operator-facing decision surface is introduced.
|
||||
|
||||
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** No surface or action changes are in scope.
|
||||
|
||||
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable; no header, row, or bulk action structure changes are introduced.
|
||||
|
||||
**Constitution alignment (OPSURF-001):** Not applicable; no operator-facing surface is added or materially refactored.
|
||||
|
||||
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature removes redundant legacy logic instead of adding a new interpretation layer. Tests stay focused on behavior and architectural truth rather than thin indirection.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** Not applicable.
|
||||
|
||||
**Constitution alignment (UX-001 - Layout & Information Architecture):** Not applicable.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-205-001 Single active compare path**: The baseline compare orchestration unit MUST retain only the active strategy-driven compare execution path.
|
||||
- **FR-205-002 Legacy drift removal**: The obsolete pre-strategy drift computation block retained in the orchestration unit MUST be removed.
|
||||
- **FR-205-003 Helper cleanup**: Any helper method, local utility, or internal dependency used exclusively by the removed legacy path MUST also be removed.
|
||||
- **FR-205-004 Truthful dependency surface**: After cleanup, imports, comments, and docblocks in the orchestration unit MUST reflect only currently active dependencies and behavior.
|
||||
- **FR-205-005 No behavioral reshaping**: The cleanup MUST NOT change strategy selection, compare execution, finding generation, summary computation, gap handling, warning handling, recurrence behavior, reason handling, or run completion behavior.
|
||||
- **FR-205-006 No speculative follow-up work**: The cleanup MUST NOT introduce new abstractions, naming generalizations, schema changes, UI changes, or unrelated refactors.
|
||||
- **FR-205-007 Regression proof**: Automated regression coverage MUST demonstrate that the active strategy-driven compare path still executes correctly after the cleanup.
|
||||
- **FR-205-008 Call-graph safety**: Before the cleanup is considered complete, the removed legacy path and its exclusive helpers MUST have no remaining productive call sites in the surrounding production code.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- **NFR-205-001 Reviewability**: The resulting change set MUST be limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files needed to prove delete safety, and only direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy` when explicitly justified.
|
||||
- **NFR-205-002 Architectural honesty**: After cleanup, an architecture review of the compare orchestration unit MUST find one authoritative compare execution path rather than a retained parallel legacy implementation.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-205-001**: A contributor can inspect the compare orchestration unit and identify a single active compare execution path without needing to rule out a retained parallel legacy path.
|
||||
- **SC-205-002**: Focused automated checks covering the current strategy-driven compare flow pass after the cleanup with no newly introduced failures.
|
||||
- **SC-205-003**: Review of the cleanup diff shows touched files limited to `CompareBaselineToTenantJob`, the focused compare guard and regression files, and at most a direct blocker-driven follow-up in `BaselineCompareService` or `IntuneCompareStrategy`.
|
||||
- **SC-205-004**: Post-cleanup architecture review no longer reports a retained pre-strategy drift computation block in the compare orchestration unit.
|
||||
194
specs/205-compare-job-cleanup/tasks.md
Normal file
194
specs/205-compare-job-cleanup/tasks.md
Normal file
@ -0,0 +1,194 @@
|
||||
# Tasks: Compare Job Legacy Drift Path Cleanup
|
||||
|
||||
**Input**: Design documents from `/specs/205-compare-job-cleanup/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/compare-job-legacy-drift-cleanup.logical.openapi.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Required. This cleanup changes runtime compare orchestration code in `CompareBaselineToTenantJob` and must keep the current strategy-driven compare path green through focused Pest regression and guard coverage.
|
||||
**Operations**: Existing `baseline_compare` `OperationRun` behavior remains unchanged. No new run type, feedback surface, or monitoring path is introduced.
|
||||
**RBAC**: No authorization change is in scope. Existing compare and monitoring permissions remain authoritative, and tasks must avoid introducing RBAC drift while touching the orchestration file.
|
||||
**Operator Surfaces**: No operator-facing surface change is in scope. Existing tenant compare and monitoring routes remain verification anchors only.
|
||||
**Filament UI Action Surfaces**: No Filament resource, page, relation manager, or action-hierarchy change is planned.
|
||||
**Proportionality**: This spec removes dead code only and must not introduce new abstractions, persistence, or semantic layers.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so the cleanup can be implemented and verified in narrow, reviewable increments. Recommended delivery order is `US1 -> US2 -> US3`, with `US1 + US2` forming the practical merge-ready slice.
|
||||
|
||||
## Phase 1: Setup (Shared Baseline)
|
||||
|
||||
**Purpose**: Capture the full required pre-cleanup regression baseline and inspect the active compare boundary before editing the orchestration file.
|
||||
|
||||
- [X] T001 [P] Capture the required pre-cleanup regression baseline by running `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T002 [P] Inspect the live compare dispatch and candidate legacy helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` and `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
||||
|
||||
**Checkpoint**: The team has a known-good full focused baseline and a confirmed starting map of the live compare path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Call-Graph Confirmation)
|
||||
|
||||
**Purpose**: Confirm the dead-vs-live method boundary so the cleanup deletes only unreachable logic.
|
||||
|
||||
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||
|
||||
- [X] T003 [P] Map exclusive callers for `computeDrift()` and its adjacent helper cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T004 [P] Review `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm the live strategy contract needs no structural change for this cleanup
|
||||
|
||||
**Checkpoint**: The delete list is confirmed and the live strategy-owned path is explicitly out of scope for redesign.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Read the live compare architecture without dead-path noise (Priority: P1) MVP
|
||||
|
||||
**Goal**: Remove the retained monolithic drift-compute path so the compare job shows one real execution engine instead of a parallel historical implementation.
|
||||
|
||||
**Independent Test**: Inspect `apps/platform/app/Jobs/CompareBaselineToTenantJob.php` after cleanup and confirm that the live compare path still flows through strategy selection and `strategy->compare(...)`, while the legacy helper names are absent and the guard suite passes.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
> **NOTE**: Update these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T005 [P] [US1] Extend legacy helper absence assertions in `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`
|
||||
- [X] T006 [P] [US1] Reconfirm one-engine orchestration guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T007 [US1] Remove `computeDrift()` and its exclusive helper cluster from `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T008 [US1] Remove dead imports and stale fallback comments or docblocks left by the deleted cluster in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T009 [US1] Re-run the guard coverage in `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php` and `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php` against `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
|
||||
**Checkpoint**: The compare job is structurally honest again and the guard suite blocks reintroduction of the deleted legacy path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Preserve current compare behavior while removing dead code (Priority: P1)
|
||||
|
||||
**Goal**: Prove that the cleanup leaves strategy selection, compare execution, finding lifecycle behavior, summary computation, gap handling, warning handling, reason translation, and run completion unchanged.
|
||||
|
||||
**Independent Test**: Run the required focused regression slice and confirm that `FindingFidelityTest`, `BaselineCompareFindingsTest`, `BaselineCompareGapClassificationTest`, `BaselineCompareWhyNoFindingsReasonCodeTest`, `Spec116OneEngineGuardTest`, `Spec118NoLegacyBaselineDriftGuardTest`, `OperationLifecycleOpsUxGuardTest`, `BaselineOperationRunGuardTest`, `OperationSummaryKeysSpecTest`, `SummaryCountsWhitelistTest`, and the matrix enqueue-path check all remain green after the delete.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
> **NOTE**: Update these tests first and confirm they fail before implementation.
|
||||
|
||||
- [X] T010 [P] [US2] Tighten strategy-driven execution assertions in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`
|
||||
- [X] T011 [P] [US2] Tighten finding lifecycle, recurrence, and summary outcome assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
|
||||
- [X] T012 [P] [US2] Tighten gap classification, warning-outcome, and reason-code assertions in `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`
|
||||
- [X] T013 [P] [US2] Reconfirm `OperationRun` lifecycle and summary-count guard coverage in `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, and `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T014 [US2] Keep live compare behavior unchanged while reconciling any cleanup fallout in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`; do not modify `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` unless a blocker proves the smallest direct fix is required
|
||||
- [X] T015 [US2] Run the required focused regression slice in `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
|
||||
**Checkpoint**: The cleanup is behaviorally safe and the current compare lifecycle still works through the strategy-owned path.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Keep the review diff mechanically narrow (Priority: P2)
|
||||
|
||||
**Goal**: Keep the cleanup PR reviewable by limiting the touched area to dead-path deletion, direct fallout cleanup, and minimal regression updates.
|
||||
|
||||
**Independent Test**: Inspect the final changed-file set and confirm that it is limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` if justified.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T016 [P] [US3] Audit the touched-file set and strip opportunistic edits outside `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
- [X] T017 [P] [US3] Review `apps/platform/app/Services/Baselines/BaselineCompareService.php` and `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` to confirm they remain unchanged or carry only the smallest blocker-driven follow-up required by the cleanup
|
||||
- [X] T018 [US3] Verify the final diff stays limited to `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, the focused compare guard and regression files above including `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, and only direct blocker-driven follow-up in `apps/platform/app/Services/Baselines/BaselineCompareService.php` or `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php`
|
||||
|
||||
**Checkpoint**: The PR stays small, reviewable, and aligned with Spec 205 rather than drifting into a broader compare refactor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Apply formatting and rerun the final focused Sail pack before handing the cleanup over for review.
|
||||
|
||||
- [X] T019 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched PHP files centered on `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
|
||||
- [X] T020 Run the final focused Sail pack from `specs/205-compare-job-cleanup/quickstart.md` covering `apps/platform/tests/Feature/BaselineDriftEngine/FindingFidelityTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareGapClassificationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareWhyNoFindingsReasonCodeTest.php`, `apps/platform/tests/Feature/Guards/OperationLifecycleOpsUxGuardTest.php`, `apps/platform/tests/Feature/Operations/BaselineOperationRunGuardTest.php`, `apps/platform/tests/Feature/OpsUx/OperationSummaryKeysSpecTest.php`, `apps/platform/tests/Feature/OpsUx/SummaryCountsWhitelistTest.php`, `apps/platform/tests/Feature/Guards/Spec116OneEngineGuardTest.php`, `apps/platform/tests/Feature/Guards/Spec118NoLegacyBaselineDriftGuardTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work.
|
||||
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||
- **User Story 2 (Phase 4)**: Depends on User Story 1 because behavior preservation is verified after the delete lands.
|
||||
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because scope review is only meaningful once the delete, gap and reason coverage, and run-guard updates exist.
|
||||
- **Polish (Phase 6)**: Depends on all user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1**: No dependency beyond Foundational.
|
||||
- **US2**: Depends on US1 because the focused regression slice validates the actual cleanup result.
|
||||
- **US3**: Depends on US1 and US2 because it is the final scope-control pass over the implemented cleanup.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Update or tighten the story's tests first and confirm they fail before implementation.
|
||||
- Keep compare start orchestration in `apps/platform/app/Services/Baselines/BaselineCompareService.php` and live strategy behavior in `apps/platform/app/Support/Baselines/Compare/IntuneCompareStrategy.php` out of scope unless a blocker demands the smallest possible fix.
|
||||
- Finish each story's focused verification before moving to the next story.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- `T001` and `T002` can run in parallel.
|
||||
- `T003` and `T004` can run in parallel.
|
||||
- Within US1, `T005` and `T006` can run in parallel.
|
||||
- Within US2, `T010`, `T011`, `T012`, and `T013` can run in parallel.
|
||||
- Within US3, `T016` and `T017` can run in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Parallel guard updates for US1
|
||||
T005 Extend legacy helper absence assertions in Spec118NoLegacyBaselineDriftGuardTest.php
|
||||
T006 Reconfirm one-engine orchestration guard coverage in Spec116OneEngineGuardTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Parallel regression tightening for US2
|
||||
T010 Tighten strategy-driven execution assertions in FindingFidelityTest.php
|
||||
T011 Tighten finding lifecycle and summary outcome assertions in BaselineCompareFindingsTest.php
|
||||
T012 Tighten gap classification and reason-code assertions in BaselineCompareGapClassificationTest.php and BaselineCompareWhyNoFindingsReasonCodeTest.php
|
||||
T013 Reconfirm OperationRun lifecycle and summary-count guard coverage in OperationLifecycleOpsUxGuardTest.php, BaselineOperationRunGuardTest.php, OperationSummaryKeysSpecTest.php, and SummaryCountsWhitelistTest.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Parallel scope review for US3
|
||||
T016 Audit the touched-file set and strip opportunistic edits outside the focused cleanup files
|
||||
T017 Review BaselineCompareService.php and IntuneCompareStrategy.php for blocker-only follow-up changes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Complete Setup and Foundational work.
|
||||
2. Deliver US1 to remove the dead path and restore a single truthful compare engine in the orchestration file.
|
||||
3. Immediately follow with US2 so the cleanup is merge-safe, not just structurally cleaner.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish US1 and confirm the guard suite blocks the deleted helper cluster.
|
||||
2. Finish US2 and prove the live strategy-driven compare behavior remains unchanged.
|
||||
3. Finish US3 to keep the cleanup PR mechanically narrow.
|
||||
4. Finish with formatting and the final focused Sail pack from Phase 6.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor handles Setup and Foundational call-graph confirmation.
|
||||
2. After Foundation is green:
|
||||
T005 and T006 can be prepared in parallel for US1.
|
||||
T010, T011, T012, and T013 can be prepared in parallel for US2.
|
||||
T016 and T017 can be prepared in parallel for US3 once the cleanup diff exists.
|
||||
3. Merge back for the final diff review, formatting, and focused Sail verification.
|
||||
Loading…
Reference in New Issue
Block a user